#!/usr/bin/env python3
# Copyright (c) 2024-2026 CYRBER Sp. z o.o. All rights reserved.
# Licensed under the Business Source License 1.1 - see LICENSE file.

"""
cyrber-verify - standalone CLI to verify CYRBER cryptographic seals.

Verifies three artefact kinds, and CYRBER uses TWO deliberately separate
Ed25519 keys (#2615):
  1. Trust snapshot - published daily on https://trust.cyrber.com. Signed with
     the TRUST key, published at /api/public/trust/public-key.
  2. Mission seal (court-pack) - exported from your CYRBER deploy. Signed with
     the TRUST key over the raw 32-byte root.
  3. Leaf proof - /api/public/trust/proof/{scan}/{finding}. Signed with the
     SIGNING key (SIGILLUM), published at /api/public/trust/testis. The bundle
     self-describes its signer in public_key_hex; the CLI pins that field
     against /testis (or --pubkey) and refuses a non-canonical embedded key.

All use Ed25519 (RFC 8032). The CLI fetches the right canonical key per artefact
(snapshot/seal -> /public-key, proof -> /testis) OR accepts an operator-provided
pubkey hex (--pubkey) for offline air-gap verification. A fetched key is pinned:
its fingerprint must match a pin baked into this script and also published
out-of-band at cyrber.com/bezpieczenstwo; a mismatch is refused so a vendor cannot swap a
key alongside the signature (#2618). Use --pubkey to override a real rotation.

Usage:
    cyrber-verify snapshot snapshot.json
    cyrber-verify snapshot snapshot.json --pubkey c3fb50d0...
    cyrber-verify seal mission_seal.json
    cyrber-verify proof leaf_proof.json           # pins bundle key against /testis
    cyrber-verify pubkey                          # print the canonical trust pubkey

Zero deps beyond stdlib + PyNaCl. Install PyNaCl on a fresh box:
    pip install pynacl    # ~6MB, no native build, pure libsodium binding

A trust snapshot also carries an OpenTimestamps anchor. This CLI classifies it
without a network call or the OTS library: it tells a calendar receipt (pending)
from a Bitcoin block attestation and never calls a calendar receipt a Bitcoin
confirmation. For a full Bitcoin check run `ots verify` on the proof.

Exit codes:
    0  VERIFIED (signature valid; timestamp is a calendar receipt or better, or
       the snapshot claims no timestamp)
    1  INVALID  (signature does not match)
    2  TAMPERED (recomputed hash differs from claimed hash)
    3  USAGE / FILE / NETWORK error
    4  UNANCHORED (snapshot claims an OTS proof but it holds no valid attestation,
       or --require-bitcoin was set and only a calendar receipt is present)
"""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
from typing import Any, Dict, Optional

try:
    from nacl.signing import VerifyKey
    from nacl.exceptions import BadSignatureError
except ImportError:
    print("ERROR: PyNaCl required. Install: pip install pynacl", file=sys.stderr)
    sys.exit(3)


CANONICAL_PUBKEY_URL = "https://trust.cyrber.com/api/public/trust/public-key"
# Leaf proofs (cmd_proof) and mission SIGILLUM roots are signed with the SIGNING
# key, NOT the trust snapshot key served at /public-key. The signing pubkey is
# published at /testis. Two deliberately separate keys, see #2615.
CANONICAL_TESTIS_URL = "https://trust.cyrber.com/api/public/trust/testis"
USER_AGENT = "cyrber-verify/1.0"

# Key pins (#2618). These fingerprints are also published out-of-band at
# https://cyrber.com/bezpieczenstwo (Cloudflare Pages, a different origin than the FastAPI
# app that mints and serves the artefacts). A fetched key whose fingerprint does
# not match its pin is refused, so a vendor cannot silently swap a key alongside
# the signature. A legitimate rotation is a reviewable change to these constants
# (or use --pubkey to override). Verify these against cyrber.com/bezpieczenstwo.
PINNED_TRUST_PUBKEY = "c3fb50d05071f5d3d1c475dece12023f78ae100c2f7796baf670226373a86794"
PINNED_SIGNING_PUBKEY = "611ad5997df9001d1263f9e0ff4c98629f2b84d433160f398e3c865198202e84"


