#!/usr/bin/env python3
"""STANDALONE VERIFIER for an Authority Notary v3 bundle. No CAIN imports, on purpose.

Re-derives the verdict from the evidence instead of trusting the issuer's own "ok" flags:

  1. every record's SHA-256 and Ed25519 signature, against a key YOU pin;
  2. the hash chain (genesis, sequence, links);
  3. each record's bound artifact digest (``layers_json``);
  4. L0: every quorum vote, the threshold, and distinct signers;
  5. L1: per-hop parent binding, monotonic attenuation, principal invariance, expiry monotonicity;
  6. L3: checkpoint hashes, links, tenancy and fact provenance;
  7. L4: mandate signature, tool/param scope, effect containment, budget, replay, settlement;
  8. that no record or receipt claims to grant authority.

Exit codes: 0 all checks pass · 2 a check failed · 3 no pinned key supplied (refuses to guess).

    python3 verify_notary.py bundle/ --pinned-pub <base64>       # or --pinned-fingerprint SHA256:...
    python3 verify_notary.py layers.json --self-check            # just recompute the layers, no pin

Needs Python 3.9+ and `cryptography`. Nothing else.
"""
from __future__ import annotations

import base64
import glob
import hashlib
import json
import os
import sys

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

NOT_PINNED = 3


# --------------------------------------------------------------------------- primitives
def canon(obj):
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode()


def sha(obj):
    if isinstance(obj, bytes):
        return hashlib.sha256(obj).hexdigest()
    if isinstance(obj, str):
        return hashlib.sha256(obj.encode()).hexdigest()
    return hashlib.sha256(canon(obj)).hexdigest()


def fingerprint(b64):
    raw = base64.b64decode(b64)
    return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=")


def sig_ok(pub_b64, sig_b64, message):
    try:
        Ed25519PublicKey.from_public_bytes(base64.b64decode(pub_b64)).verify(
            base64.b64decode(sig_b64 or ""), message)
        return True
    except (InvalidSignature, ValueError, TypeError):
        return False


def is_subset(child, parent):
    c = set(child or [])
    p = set(parent or [])
    for cap in c:
        if cap in p:
            continue
        head = cap.split(":", 1)[0] + ":*" if ":" in cap else None
        if head and head in p:
            continue
        return False
    return True


def within(effect, authorized, allow=()):
    allow = set(allow or [])
    if isinstance(effect, dict):
        if not isinstance(authorized, dict):
            return False
        for k, v in effect.items():
            if k in authorized:
                if not within(v, authorized[k], allow):
                    return False
            elif k not in allow:
                return False
        return True
    if isinstance(effect, list):
        if not isinstance(authorized, list):
            return False
        return all(any(within(e, a, allow) for a in authorized) for e in effect)
    return effect == authorized


# --------------------------------------------------------------------------- record layer
def check_records(records, pinned_b64):
    problems = []
    prev = None
    for i, rec in enumerate(records):
        body = {k: v for k, v in rec.items() if k not in ("record_hash", "signature_b64", "public_key_b64")}
        if sha(body) != rec.get("record_hash"):
            problems.append(f"record {i}: content does not match record_hash")
        if not sig_ok(pinned_b64, rec.get("signature_b64", ""), str(rec.get("record_hash", "")).encode()):
            problems.append(f"record {i}: signature does not verify against the pinned key")
        # A bundle may carry a contiguous slice of an append-only ledger, so positions start where the
        # slice starts. What must hold is that the slice is contiguous and internally linked.
        if i == 0:
            if not rec.get("prev_record_hash"):
                problems.append(f"record {i}: missing genesis link")
            first_seq = rec.get("sequence")
        else:
            if rec.get("prev_record_hash") != prev.get("record_hash"):
                problems.append(f"record {i}: prev_record_hash does not link to record {i-1}")
            if rec.get("sequence") != (prev.get("sequence") or 0) + 1:
                problems.append(f"record {i}: sequence {rec.get('sequence')} is not contiguous after "
                                f"{prev.get('sequence')}")
        if sha(rec.get("layers_json", "")) != rec.get("layers_sha256"):
            problems.append(f"record {i}: layers_json does not match layers_sha256")
        if rec.get("grants_authority") is not False:
            problems.append(f"record {i}: record must not grant authority")
        prev = rec
    return problems


# --------------------------------------------------------------------------- layer recompute
#
# Each re-derivation returns the list of violations it found. A violation is not automatically a
# failure: a record that HONESTLY declares OUTSIDE_AUTHORITY for a widening effect is correct evidence.
# The failure is an issuer who claims AUTHORIZED while the evidence says otherwise. So we compare the
# re-derived result against the declared verdict and only object when the claim is more generous.

