# Generated from the walkthrough published at https://pki.dicomserver.com/signature
# Run the steps below in order. Lines prefixed with "# $ " are shell commands
# meant to be run yourself in a terminal, not Python. Step 8 is optional.
# "# %%" separators are percent-format cell markers: editors that support
# them (VS Code, PyCharm, Spyder, Jupytext) run each step as a notebook cell;
# as a plain script the file runs top-to-bottom unchanged.

# $ python3 -m pip install base45 cbor2
# $ openssl version   # any recent OpenSSL 1.1.1+/3.x works

# $ printf '%s' 'DS1:6BF...' > token.txt

# %% Step 1 — Strip the `DS1:` prefix

token = open('token.txt').read().strip()

PREFIX = 'DS1:'
if not token.startswith(PREFIX):
    raise SystemExit(f'reject: missing/unknown scheme prefix (expected {PREFIX!r})')

b45_payload = token[len(PREFIX):]

# %% Step 2 — Base45-decode (RFC 9285)

import base45

deflated = base45.b45decode(b45_payload)

_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
_REVERSE = {c: i for i, c in enumerate(_ALPHABET)}


def base45_decode(s: str) -> bytes:
    if len(s) % 3 == 1:
        raise ValueError('invalid base45 length (remainder of 1 is never valid)')

    out = bytearray()
    for i in range(0, len(s), 3):
        group = s[i:i + 3]
        try:
            values = [_REVERSE[c] for c in group]
        except KeyError as exc:
            raise ValueError(f'character not in base45 alphabet: {exc}') from None

        if len(group) == 3:
            value = values[0] + values[1] * 45 + values[2] * 2025
            if value > 0xFFFF:
                raise ValueError('base45 value out of range for a 2-byte group')
            out += value.to_bytes(2, 'big')
        else:
            value = values[0] + values[1] * 45
            if value > 0xFF:
                raise ValueError('base45 value out of range for a 1-byte group')
            out.append(value)

    return bytes(out)


# deflated = base45_decode(b45_payload)

# %% Step 3 — Raw-DEFLATE inflate, size-bounded (RFC 1951)

import zlib

MAX_INFLATED_BYTES = 8192


def bounded_raw_inflate(data: bytes, cap: int = MAX_INFLATED_BYTES) -> bytes:
    decompressor = zlib.decompressobj(wbits=-15)
    out = decompressor.decompress(data, cap)

    if len(out) >= cap:
        raise ValueError('reject: inflated payload reached the cap (bomb/truncation-ambiguous)')
    if decompressor.unconsumed_tail:
        raise ValueError('reject: inflated payload exceeds the size cap')
    if not decompressor.eof:
        raise ValueError('reject: truncated or corrupt DEFLATE stream')

    return out


cose_bytes = bounded_raw_inflate(deflated)

# %% Step 4 — Decode the COSE_Sign structure (CBOR tag 98, RFC 9052)

import json
import cbor2
from cbor2 import CBORTag

decoded = cbor2.loads(cose_bytes)
if not (isinstance(decoded, CBORTag) and decoded.tag == 98):
    raise SystemExit('reject: not a COSE_Sign (CBOR tag 98) object')

body_protected_bytes, body_unprotected, payload_bytes, signatures = decoded.value
if body_unprotected != {}:
    raise SystemExit('reject: unexpected non-empty unprotected body header')

body_protected = cbor2.loads(body_protected_bytes)
kid = body_protected[4]  # RFC 9052 §3.1 header label 4 = key identifier
print('kid (hex):', kid.hex())

payload = json.loads(payload_bytes.decode('utf-8'))
print(json.dumps(payload, indent=2, ensure_ascii=False))

parsed_signatures = []
for sign_protected_bytes, sign_unprotected, signature in signatures:
    if sign_unprotected != {}:
        raise SystemExit('reject: unexpected non-empty unprotected signature header')

    claims = cbor2.loads(sign_protected_bytes)
    if claims.get(1) != -7:
        raise SystemExit('reject: unexpected signature algorithm, expected ES256 (-7)')

    print('signer claims:', claims)
    parsed_signatures.append(
        {
            'sign_protected_bytes': sign_protected_bytes,
            'claims': claims,
            'signature': signature,
        }
    )