# ── Helpers ─────────────────────────────────────────────────────────────


def _print(label: str, value: str) -> None:
    print(f"{label:18s} {value}")


def _err(msg: str) -> None:
    print(f"ERROR: {msg}", file=sys.stderr)


def _check_pin(fetched: str, pinned: str, kind: str) -> str:
    """Refuse a fetched key whose fingerprint does not match its out-of-band pin (#2618).

    The pin is also published at cyrber.com/bezpieczenstwo (a different origin than the app
    that serves the artefact), so a vendor who swaps a key on the artefact origin
    cannot silently change this pin baked into the git-tracked verifier. Pass
    --pubkey to override a legitimate rotation deliberately.
    """
    f = (fetched or "").strip().lower()
    p = (pinned or "").strip().lower()
    if p and f != p:
        raise RuntimeError(
            f"KEY PIN MISMATCH ({kind}): fetched fp {_fingerprint(f)} != pinned fp "
            f"{_fingerprint(p)}. The key may have rotated or been substituted; verify "
            f"against cyrber.com/bezpieczenstwo out-of-band, then update the pin or pass --pubkey."
        )
    return f


def _fetch_canonical_pubkey() -> str:
    """Fetch pubkey hex from trust.cyrber.com and check it against the pin. Network required."""
    try:
        req = urllib.request.Request(CANONICAL_PUBKEY_URL, headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=5) as r:
            payload = json.loads(r.read())
            return _check_pin(str(payload["public_key"]), PINNED_TRUST_PUBKEY, "trust")
    except urllib.error.URLError as exc:
        raise RuntimeError(f"network: {exc.reason}") from exc
    except (KeyError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"unexpected payload: {exc}") from exc


def _resolve_pubkey(arg: Optional[str]) -> str:
    """Either honour explicit --pubkey, or fetch canonical."""
    if arg:
        key = arg.strip().lower()
        if len(key) != 64:
            raise ValueError(f"pubkey must be 64 hex chars (Ed25519 32 bytes), got {len(key)}")
        int(key, 16)  # validate hex
        return key
    print("[INFO] fetching canonical pubkey from trust.cyrber.com ...", file=sys.stderr)
    return _fetch_canonical_pubkey()


def _fetch_canonical_signing_pubkey() -> str:
    """Fetch the SIGNING pubkey hex (leaf-proof signer) from /testis. Network required."""
    try:
        req = urllib.request.Request(CANONICAL_TESTIS_URL, headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=5) as r:
            payload = json.loads(r.read())
            key = str(payload.get("public_key_hex") or payload.get("public_key") or "").strip().lower()
            if len(key) != 64:
                raise RuntimeError(f"testis pubkey must be 64 hex chars, got {len(key)}")
            int(key, 16)  # validate hex
            return _check_pin(key, PINNED_SIGNING_PUBKEY, "signing")
    except urllib.error.URLError as exc:
        raise RuntimeError(f"network: {exc.reason}") from exc
    except (KeyError, ValueError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"unexpected payload: {exc}") from exc


def _resolve_signing_pubkey(arg: Optional[str]) -> str:
    """Resolve the SIGNING key used to pin leaf proofs: explicit --pubkey, else /testis.

    Leaf proofs are signed with the signing key, so the default fetch targets
    /testis (NOT /public-key, which serves the trust snapshot key), see #2615.
    """
    if arg:
        key = arg.strip().lower()
        if len(key) != 64:
            raise ValueError(f"pubkey must be 64 hex chars (Ed25519 32 bytes), got {len(key)}")
        int(key, 16)  # validate hex
        return key
    print("[INFO] fetching canonical signing pubkey from trust.cyrber.com/testis ...", file=sys.stderr)
    return _fetch_canonical_signing_pubkey()