def _l0(root):
    out = []
    if not root:
        return ["L0: no quorum root in the record"]
    for r in root.get("receipts") or []:
        votes, keys = 0, set()
        for v in r.get("votes") or []:
            pub = v.get("public_key_b64", "")
            msg = canon({"height": r.get("height"), "digest": r.get("digest")})
            if not sig_ok(pub, v.get("sig_b64", ""), msg):
                out.append(f"L0: bad quorum signature from {v.get('replica')}")
                continue
            if pub in keys:
                out.append("L0: one key counted twice in a quorum (Sybil)")
                continue
            keys.add(pub)
            votes += 1
        if votes < int(r.get("threshold", 3)):
            out.append(f"L0: {votes} valid votes < threshold {r.get('threshold')}")
    if sha([r.get("receipt_hash") for r in root.get("receipts") or []]) != root.get("root_digest"):
        out.append("L0: root_digest does not commit to its receipts")
    return out


def _l1(chain):
    out = []
    if not chain:
        return ["L1: empty delegation chain"]
    principal = chain[0].get("principal")
    prev = None
    for i, link in enumerate(chain):
        body = {k: v for k, v in link.items() if k not in ("record_hash", "public_key_b64", "sig_b64", "link_id")}
        if sha(body) != link.get("record_hash"):
            out.append(f"L1 hop {i}: content does not match record_hash")
        if not sig_ok(link.get("public_key_b64", ""), link.get("sig_b64", ""), str(link.get("record_hash")).encode()):
            out.append(f"L1 hop {i}: signature invalid")
        if i > 0:
            if link.get("parent_link_hash") != prev.get("record_hash"):
                out.append(f"L1 hop {i}: parent link does not bind (swap refused)")
            if not is_subset(link.get("capabilities"), prev.get("capabilities")):
                out.append(f"L1 hop {i}: capabilities widen beyond the parent")
            if str(link.get("expires_at", "")) > str(prev.get("expires_at", "")):
                out.append(f"L1 hop {i}: expiry is not monotonic")
            if link.get("principal") != principal:
                out.append(f"L1 hop {i}: principal changed")
        prev = link
    return out


def _l3(chain):
    out = []
    prev = None
    for cp in chain:
        body = {k: v for k, v in cp.items() if k != "hash"}
        if sha(body) != cp.get("hash"):
            out.append(f"L3 step {cp.get('step')}: content does not match hash")
        if sha(cp.get("facts") or []) != cp.get("facts_digest"):
            out.append(f"L3 step {cp.get('step')}: facts_digest mismatch")
        if prev is not None and cp.get("prev_hash") != prev.get("hash"):
            out.append(f"L3 step {cp.get('step')}: chain link broken")
        prev = cp
    return out


def _l4(inp):
    out = []
    mandate = inp.get("mandate") or {}
    proposal = inp.get("proposal") or {}
    effect = inp.get("effect") or {}
    body = {k: v for k, v in mandate.items() if k not in ("record_hash", "public_key_b64", "sig_b64")}
    if sha(body) != mandate.get("record_hash"):
        out.append("L4: mandate content does not match its hash")
    if not sig_ok(mandate.get("public_key_b64", ""), mandate.get("sig_b64", ""),
                  str(mandate.get("record_hash", "")).encode()):
        out.append("L4: mandate signature invalid")
    if proposal.get("tool") not in (mandate.get("allowed_tools") or []):
        out.append("L4: proposal tool outside the mandate")
    if effect.get("tool") != proposal.get("tool"):
        out.append("L4: effect tool differs from the proposal")
    if not within(effect.get("params") or {}, proposal.get("params") or {},
                  mandate.get("allowed_effect_keys") or []):
        out.append("L4: effect parameters widen beyond the proposal")
    spend = int(effect.get("amount_minor", 0) or 0)
    if spend > int(proposal.get("amount_minor", 0) or 0):
        out.append("L4: effect spent more than proposed")
    if spend > int(mandate.get("budget_minor", 0) or 0):
        out.append("L4: effect exceeds the mandate budget")
    if effect.get("currency") != mandate.get("currency"):
        out.append("L4: currency mismatch")
    undeclared = [s for s in (effect.get("side_effects") or [])
                  if s not in (mandate.get("allowed_side_effects") or [])]
    if undeclared:
        out.append(f"L4: undeclared side effects {undeclared}")
    settled = int((effect.get("settlement") or {}).get("amount_minor", spend))
    if settled != spend:
        out.append("L4: settled amount differs from the authorized effect")
    return out


def _l2(inp):
    status = (inp.get("decision") or {}).get("status")
    if status not in ("AUTHORIZED", "NARROWED", "OUTSIDE_AUTHORITY", "UNVERIFIABLE"):
        return [f"L2: unknown admission status {status!r}"]
    return []


