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 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-07-17 11:15:19 +02:00
parent 4e5c39b93c
commit 52179bdaf1
3 changed files with 182 additions and 42 deletions

View file

@ -24,7 +24,8 @@ Layout:
| `sth-history.jsonl` | **every** Signed Tree Head ever issued — the witness channel: all cloners see the same heads | | `sth-history.jsonl` | **every** Signed Tree Head ever issued — the witness channel: all cloners see the same heads |
| `latest-sth.json` | the current head | | `latest-sth.json` | the current head |
| `provider.ed25519.pub` | the provider's public key (the sole trust anchor) | | `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: Verify everything locally, no installation:

125
verify.py
View file

@ -9,10 +9,12 @@ files in this repository alone:
2. every historical Signed Tree Head against the recomputed prefix root 2. every historical Signed Tree Head against the recomputed prefix root
(a split view or tampered entry fails here), (a split view or tampered entry fails here),
3. every STH Ed25519 signature, 3. every STH Ed25519 signature,
4. a receipt as a FULL transparency receipt (--receipt FILE): its STH 4. every published receipt under receipts/ (with --all), and any receipt
signature, key fingerprint, log id, tree-size agreement, that its supplied via --receipt FILE, as a FULL transparency receipt: type tag,
leaf hash matches the named entry, that its STH is present in the STH signature, REQUIRED key fingerprint, log id, presence of its STH
published history, and its inclusion proof. 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 FAIL-CLOSED: if signature checking is unavailable (no `openssl`, or the
public key is missing), the run FAILS signatures are load-bearing and a 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" 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: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--all", action="store_true") 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") failures.append(f"latest-sth tree_size {latest['tree_size']} != {len(leaves)} leaves")
else: else:
print(f"latest-sth: size {latest['tree_size']} == leaf count, and == final history head OK") 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: if args.receipt:
receipt = json.loads(Path(args.receipt).read_text()) failures += verify_receipt(json.loads(Path(args.receipt).read_text()),
sth = receipt["sth"] heads, args.structural_only, Path(args.receipt).name)
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'}")
mode = "REDUCED (structural only, signatures NOT checked)" if args.structural_only else "full" mode = "REDUCED (structural only, signatures NOT checked)" if args.structural_only else "full"
if failures: if failures:

96
verify_selftest.py Normal file
View file

@ -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())