def _fingerprint(pubkey_hex: str) -> str:
    """Short SHA-256 fingerprint of the pubkey (first 16 hex chars)."""
    return hashlib.sha256(bytes.fromhex(pubkey_hex)).hexdigest()[:16]


def _ed25519_verify(pubkey_hex: str, message: bytes, signature_hex: str) -> bool:
    try:
        vk = VerifyKey(bytes.fromhex(pubkey_hex))
        vk.verify(message, bytes.fromhex(signature_hex))
        return True
    except BadSignatureError:
        return False
    except Exception as exc:
        _err(f"signature verification failed: {type(exc).__name__}: {exc}")
        return False


# ── OpenTimestamps classification (stdlib only, no network) ─────────────
#
# We do NOT do full OTS verification here (that needs the `ots` CLI or
# python-opentimestamps + a Bitcoin node/calendar). We classify the anchor by
# scanning for the fixed 8-byte attestation tags, so we can tell a calendar
# receipt (pending) from a Bitcoin block attestation and refuse to call a
# calendar receipt a Bitcoin confirmation (#2617).

_OTS_PENDING_TAG = bytes.fromhex("83dfe30d2ef90c8e")   # PendingAttestation (calendar)
_OTS_BITCOIN_TAG = bytes.fromhex("0588960d73d71901")   # BitcoinBlockHeaderAttestation
_OTS_LITECOIN_TAG = bytes.fromhex("06869a0d73d71b45")  # LitecoinBlockHeaderAttestation
_OTS_ETHEREUM_TAG = bytes.fromhex("30fe8087b5c7fd7c")  # EthereumBlockHeaderAttestation


def _ots_varuint(buf: bytes, i: int):
    """Read an OTS base-128 varuint (7 bits/byte, LSB first, MSB=continuation)."""
    val = 0
    shift = 0
    while i < len(buf):
        b = buf[i]
        i += 1
        val |= (b & 0x7F) << shift
        if not (b & 0x80):
            return val, i
        shift += 7
    return val, i


def _ots_classify(proof_hex: str) -> Dict[str, Any]:
    """Classify an OTS proof by scanning for attestation tags. No network, no lib."""
    try:
        buf = bytes.fromhex((proof_hex or "").strip())
    except (ValueError, AttributeError):
        return {"ok": False, "note": "proof is not valid hex"}
    if len(buf) < 8:
        return {"ok": False, "note": "proof too short to hold an attestation"}
    res: Dict[str, Any] = {"ok": False, "chain": None, "pending": False,
                           "block_height": None, "calendars": []}
    for tag, name in ((_OTS_BITCOIN_TAG, "bitcoin"),
                      (_OTS_LITECOIN_TAG, "litecoin"),
                      (_OTS_ETHEREUM_TAG, "ethereum")):
        idx = buf.find(tag)
        if idx != -1:
            res["ok"] = True
            res["chain"] = name
            j = idx + len(tag)
            _plen, j = _ots_varuint(buf, j)   # attestation payload length
            height, _ = _ots_varuint(buf, j)  # block height
            res["block_height"] = height
            return res
    idx = buf.find(_OTS_PENDING_TAG)
    while idx != -1:
        res["ok"] = True
        res["pending"] = True
        j = idx + len(_OTS_PENDING_TAG)
        _plen, j = _ots_varuint(buf, j)       # attestation payload length
        ulen, j = _ots_varuint(buf, j)        # calendar URI length
        if 0 < ulen < 200 and j + ulen <= len(buf):
            url = buf[j:j + ulen].decode("utf-8", "replace")
            if url and url not in res["calendars"]:
                res["calendars"].append(url)
        idx = buf.find(_OTS_PENDING_TAG, idx + 1)
    return res


