#!/usr/bin/env python3
"""Standalone offline verifier for Frederick & Sons Trust Registry records.

stdlib + `cryptography` only — NO imports from registry/, nothing phoning home. This file
INTENTIONALLY DUPLICATES registry/canonical.py's canonicalization, did:key, and verify_record
logic (same pattern as verify_attribution_signature.py <-> attribution_signing.py) so a third
party can check a record's id + signature + issuer binding without installing or trusting
anything of ours. If registry/canonical.py's canonicalization ever changes, this file must change
identically, byte for byte — registry/testvectors/vectors.json + registry/tests/test_verifiers_agree.py
are what catch a silent divergence between the two copies.

    python verify_registry_record.py record.json     # or pipe JSON on stdin

Prints VALID or INVALID (with reasons), exits 0 / 1. Checks ONLY the record's own self-signature
(design doc SS4.3) — NOT log-inclusion (that's a separate check: fetch /registry/v1/log, replay the
hash chain, confirm the wallet-signed root) and NOT whether the issuer is a "wallet-anchored"
trust-tier vs merely self-asserted (compare record.issuer against /.well-known/attestations.json
yourself for that).
"""
from __future__ import annotations

import hashlib
import json
import sys
from dataclasses import dataclass
from typing import Any

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

# --- JCS-safe subset guard (audit fix F1) -----------------------------------------------------
#
# RFC 8785 SS3.2.1's numeric rule (JSON numbers must round-trip through an IEEE-754 double the way
# every other conformant implementation would) has no clean answer for float/NaN/Infinity and no
# safe answer for integers a double can't represent exactly -- the universal fix (used by every
# "JCS-safe subset" profile, not invented here) is to forbid floats outright and cap integers to
# +/-(2**53-1), the largest magnitude a double represents exactly. Object keys get a second,
# narrower rule: json.dumps(sort_keys=True) sorts dict keys by Python str comparison, which orders
# by Unicode CODE POINT; RFC 8785 SS3.2.3 requires object members sorted by their UTF-16 CODE UNIT
# sequence. The two orderings agree for keys made only of Basic-Multilingual-Plane characters
# (code point < 0x10000) but can disagree once a key contains an astral character (encoded as a
# UTF-16 surrogate pair, whose high half sorts differently by code-unit vs. by the astral
# character's full code point) -- forbidding astral-plane key characters sidesteps having to
# implement a second, UTF-16-code-unit-based comparator just for this one edge case.
_JCS_MAX_INT = (2**53) - 1   # largest integer magnitude an IEEE-754 double represents exactly
_JCS_MIN_INT = -((2**53) - 1)
_ASTRAL_PLANE_START = 0x10000  # first code point requiring a UTF-16 surrogate pair


def assert_jcs_safe(obj: Any) -> None:
    """Recursively raise ValueError if `obj` contains anything outside the subset of JSON this
    module's canonical_bytes() can serialize as provably-conformant RFC 8785 JCS: str, bool, None,
    int within +/-(2**53-1), list, or dict with string keys containing no astral-plane code point.
    Floats (including NaN/Infinity/-Infinity, which are all Python `float` regardless of value) are
    rejected outright; bool is allowed despite being an int subclass (checked before the int
    branch below, or it would silently fall into the int-range check instead).

    MUST be called at the top of canonical_bytes() in BOTH this file and its twin
    registry/canonical.py -- identical code, copied byte for byte (see module docstring's twin-file
    discipline) -- so every canonicalization/verify path in both implementations enforces the
    identical subset. A guard that only one twin ran would itself be exactly the kind of silent
    verifier divergence this whole file exists to prevent.
    """
    if isinstance(obj, bool):
        return
    if isinstance(obj, float):
        raise ValueError(f"JCS-unsafe: float value {obj!r} (float/NaN/Infinity have no JCS representation)")
    if isinstance(obj, int):
        if not (_JCS_MIN_INT <= obj <= _JCS_MAX_INT):
            raise ValueError(f"JCS-unsafe: integer {obj!r} exceeds +/-(2**53-1)")
        return
    if obj is None or isinstance(obj, str):
        return
    if isinstance(obj, list):
        for item in obj:
            assert_jcs_safe(item)
        return
    if isinstance(obj, dict):
        for key, value in obj.items():
            if not isinstance(key, str):
                raise ValueError(f"JCS-unsafe: dict key {key!r} is not a string")
            if any(ord(ch) >= _ASTRAL_PLANE_START for ch in key):
                raise ValueError(f"JCS-unsafe: dict key {key!r} contains an astral-plane (>= U+10000) code point")
            assert_jcs_safe(value)
        return
    raise ValueError(f"JCS-unsafe: value of type {type(obj).__name__} is not JSON-safe: {obj!r}")


