From 52179bdaf19322f993b499f13f2751d2d9200823 Mon Sep 17 00:00:00 2001 From: mrwulf Date: Fri, 17 Jul 2026 11:15:19 +0200 Subject: [PATCH] verify.py: --all verifies every published receipt; binding fields required, not compare-if-present (round-12 GPT B4) Round-12 review found two artifact-claim gaps: --all never touched receipts, and receipt mode checked fingerprint/leaf_hash only when present. Now: - --all enumerates receipts/*.receipt.json and runs the full binding list on each (type tag, STH signature, REQUIRED key fingerprint, log_id vs log-metadata, STH membership in the published history, REQUIRED leaf_hash vs the named entry, tree_size agreement, receipt hash_algorithm + log_id consistency, inclusion proof). - --receipt FILE routes through the same function. - Malformed values (bad hex, missing sizes) are failures, not crashes. - NEW verify_selftest.py: 11-case adversarial battery (mutated real receipts must be REJECTED: no fingerprint, no leaf_hash, wrong type, forged root, size mismatch, wrong log_id; honest controls pass; structural-only never claims full; no-openssl exits 2). GREEN. One bug caught by the honest controls during development: the new hash-algorithm check assumed 'sha256' but deployed heads carry 'RFC9162_SHA256' - fixed against reality, plus receipt-level hash_algorithm/log_id binding added. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- verify.py | 125 ++++++++++++++++++++++++++++++--------------- verify_selftest.py | 96 ++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 42 deletions(-) create mode 100644 verify_selftest.py diff --git a/README.md b/README.md index ce46a7b..8ea45f6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ Layout: | `sth-history.jsonl` | **every** Signed Tree Head ever issued — the witness channel: all cloners see the same heads | | `latest-sth.json` | the current head | | `provider.ed25519.pub` | the provider's public key (the sole trust anchor) | -| `verify.py` | standalone verifier, Python standard library only | +| `verify.py` | standalone verifier (Python stdlib + the `openssl` binary; fails closed without them; `--all` covers every published receipt) | +| `verify_selftest.py` | adversarial self-test: proves the verifier's fail-closed paths reject mutated receipts | Verify everything locally, no installation: diff --git a/verify.py b/verify.py index 4c607c4..81c0126 100644 --- a/verify.py +++ b/verify.py @@ -9,10 +9,12 @@ files in this repository alone: 2. every historical Signed Tree Head against the recomputed prefix root (a split view or tampered entry fails here), 3. every STH Ed25519 signature, - 4. a receipt as a FULL transparency receipt (--receipt FILE): its STH - signature, key fingerprint, log id, tree-size agreement, that its - leaf hash matches the named entry, that its STH is present in the - published history, and its inclusion proof. + 4. every published receipt under receipts/ (with --all), and any receipt + supplied via --receipt FILE, as a FULL transparency receipt: type tag, + STH signature, REQUIRED key fingerprint, log id, presence of its STH + in the published history, REQUIRED leaf hash matching the named entry, + tree-size agreement, and the inclusion proof. Binding fields are + required, never compare-if-present. FAIL-CLOSED: if signature checking is unavailable (no `openssl`, or the public key is missing), the run FAILS — signatures are load-bearing and a @@ -124,6 +126,79 @@ def check_sth_signature(head) -> str: return "VALID" if result.returncode == 0 else "INVALID" +RECEIPT_TYPE = "pacta.transparency.receipt.v1" + + +def verify_receipt(receipt, heads, structural_only: bool, label: str): + """Full binding checks for one receipt. Every binding field is REQUIRED; + a missing field is a failure, never a skip. Returns failure strings.""" + problems = [] + if receipt.get("type") != RECEIPT_TYPE: + problems.append(f"{label}: type is {receipt.get('type')!r}, expected {RECEIPT_TYPE!r}") + sth = receipt.get("sth") or {} + if sth.get("hash_algorithm") != "RFC9162_SHA256": + problems.append(f"{label}: STH hash_algorithm is not RFC9162_SHA256") + if receipt.get("hash_algorithm") != "RFC9162_SHA256": + problems.append(f"{label}: receipt hash_algorithm is not RFC9162_SHA256") + if receipt.get("log_id") != sth.get("log_id"): + problems.append(f"{label}: receipt log_id != its STH log_id") + try: + index = int(receipt.get("leaf_index")) + except (TypeError, ValueError): + index = -1 + entry_path = HERE / "entries" / f"{index:06d}.json" if index >= 0 else None + if entry_path is None or not entry_path.exists(): + problems.append(f"{label}: leaf_index {receipt.get('leaf_index')!r} names no published entry") + return problems + entry = json.loads(entry_path.read_text()) + leaf_bytes = canonical_json(entry["leaf"]) + # (a) the receipt's STH must be signed by THIS log's key ... + rsig = check_sth_signature(sth) + if rsig == "INVALID" or (rsig == "UNAVAILABLE" and not structural_only): + problems.append(f"{label}: STH signature {rsig}") + # (b) ... the named key fingerprint is REQUIRED and must be this key ... + fp = (sth.get("signatures", {}).get("ed25519", {}) or {}).get("public_key_fingerprint_sha256") + if not fp: + problems.append(f"{label}: STH lacks public_key_fingerprint_sha256 (required)") + elif (HERE / "provider.ed25519.pub").exists() and fp != key_fingerprint(): + problems.append(f"{label}: STH signed by a different key than provider.ed25519.pub") + # (c) ... its log_id must be present and match the log ... + meta_path = HERE / "log-metadata.json" + if meta_path.exists(): + meta_log_id = json.loads(meta_path.read_text()).get("log_id") + if meta_log_id and sth.get("log_id") != meta_log_id: + problems.append(f"{label}: STH log_id missing or not this log's") + # (d) ... the receipt's STH must appear in the published history ... + if heads and canonical_json(sth) not in {canonical_json(h) for h in heads}: + problems.append(f"{label}: STH not present in sth-history.jsonl") + # (e) ... the leaf_hash is REQUIRED and must match the named entry ... + if not receipt.get("leaf_hash"): + problems.append(f"{label}: leaf_hash missing (required)") + elif receipt["leaf_hash"] != leaf_hash(leaf_bytes).hex(): + problems.append(f"{label}: leaf_hash does not match the named entry") + # (f) ... tree_size agreement ... + try: + size_agree = int(receipt.get("tree_size")) == int(sth.get("tree_size")) + except (TypeError, ValueError): + size_agree = False + if not size_agree: + problems.append(f"{label}: tree_size != its STH tree_size") + # (g) ... and finally the inclusion proof itself. + try: + ok = verify_inclusion( + leaf_bytes, index, int(receipt.get("tree_size") or 0), + [bytes.fromhex(h) for h in receipt.get("inclusion_proof") or []], + bytes.fromhex(sth.get("root_hash") or ""), + ) + except (TypeError, ValueError): + ok = False + if not ok: + problems.append(f"{label}: inclusion proof INVALID") + print(f"receipt {label}: leaf {index} of {receipt.get('tree_size')} " + f"STH-sig:{rsig} bindings+inclusion:{'OK' if not problems else 'FAIL'}") + return problems + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--all", action="store_true") @@ -182,45 +257,13 @@ def main() -> int: failures.append(f"latest-sth tree_size {latest['tree_size']} != {len(leaves)} leaves") else: print(f"latest-sth: size {latest['tree_size']} == leaf count, and == final history head OK") + for receipt_path in sorted((HERE / "receipts").glob("*.receipt.json")): + failures += verify_receipt(json.loads(receipt_path.read_text()), + heads, args.structural_only, receipt_path.name) if args.receipt: - receipt = json.loads(Path(args.receipt).read_text()) - sth = receipt["sth"] - index = int(receipt["leaf_index"]) - entry = json.loads((HERE / "entries" / f"{index:06d}.json").read_text()) - leaf_bytes = canonical_json(entry["leaf"]) - - # (a) the receipt's STH must be signed by THIS log's key ... - rsig = check_sth_signature(sth) - if rsig == "INVALID" or (rsig == "UNAVAILABLE" and not args.structural_only): - failures.append(f"receipt STH signature {rsig}") - # (b) ... fingerprint the receipt names must be this key ... - fp = (sth.get("signatures", {}).get("ed25519", {}) or {}).get("public_key_fingerprint_sha256") - if sigs_ok and fp and fp != key_fingerprint(): - failures.append("receipt STH signed by a different key than provider.ed25519.pub") - # (c) ... its log_id must match the log ... - meta_log_id = json.loads((HERE / "log-metadata.json").read_text()).get("log_id") if (HERE / "log-metadata.json").exists() else None - if meta_log_id and sth.get("log_id") not in (None, meta_log_id): - failures.append("receipt STH log_id does not match this log") - # (d) ... the receipt's STH must actually appear in the published history ... - if heads and canonical_json(sth) not in {canonical_json(h) for h in heads}: - failures.append("receipt STH is not present in sth-history.jsonl") - # (e) ... the receipt's leaf_hash must match the named entry ... - if receipt.get("leaf_hash") and receipt["leaf_hash"] != leaf_hash(leaf_bytes).hex(): - failures.append("receipt leaf_hash does not match the named entry") - # (f) ... tree_size agreement ... - if int(receipt.get("tree_size", -1)) != int(sth.get("tree_size", -2)): - failures.append("receipt tree_size != its STH tree_size") - # (g) ... and finally the inclusion proof itself. - ok = verify_inclusion( - leaf_bytes, index, int(receipt["tree_size"]), - [bytes.fromhex(h) for h in receipt["inclusion_proof"]], - bytes.fromhex(sth["root_hash"]), - ) - if not ok: - failures.append("receipt inclusion proof") - print(f"receipt leaf {index} of {receipt['tree_size']}: STH-sig:{rsig} bindings:" - f"{'OK' if not any('receipt' in f for f in failures) else 'FAIL'} inclusion:{'VALID' if ok else 'INVALID'}") + failures += verify_receipt(json.loads(Path(args.receipt).read_text()), + heads, args.structural_only, Path(args.receipt).name) mode = "REDUCED (structural only, signatures NOT checked)" if args.structural_only else "full" if failures: diff --git a/verify_selftest.py b/verify_selftest.py new file mode 100644 index 0000000..93bf07c --- /dev/null +++ b/verify_selftest.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Adversarial self-test for verify.py — proves the fail-closed paths fail. + +Each case mutates a real published receipt (or the environment) and asserts +the verifier REJECTS it; plus the honest controls. Exit 0 only if every case +behaves. Run from a clone: python3 verify_selftest.py +""" +import copy +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def run(*args, env=None): + result = subprocess.run( + [sys.executable, str(HERE / "verify.py"), *args], + capture_output=True, text=True, env=env, + ) + return result.returncode, result.stdout + + +def base_receipt(): + path = sorted((HERE / "receipts").glob("*.receipt.json"))[0] + return json.loads(path.read_text()) + + +def mutated(**changes): + receipt = copy.deepcopy(base_receipt()) + for dotted, value in changes.items(): + target, keys = receipt, dotted.split(".") + for key in keys[:-1]: + target = target[key] + if value is None: + target.pop(keys[-1], None) + else: + target[keys[-1]] = value + return receipt + + +def check_receipt(receipt) -> int: + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + json.dump(receipt, handle) + path = handle.name + try: + code, _ = run("--receipt", path) + return code + finally: + os.unlink(path) + + +def main() -> int: + cases = [] + + code, out = run("--all") + cases.append(("honest --all passes (full)", code == 0 and "RESULT: OK [full]" in out)) + cases.append(("--all covers every published receipt", + out.count("receipt ") == len(list((HERE / "receipts").glob("*.receipt.json"))))) + + cases.append(("honest receipt passes", check_receipt(base_receipt()) == 0)) + cases.append(("missing key fingerprint REJECTED", + check_receipt(mutated(**{"sth.signatures.ed25519.public_key_fingerprint_sha256": None})) == 1)) + cases.append(("missing leaf_hash REJECTED", check_receipt(mutated(leaf_hash=None)) == 1)) + cases.append(("wrong receipt type REJECTED", check_receipt(mutated(type="forged.v0")) == 1)) + cases.append(("forged (unsigned) root REJECTED", + check_receipt(mutated(**{"sth.root_hash": "ff" * 32})) == 1)) + cases.append(("tree_size mismatch REJECTED", + check_receipt(mutated(tree_size=int(base_receipt()["tree_size"]) + 1)) == 1)) + cases.append(("wrong log_id REJECTED", + check_receipt(mutated(**{"sth.log_id": "00" * 32})) == 1)) + + code, out = run("--all", "--structural-only") + cases.append(("--structural-only is explicit, never claims full", + code == 0 and "REDUCED" in out and "[full]" not in out)) + + with tempfile.TemporaryDirectory() as tmp: + os.symlink(sys.executable, Path(tmp) / Path(sys.executable).name) + code, out = run("--all", env={"PATH": tmp}) + cases.append(("no openssl -> FAIL CLOSED (exit 2)", code == 2)) + + width = max(len(name) for name, _ in cases) + for name, ok in cases: + print(f"{'PASS' if ok else 'FAIL'} {name:<{width}}") + if all(ok for _, ok in cases): + print(f"SELFTEST GREEN ({len(cases)} cases)") + return 0 + print("SELFTEST RED") + return 1 + + +if __name__ == "__main__": + sys.exit(main())