def _snapshot_ots(snap: Dict[str, Any]):
    """Extract (proof_hex, calendar_url) from either the nested public shape
    ({"ots": {"proof_hex", "calendar_url"}}) or the flat builder shape."""
    ots = snap.get("ots")
    if isinstance(ots, dict):
        return ots.get("proof_hex"), ots.get("calendar_url")
    return snap.get("ots_proof_hex"), snap.get("ots_calendar_url")


def _report_ots(snap: Dict[str, Any], require_bitcoin: bool) -> int:
    """Print the OTS anchor status and return an exit code contribution.

    0  = anchored (calendar receipt or Bitcoin), or no timestamp claimed
    4  = the snapshot claims an OTS proof but it holds no valid attestation,
         or --require-bitcoin was set and only a calendar receipt is present.
    """
    proof_hex, _cal = _snapshot_ots(snap)
    print()
    if not proof_hex:
        print("TIMESTAMP:         none (snapshot is not OTS-anchored)")
        return 0
    info = _ots_classify(proof_hex)
    if not info.get("ok"):
        print("TIMESTAMP:         UNANCHORED")
        print(f"  → snapshot carries an ots proof but it holds no valid attestation "
              f"({info.get('note', 'no calendar/chain tag found')}).")
        return 4
    if info.get("chain") == "bitcoin":
        print(f"TIMESTAMP:         BITCOIN-ANCHORED (block {info.get('block_height')})")
        print("  → root_hash is committed to a Bitcoin block header.")
        return 0
    if info.get("chain"):
        print(f"TIMESTAMP:         {str(info['chain']).upper()}-ANCHORED "
              f"(block {info.get('block_height')})")
        return 0
    # pending calendar receipt
    cals = ", ".join(info.get("calendars") or []) or "(calendar)"
    print(f"TIMESTAMP:         CALENDAR RECEIPT ({cals})")
    print("  → a calendar receipt proves submission, NOT a Bitcoin confirmation.")
    print("    Upgrade to a Bitcoin attestation with `ots upgrade` / `ots verify`.")
    if require_bitcoin:
        print("  → --require-bitcoin set: no Bitcoin attestation yet.")
        return 4
    return 0


# ── Trust snapshot verifier ─────────────────────────────────────────────


def _canonicalize_snapshot(snap: Dict[str, Any]) -> bytes:
    """Reconstruct the canonical JSON used by modules/trust_snapshot.py.

    Server side (modules/trust_snapshot.py:canonical_payload) builds:
      json.dumps({
        "version": 1,
        "created_at":           <ISO>,
        "mission_id":           <int|None>,
        "total_findings":       <int>,
        "findings_by_severity": <dict, sorted keys>,
        "delta_total":          <int>,
        "delta_new_names":      <list, sorted>,
      }, sort_keys=True, separators=(",", ":"))

    Version 2 (#2620) adds "evidence_root" (SHA-256 over the mission's
    findings) and sets "version": 2. The public API returns both fields.

    SHA-256 is computed over these bytes, AND the Ed25519 signature is
    computed over the SAME BYTES (not over the hex digest). Verifier
    must reproduce the canonical payload exactly.
    """
    sev = snap.get("findings_by_severity") or {}
    new_names = snap.get("delta_new_names") or []
    evidence_root = snap.get("evidence_root")
    payload = {
        "version": 2 if evidence_root is not None else 1,
        "created_at": snap.get("created_at"),
        "mission_id": snap.get("mission_id"),
        "total_findings": int(snap.get("total_findings", 0)),
        "findings_by_severity": {k: int(v) for k, v in sorted(sev.items())},
        "delta_total": int(snap.get("delta_total", 0)),
        "delta_new_names": sorted(list(new_names)),
    }
    if evidence_root is not None:
        payload["evidence_root"] = evidence_root
    return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")