# %% Step 5 — Locate the leaf certificate by KID

# $ curl -sSfLo leaf-bundle-2026.pem https://pki.dicomserver.com/leaf-bundle-2026.pem

# $ openssl x509 -in candidate.pem -outform DER | sha256sum

import re
import subprocess

year = parsed_signatures[0]['claims']['y']
bundle_path = f'leaf-bundle-{year}.pem'

bundle_text = open(bundle_path).read()
cert_blocks = re.findall(
    r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
    bundle_text,
    re.S,
)

leaf_pem = None
for i, cert_pem in enumerate(cert_blocks):
    der = subprocess.run(
        ['openssl', 'x509', '-outform', 'DER'],
        input=cert_pem.encode('ascii'),
        capture_output=True,
        check=True,
    ).stdout
    fingerprint = subprocess.run(
        ['openssl', 'dgst', '-sha256', '-binary'],
        input=der,
        capture_output=True,
        check=True,
    ).stdout

    if fingerprint == kid:
        leaf_pem = cert_pem
        print(f'matched leaf certificate #{i} in {bundle_path}')
        break

if leaf_pem is None:
    raise SystemExit(f'reject: no certificate in {bundle_path} matches kid {kid.hex()}')

with open('leaf.pem', 'w') as f:
    f.write(leaf_pem)

# %% Step 6 — Chain the leaf to the trust bundle

# $ curl -sSfLo ca-bundle.pem https://pki.dicomserver.com/ca-bundle.pem
# $ openssl verify -CAfile ca-bundle.pem leaf.pem

# %% Step 7 — Rebuild the Sig_structure and verify the signature

signer = parsed_signatures[0]

sig_structure_bytes = cbor2.dumps(
    [
        'Signature',
        body_protected_bytes,
        signer['sign_protected_bytes'],
        b'',  # external_aad — always empty in this scheme
        payload_bytes,
    ]
)

with open('sigstructure.bin', 'wb') as f:
    f.write(sig_structure_bytes)

def _encode_der_length(length: int) -> bytes:
    if length < 0x80:
        return bytes([length])
    encoded = b''
    while length > 0:
        encoded = bytes([length & 0xFF]) + encoded
        length >>= 8
    return bytes([0x80 | len(encoded)]) + encoded


def _encode_der_integer(magnitude: bytes) -> bytes:
    magnitude = magnitude.lstrip(b'\x00') or b'\x00'
    if magnitude[0] & 0x80:
        magnitude = b'\x00' + magnitude  # keep it a positive INTEGER in DER
    return b'\x02' + _encode_der_length(len(magnitude)) + magnitude


def raw_to_der(raw: bytes) -> bytes:
    if len(raw) != 64:
        raise ValueError('expected a 64-byte raw r||s ECDSA P-256 signature')
    r, s = raw[:32], raw[32:]
    body = _encode_der_integer(r) + _encode_der_integer(s)
    return b'\x30' + _encode_der_length(len(body)) + body


sig_der = raw_to_der(signer['signature'])
with open('sig.der', 'wb') as f:
    f.write(sig_der)

# $ openssl x509 -pubkey -noout -in leaf.pem > pub.pem
# $ openssl dgst -sha256 -verify pub.pem -signature sig.der sigstructure.bin

# %% Step 8 (optional) — Recompute `text_sha256` from a transcription

import hashlib
import re
import unicodedata

# The 25 Unicode White_Space code points - do not substitute a runtime's
# native whitespace class (see the DS Text Profile v1 page's portability note).
_WHITE_SPACE_RE = re.compile(
    '[\t\n\x0b\x0c\r\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+'
)

transcribed = open('transcription.txt', encoding='utf-8').read()

normalized = unicodedata.normalize('NFKC', transcribed)
folded = normalized.casefold()  # Python's casefold() is full, locale-independent
collapsed = _WHITE_SPACE_RE.sub(' ', folded).strip()
computed = hashlib.sha256(collapsed.encode('utf-8')).hexdigest()
expected = payload['finding'].get('text_sha256')

print('computed:', computed)
print('expected:', expected)
print('MATCH' if computed == expected else 'MISMATCH — verify online or re-transcribe')