# --- Canonicalization -------------------------------------------------------------------------
#
# NOT byte-identical to fifty50/core/attribution_signing.py:canonical_result_bytes() on non-ASCII
# input -- Fifty50's version omits ensure_ascii=False (verified 2026-07: it's a bare
# json.dumps(sort_keys=True, separators=(",",":"))), so IT \uXXXX-escapes non-ASCII while this line
# emits literal UTF-8 bytes for the same string. ensure_ascii=False is what RFC 8785 JCS actually
# requires, so THIS line is the conformant one and Fifty50's is not JCS. The byte-identical pairing
# that matters for THIS file is with its own twin, registry/canonical.py. Only a conformant JCS
# serializer for input that has passed assert_jcs_safe() (above) first -- that's why the call below
# is not optional.


def canonical_bytes(obj: Any) -> bytes:
    assert_jcs_safe(obj)
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


# --- Record envelope -----------------------------------------------------------------------------

SIGNED_OVER = "fas-registry-record/v1"
REQUIRED_TOP_KEYS = frozenset({"fas_registry", "type", "id", "issuer", "subject", "issued_at", "body", "proof"})
REQUIRED_PROOF_KEYS = frozenset({"algo", "signed_over", "public_key", "signature"})
RECORD_TYPES = frozenset({"identity", "attestation", "receipt", "rotation", "revocation"})


def record_id(record: dict) -> str:
    body_for_hash = {k: v for k, v in record.items() if k not in ("id", "proof")}
    return hashlib.sha256(canonical_bytes(body_for_hash)).hexdigest()


# --- did:key (ed25519, multicodec 0xed01, base58btc, 'z' multibase prefix) ---------------------

# multicodec varint(0xed) == bytes(0xED, 0x01) -- fixed 2-byte tag; makes every ed25519 did:key
# start "z6Mk...". Hardcoded per the did:key spec (W3C CCG) rather than a multicodec dependency.
ED25519_MULTICODEC_PREFIX = bytes((0xED, 0x01))
DID_KEY_PREFIX = "did:key:z"

_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"  # base58btc, no 0/O/I/l
_B58_INDEX = {ch: i for i, ch in enumerate(_B58_ALPHABET)}


def b58encode(data: bytes) -> str:
    n_pad = len(data) - len(data.lstrip(b"\x00"))
    num = int.from_bytes(data, "big")
    out = []
    while num:
        num, rem = divmod(num, 58)
        out.append(_B58_ALPHABET[rem])
    out.reverse()
    return ("1" * n_pad) + "".join(out)


def b58decode(s: str) -> bytes:
    if not s:
        return b""
    n_pad = len(s) - len(s.lstrip("1"))
    num = 0
    for ch in s:
        if ch not in _B58_INDEX:
            raise ValueError(f"invalid base58btc character: {ch!r}")
        num = num * 58 + _B58_INDEX[ch]
    body = num.to_bytes((num.bit_length() + 7) // 8, "big") if num else b""
    return (b"\x00" * n_pad) + body


def did_key_from_pubkey_hex(pubkey_hex: str) -> str:
    try:
        raw = bytes.fromhex(pubkey_hex)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"public_key is not valid hex: {exc}") from exc
    if len(raw) != 32:
        raise ValueError(f"ed25519 public key must be 32 raw bytes, got {len(raw)}")
    return DID_KEY_PREFIX + b58encode(ED25519_MULTICODEC_PREFIX + raw)


def pubkey_hex_from_did_key(did: str) -> str:
    if not did.startswith(DID_KEY_PREFIX):
        raise ValueError(f"not an ed25519 did:key (expected prefix {DID_KEY_PREFIX!r}): {did!r}")
    payload = b58decode(did[len(DID_KEY_PREFIX):])
    if payload[:2] != ED25519_MULTICODEC_PREFIX or len(payload) != 34:
        raise ValueError("did:key does not decode to a 2-byte 0xed01 prefix + 32-byte ed25519 key")
    return payload[2:].hex()


# --- Offline-verify algorithm (design doc SS4.3) ------------------------------------------------


@dataclass(frozen=True)
class VerifyOutcome:
    valid: bool
    computed_id: str | None
    reasons: tuple[str, ...]