def cmd_snapshot(snap_path: str, pubkey_arg: Optional[str], require_bitcoin: bool = False) -> int:
    try:
        with open(snap_path, "r", encoding="utf-8") as fh:
            snap = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        _err(f"could not read snapshot file '{snap_path}': {exc}")
        return 3

    print("=" * 60)
    print("CYRBER TRUST SNAPSHOT VERIFICATION")
    print("=" * 60)
    _print("snapshot_id:", str(snap.get("snapshot_id")))
    _print("created_at:", str(snap.get("created_at")))
    _print("snapshot_hash:", str(snap.get("snapshot_hash", ""))[:32] + "...")

    try:
        pubkey = _resolve_pubkey(pubkey_arg)
    except (RuntimeError, ValueError) as exc:
        _err(f"pubkey: {exc}")
        return 3
    _print("pubkey:", pubkey[:32] + "... (fp " + _fingerprint(pubkey) + ")")

    # Step 1: Recompute canonical payload + SHA-256 hash and confirm
    # tamper detection (snapshot_hash field must match).
    if int(snap.get("version") or 1) >= 2 and snap.get("evidence_root") is None:
        _err("snapshot declares version 2 but has no evidence_root; "
             "download it again from /api/public/trust/status")
        return 3
    canonical_bytes = _canonicalize_snapshot(snap)
    recomputed = hashlib.sha256(canonical_bytes).hexdigest()
    claimed = str(snap.get("snapshot_hash", "")).lower()
    if recomputed != claimed:
        print()
        print("VERDICT:           TAMPERED")
        print(f"  recomputed_hash: {recomputed}")
        print(f"  claimed_hash:    {claimed}")
        print("  → snapshot payload was modified after signing.")
        return 2

    # Step 2: Verify Ed25519 signature OVER THE CANONICAL PAYLOAD BYTES
    # (NOT over the hex hash - server signs payload directly per
    # modules/trust_snapshot.py:_sign).
    sig = str(snap.get("ed25519_signature", "")).lower()
    if not sig:
        _err("snapshot has no ed25519_signature field")
        return 3
    ok = _ed25519_verify(pubkey, canonical_bytes, sig)
    print()
    if ok:
        print("VERDICT:           VERIFIED")
        print("  hash_match:      OK")
        print("  signature:       VALID")
        print("  → snapshot is authentic and unmodified.")
        return _report_ots(snap, require_bitcoin)
    print("VERDICT:           INVALID")
    print("  hash_match:      OK")
    print("  signature:       BAD")
    print("  → hash matches but signature does not - wrong pubkey or forged sig.")
    return 1


# ── Mission seal verifier ───────────────────────────────────────────────


_SEAL_CANONICAL_FIELDS = (
    "mission_id",
    "organization_id",
    "root_hash",
    "leaves_count",
    "sealed_at",
    "target",
)


def cmd_seal(seal_path: str, pubkey_arg: Optional[str]) -> int:
    """Verify a TestimoniumTree mission seal (per-mission Merkle root).

    Expected JSON shape (export from your CYRBER deploy via
    `testimonium.get_seal` capability or admin export):
      {
        "mission_id": int,
        "organization_id": int,
        "root_hash": "hex64",          # Merkle SHA-256 over findings
        "leaves_count": int,
        "sealed_at": "ISO-8601",
        "target": "url-or-cidr",
        "signature": "hex128",         # Ed25519 sig over root_hash bytes
      }
    """
    try:
        with open(seal_path, "r", encoding="utf-8") as fh:
            seal = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        _err(f"could not read seal file '{seal_path}': {exc}")
        return 3

    print("=" * 60)
    print("CYRBER MISSION SEAL VERIFICATION")
    print("=" * 60)
    _print("mission_id:", str(seal.get("mission_id")))
    _print("root_hash:", str(seal.get("root_hash", ""))[:32] + "...")
    _print("leaves_count:", str(seal.get("leaves_count")))
    _print("sealed_at:", str(seal.get("sealed_at")))
    _print("target:", str(seal.get("target")))

    try:
        pubkey = _resolve_pubkey(pubkey_arg)
    except (RuntimeError, ValueError) as exc:
        _err(f"pubkey: {exc}")
        return 3
    _print("pubkey:", pubkey[:32] + "... (fp " + _fingerprint(pubkey) + ")")

    sig = str(seal.get("signature", "")).lower()
    root = str(seal.get("root_hash", "")).lower()
    if not sig:
        _err("seal has no 'signature' field - export needs to include Ed25519 sig over root_hash")
        return 3
    if not root or len(root) != 64:
        _err(f"root_hash must be 64 hex chars, got {len(root)}")
        return 3

    ok = _ed25519_verify(pubkey, bytes.fromhex(root), sig)
    print()
    if ok:
        print("VERDICT:           VERIFIED")
        print("  signature:       VALID")
        print("  → mission seal is authentic and tied to canonical CYRBER pubkey.")
        return 0
    print("VERDICT:           INVALID")
    print("  signature:       BAD")
    print("  → signature does not match - wrong pubkey or forged seal.")
    return 1