CHECKS = {"L0.root": _l0, "L1.delegation": _l1, "L3.state": _l3, "L4.intent-effect": _l4, "L2.admission": _l2}


def recompute(layers_doc):
    problems = []
    inputs = layers_doc.get("extras", {}).get("inputs", {})
    declared = {l.get("layer"): l.get("status") for l in layers_doc.get("layers") or []}

    for name, fn in CHECKS.items():
        if name not in declared:
            continue
        if name == "L0.root":
            violations = fn(inputs.get("root"))
        elif name == "L1.delegation":
            violations = fn(inputs.get("delegation_chain") or [])
        elif name == "L3.state":
            violations = fn(inputs.get("state_chain") or [])
        elif name == "L4.intent-effect":
            violations = fn(inputs) if inputs.get("mandate") else []
        else:
            violations = fn(inputs) if inputs.get("decision") else []
        # A layer may only be claimed AUTHORIZED when the evidence re-derives clean.
        if violations and declared.get(name) in ("AUTHORIZED", "NARROWED"):
            problems.append(f"{name}: declared {declared.get(name)} but re-derivation found "
                            f"{len(violations)} violation(s): {violations[0]}")
        if not violations and declared.get(name) == "UNVERIFIABLE":
            problems.append(f"{name}: declared UNVERIFIABLE but the evidence re-derives clean")

    # The composed status must equal the strictest layer, not be kinder than any of them.
    rank = {"AUTHORIZED": 0, "NARROWED": 1, "OUTSIDE_AUTHORITY": 2, "UNVERIFIABLE": 3}
    statuses = list(declared.values())
    if statuses:
        strictest = max(statuses, key=lambda s: rank.get(s, 9))
        if layers_doc.get("composed_status") != strictest:
            problems.append(f"composed_status {layers_doc.get('composed_status')!r} is not the strictest "
                            f"layer ({strictest!r})")
    if layers_doc.get("grants_authority") is not False:
        problems.append("the stack must not grant authority")
    return problems


# --------------------------------------------------------------------------- driver
def load_bundle(path):
    import zipfile
    if os.path.isdir(path):
        files = {}
        for p in glob.glob(os.path.join(path, "*.json")):
            files[os.path.basename(p)] = json.load(open(p, encoding="utf-8"))
        return files
    with zipfile.ZipFile(path) as z:
        return {os.path.basename(n): json.loads(z.read(n)) for n in z.namelist() if n.endswith(".json")}


def main(argv=None):
    argv = list(sys.argv[1:] if argv is None else argv)
    pinned = pinned_fp = target = None
    self_check = False
    i = 0
    positional = []
    while i < len(argv):
        a = argv[i]
        if a == "--pinned-pub":
            pinned = argv[i + 1]; i += 2
        elif a == "--pinned-fingerprint":
            pinned_fp = argv[i + 1]; i += 2
        elif a == "--self-check":
            self_check = True; i += 1
        else:
            positional.append(a); i += 1
    if not positional:
        print("usage: verify_notary.py <bundle|layers.json> --pinned-pub <b64> | --self-check")
        return 2
    target = positional[0]

    if target.endswith(".json"):
        doc = json.load(open(target, encoding="utf-8"))
        problems = recompute(doc)
        print(("SELF-CHECK OK" if not problems else "SELF-CHECK FAILED") + f" ({target})")
        for p in problems:
            print("  -", p)
        return 0 if not problems else 2

    files = load_bundle(target)
    records = [files[k] for k in sorted(files) if k.startswith("record")]
    if not records:
        print("no record*.json found in the bundle")
        return 2

    if pinned is None and pinned_fp is None:
        claimed = records[0].get("public_key_b64", "")
        print("REFUSED: pin the signing key yourself with --pinned-pub or --pinned-fingerprint.")
        print("the bundle claims key", fingerprint(claimed) if claimed else "(none)")
        return NOT_PINNED
    if pinned is None:
        candidates = {records[0].get("public_key_b64", "")}
        if fingerprint(records[0].get("public_key_b64", "")) != pinned_fp:
            print(f"REFUSED: the bundle's key is {fingerprint(records[0].get('public_key_b64',''))}, "
                  f"you pinned {pinned_fp}")
            return 2
        pinned = records[0].get("public_key_b64", "")

    problems = check_records(records, pinned)
    for rec in records:
        try:
            layers_doc = json.loads(rec.get("layers_json", "{}"))
        except json.JSONDecodeError:
            problems.append(f"record {rec.get('run_id')}: layers_json is not valid JSON")
            continue
        problems.extend(recompute(layers_doc))

    print(("ALL RECORDS VALID" if not problems else "VERIFICATION FAILED") +
          f" — {len(records)} record(s), key {fingerprint(pinned)}")
    for p in problems:
        print("  -", p)
    return 0 if not problems else 2


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