def verify_record(record: Any) -> VerifyOutcome:
    if not isinstance(record, dict):
        return VerifyOutcome(False, None, ("record is not a JSON object",))

    extra = set(record.keys()) - REQUIRED_TOP_KEYS
    missing = REQUIRED_TOP_KEYS - record.keys()
    if missing or extra:
        reasons = []
        if missing:
            reasons.append(f"missing required field(s): {sorted(missing)}")
        if extra:
            reasons.append(f"unexpected top-level field(s): {sorted(extra)}")
        return VerifyOutcome(False, None, tuple(reasons))

    if record.get("fas_registry") != "v1":
        return VerifyOutcome(False, None, (f"unsupported fas_registry version: {record.get('fas_registry')!r}",))
    if record.get("type") not in RECORD_TYPES:
        return VerifyOutcome(False, None, (f"unknown record type: {record.get('type')!r}",))

    proof = record.get("proof")
    if not isinstance(proof, dict):
        return VerifyOutcome(False, None, ("proof must be an object",))
    # Closed key-set (audit fix F8): previously only MISSING required keys were checked, never
    # extra ones -- so arbitrary unsigned data could ride in proof.* (e.g. proof.note="trust me")
    # and be stored/served forever next to a record that reports as "verified", since proof is
    # stripped before hashing and nothing else ever looks at its keys. Closing the set here mirrors
    # the top-level envelope's own closed-key check above (missing/extra handled together).
    missing_proof = REQUIRED_PROOF_KEYS - proof.keys()
    extra_proof = proof.keys() - REQUIRED_PROOF_KEYS
    if missing_proof or extra_proof:
        proof_reasons = []
        if missing_proof:
            proof_reasons.append(f"missing proof field(s): {sorted(missing_proof)}")
        if extra_proof:
            proof_reasons.append(f"unexpected proof field(s): {sorted(extra_proof)}")
        return VerifyOutcome(False, None, tuple(proof_reasons))

    if proof.get("algo") != "ed25519":
        return VerifyOutcome(False, None, (f"unsupported proof.algo: {proof.get('algo')!r}",))
    if proof.get("signed_over") != SIGNED_OVER:
        return VerifyOutcome(False, None, (f"unexpected proof.signed_over: {proof.get('signed_over')!r}",))

    body_for_hash = {k: v for k, v in record.items() if k not in ("id", "proof")}
    try:
        canonical_body = canonical_bytes(body_for_hash)
    except (TypeError, ValueError) as exc:
        return VerifyOutcome(False, None, (f"record is not JSON-canonicalizable: {exc}",))
    computed_id = hashlib.sha256(canonical_body).hexdigest()

    if record.get("id") != computed_id:
        return VerifyOutcome(False, computed_id, ("id does not match sha256(JCS(record minus id/proof))",))

    pub_hex = proof.get("public_key")
    sig_hex = proof.get("signature")
    if not isinstance(pub_hex, str) or not isinstance(sig_hex, str):
        return VerifyOutcome(False, computed_id, ("proof.public_key / proof.signature must be hex strings",))
    try:
        pub_raw = bytes.fromhex(pub_hex)
        sig_raw = bytes.fromhex(sig_hex)
    except ValueError:
        return VerifyOutcome(False, computed_id, ("proof.public_key / proof.signature are not valid hex",))
    if len(pub_raw) != 32:
        return VerifyOutcome(False, computed_id, (f"proof.public_key must be 32 raw bytes, got {len(pub_raw)}",))
    if len(sig_raw) != 64:
        return VerifyOutcome(False, computed_id, (f"proof.signature must be 64 raw bytes, got {len(sig_raw)}",))

    try:
        public_key = Ed25519PublicKey.from_public_bytes(pub_raw)
        # See registry/canonical.py's matching comment: this is the only ed25519 verify path
        # `cryptography` exposes (OpenSSL-backed, RFC 8032 canonical decode -- rejects S >= L).
        # Treated as "verify_strict" per the design doc; not independently vector-tested against
        # ed25519-dalek's exact edge cases. Flagged for audit in the build handoff.
        #
        # Audit disclosure (2026-07 fix pass, no behavior change): this OpenSSL-backed path is NOT
        # vector-confirmed equivalent to ed25519-dalek's verify_strict on small-order/non-canonical
        # points. v1-core-acceptable because every record type in this build is self-signed
        # identity/attestation data with no money attached -- but this must NOT be read as "full
        # dalek parity, safe for anything." Wycheproof ed25519 test-vector cross-verification
        # against ed25519-dalek's actual small-order/non-canonical-point handling is a REQUIRED
        # gate before a `receipt` record type (money-adjacent) ships.
        public_key.verify(sig_raw, canonical_body)
    except InvalidSignature:
        return VerifyOutcome(False, computed_id, ("signature does not verify",))
    except Exception as exc:
        return VerifyOutcome(False, computed_id, (f"signature verification error: {exc}",))

    try:
        expected_did = did_key_from_pubkey_hex(pub_hex)
    except ValueError as exc:
        return VerifyOutcome(False, computed_id, (f"cannot derive did:key from proof.public_key: {exc}",))
    if record.get("issuer") != expected_did:
        return VerifyOutcome(False, computed_id, ("record.issuer does not match did:key(proof.public_key)",))

    return VerifyOutcome(True, computed_id, ())


def main(argv: list[str] | None = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    try:
        if argv:
            with open(argv[0], "r", encoding="utf-8") as f:
                data = json.load(f)
        else:
            data = json.load(sys.stdin)
    except Exception as exc:
        print(f"INVALID (could not read/parse input: {exc})")
        return 1

    outcome = verify_record(data)
    if outcome.valid:
        print(f"VALID  id={outcome.computed_id}")
        return 0
    print("INVALID")
    for reason in outcome.reasons:
        print(f"  - {reason}")
    if outcome.computed_id:
        print(f"  (computed id from content: {outcome.computed_id})")
    return 1


if __name__ == "__main__":
    sys.exit(main())