# ── Leaf proof verifier (public Merkle path, F-05 #2329) ────────────────


def _pair_hash(a: str, b: str) -> str:
    """Mirror backend/proof.py:_hash_pair - sorted-pair SHA-256, order-independent,
    so the sibling path needs no left/right flags."""
    lo, hi = (a, b) if a <= b else (b, a)
    return hashlib.sha256((lo + hi).encode("utf-8")).hexdigest()


def _recompute_root(finding_hash: str, merkle_path) -> str:
    cur = finding_hash
    for sib in merkle_path:
        cur = _pair_hash(cur, str(sib).lower())
    return cur


def cmd_proof(bundle_path: str, pubkey_arg: Optional[str]) -> int:
    """Verify a public leaf proof bundle from
    /api/public/trust/proof/{scan_id}/{finding_id}. Two independent offline checks:

      1. Recompute the Merkle root from finding_hash + sibling path; it must equal the
         bundle's root_hash (leaf membership, no trust in the server's verdict).
      2. Verify the Ed25519 signature over root_hash against the CYRBER pubkey (root
         authenticity). The server signs the root_hash hex STRING (utf-8 bytes), per
         modules/signing.sign, so the verifier signs the same bytes.
    """
    try:
        with open(bundle_path, "r", encoding="utf-8") as fh:
            b = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        _err(f"could not read proof bundle '{bundle_path}': {exc}")
        return 3

    print("=" * 60)
    print("CYRBER LEAF PROOF VERIFICATION")
    print("=" * 60)
    _print("scan_id:", str(b.get("scan_id")))
    _print("finding_id:", str(b.get("finding_id")))
    _print("finding_hash:", str(b.get("finding_hash", ""))[:32] + "...")
    _print("leaf_index:", str(b.get("leaf_index")))
    _print("root_hash:", str(b.get("root_hash", ""))[:32] + "...")

    finding_hash = str(b.get("finding_hash", "")).lower()
    root = str(b.get("root_hash", "")).lower()
    path = b.get("merkle_path") or []
    if len(finding_hash) != 64 or len(root) != 64:
        _err("finding_hash and root_hash must be 64 hex chars")
        return 3

    # Step 1: recompute the root from the leaf + sibling path
    recomputed = _recompute_root(finding_hash, path)
    print()
    if recomputed != root:
        print("VERDICT:           TAMPERED")
        print(f"  recomputed_root: {recomputed}")
        print(f"  claimed_root:    {root}")
        print("  → leaf does not fold to the claimed root (path or hash altered).")
        return 2
    print("  merkle_root:     OK (leaf folds to root)")

    # Step 2: verify the Ed25519 signature over root_hash
    sig = str(b.get("root_signature") or "").lower()
    if not sig:
        print()
        print("VERDICT:           UNSIGNED")
        print("  → root is unsigned (pre-backfill tree). Membership holds, but the root")
        print("    is not tied to CYRBER's key. Ask for a re-sealed / backfilled proof.")
        return 1
    bundle_pubkey = str(b.get("public_key_hex") or "").strip().lower()
    try:
        pin = _resolve_signing_pubkey(pubkey_arg)
    except (RuntimeError, ValueError) as exc:
        _err(f"pubkey: {exc}")
        return 3
    # Pin the bundle's self-described signer against the canonical signing key
    # (or operator --pubkey). A forged bundle can embed its own key, so refuse
    # any embedded public_key_hex that is not the pinned one (#2615).
    if bundle_pubkey:
        if len(bundle_pubkey) != 64:
            _err("bundle public_key_hex must be 64 hex chars")
            return 3
        if bundle_pubkey != pin:
            print()
            print("VERDICT:           INVALID")
            print(f"  bundle pubkey fp: {_fingerprint(bundle_pubkey)}")
            print(f"  pinned pubkey fp: {_fingerprint(pin)}")
            print("  → bundle public_key_hex is not the pinned/canonical signing key.")
            print("    Refusing: a forged bundle can embed its own key.")
            return 2
        pubkey = bundle_pubkey
    else:
        pubkey = pin
    _print("pubkey:", pubkey[:32] + "... (fp " + _fingerprint(pubkey) + ")")

    ok = _ed25519_verify(pubkey, root.encode("utf-8"), sig)
    if ok:
        print("VERDICT:           VERIFIED")
        print("  merkle_root:     OK")
        print("  signature:       VALID")
        print("  → finding sits in a Merkle tree whose root CYRBER signed. Trustless.")
        return 0
    print("VERDICT:           INVALID")
    print("  merkle_root:     OK")
    print("  signature:       BAD")
    print("  → root folds correctly but the signature does not match CYRBER's key.")
    return 1


# ── pubkey command ──────────────────────────────────────────────────────


def cmd_pubkey() -> int:
    try:
        key = _fetch_canonical_pubkey()
    except RuntimeError as exc:
        _err(f"fetch failed: {exc}")
        return 3
    print(key)
    print(f"# fingerprint: {_fingerprint(key)}", file=sys.stderr)
    return 0


# ── CLI entry ───────────────────────────────────────────────────────────


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(
        prog="cyrber-verify",
        description="Verify CYRBER cryptographic seals (trust snapshots + mission seals).",
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    p_snap = sub.add_parser("snapshot", help="verify a trust.cyrber.com snapshot JSON")
    p_snap.add_argument("path", help="snapshot JSON file")
    p_snap.add_argument("--pubkey", default=None,
                        help="hex Ed25519 pubkey (offline mode); default fetches from trust.cyrber.com")
    p_snap.add_argument("--require-bitcoin", action="store_true",
                        help="exit non-zero if the OTS anchor is only a calendar receipt (no Bitcoin attestation yet)")

    p_seal = sub.add_parser("seal", help="verify a TestimoniumTree mission seal JSON")
    p_seal.add_argument("path", help="mission seal JSON file")
    p_seal.add_argument("--pubkey", default=None,
                        help="hex Ed25519 pubkey (offline mode); default fetches from trust.cyrber.com")

    p_proof = sub.add_parser("proof", help="verify a public leaf proof bundle (Merkle path + signed root)")
    p_proof.add_argument("path", help="proof bundle JSON from /api/public/trust/proof/{scan}/{finding}")
    p_proof.add_argument("--pubkey", default=None,
                         help="hex Ed25519 SIGNING pubkey (offline mode); default fetches /testis and pins the bundle's public_key_hex against it")

    sub.add_parser("pubkey", help="print canonical CYRBER pubkey from trust.cyrber.com")

    args = parser.parse_args(argv)

    if args.cmd == "snapshot":
        return cmd_snapshot(args.path, args.pubkey, args.require_bitcoin)
    if args.cmd == "seal":
        return cmd_seal(args.path, args.pubkey)
    if args.cmd == "proof":
        return cmd_proof(args.path, args.pubkey)
    if args.cmd == "pubkey":
        return cmd_pubkey()
    parser.print_help()
    return 3


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