lean-transparency-log/verify.py
mrwulf 52179bdaf1 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>
2026-07-17 11:15:19 +02:00

278 lines
12 KiB
Python

#!/usr/bin/env python3
"""Standalone verifier for the published Lean Transparency Log.
Pure Python 3 standard library for hashing and structure; Ed25519
signature checking shells out to the `openssl` binary. Verifies, from the
files in this repository alone:
1. every entry's leaf hash,
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. 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
"couldn't check" is not a pass. Use --structural-only to explicitly ask
for hashes/structure without signatures (it prints, and exits, as a
reduced check, never as full verification).
Usage:
python3 verify.py --all
python3 verify.py --receipt receipts/dalek-ed25519-verified.receipt.json
python3 verify.py --all --structural-only # explicit reduced check
"""
import argparse
import base64
import hashlib
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
def leaf_hash(data: bytes) -> bytes:
return hashlib.sha256(b"\x00" + data).digest()
def node_hash(left: bytes, right: bytes) -> bytes:
return hashlib.sha256(b"\x01" + left + right).digest()
def merkle_root(leaves):
if not leaves:
return hashlib.sha256(b"").digest()
if len(leaves) == 1:
return leaf_hash(leaves[0])
split = 1 << ((len(leaves) - 1).bit_length() - 1)
return node_hash(merkle_root(leaves[:split]), merkle_root(leaves[split:]))
def verify_inclusion(leaf: bytes, index: int, size: int, proof, root: bytes) -> bool:
if index >= size:
return False
fn, sn = index, size - 1
node = leaf_hash(leaf)
for sibling in proof:
if sn == 0:
return False
if fn % 2 == 1 or fn == sn:
node = node_hash(sibling, node)
if fn % 2 == 0:
while fn % 2 == 0 and fn != 0:
fn //= 2
sn //= 2
else:
node = node_hash(node, sibling)
fn //= 2
sn //= 2
return sn == 0 and node == root
def canonical_json(document) -> bytes:
return json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def load_leaves():
leaves, problems = [], []
for position, path in enumerate(sorted((HERE / "entries").glob("[0-9]*.json"))):
record = json.loads(path.read_text())
data = canonical_json(record["leaf"])
if record.get("index") != position:
problems.append(f"{path.name}: index {record.get('index')} at position {position}")
if leaf_hash(data).hex() != record.get("leaf_hash"):
problems.append(f"{path.name}: leaf_hash mismatch (tampered entry)")
leaves.append(data)
return leaves, problems
def signatures_available() -> bool:
return bool(shutil.which("openssl")) and (HERE / "provider.ed25519.pub").exists()
def key_fingerprint() -> str:
return hashlib.sha256((HERE / "provider.ed25519.pub").read_bytes()).hexdigest()
def check_sth_signature(head) -> str:
"""VALID / INVALID / UNAVAILABLE. UNAVAILABLE is a FAILURE at the
caller unless the run is explicitly --structural-only."""
openssl = shutil.which("openssl")
key = HERE / "provider.ed25519.pub"
if not openssl or not key.exists():
return "UNAVAILABLE"
signatures = head.get("signatures") or {}
ed = signatures.get("ed25519") or {}
payload = canonical_json({k: v for k, v in head.items() if k != "signatures"})
with tempfile.TemporaryDirectory() as tmp:
payload_path = Path(tmp) / "p"
signature_path = Path(tmp) / "s"
payload_path.write_bytes(payload)
signature_path.write_bytes(base64.b64decode(ed.get("signature_base64", "")))
result = subprocess.run(
[openssl, "pkeyutl", "-verify", "-pubin", "-inkey", str(key), "-rawin",
"-in", str(payload_path), "-sigfile", str(signature_path)],
capture_output=True,
)
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")
parser.add_argument("--receipt")
parser.add_argument("--structural-only", action="store_true",
help="skip Ed25519 signature checks explicitly; the run reports a "
"REDUCED check and can never print full verification.")
args = parser.parse_args()
sigs_ok = signatures_available()
if not args.structural_only and not sigs_ok:
# Fail closed: a verifier that cannot check signatures must not
# imply it did. Do not silently continue.
print("FATAL: signature checking unavailable (need the `openssl` binary and "
"provider.ed25519.pub). Install openssl / fetch the key, or pass "
"--structural-only to run an explicit hashes-and-structure check.")
return 2
leaves, problems = load_leaves()
print(f"entries: {len(leaves)}")
failures = list(problems)
for problem in problems:
print("PROBLEM:", problem)
history_path = HERE / "sth-history.jsonl"
heads = [json.loads(line) for line in history_path.read_text().splitlines() if line.strip()] if history_path.exists() else []
if args.all or not args.receipt:
# log-wide checks (GPT §4.3): history internally consistent AND the
# published latest-sth.json is exactly the final history head.
previous = -1
log_id = None
for position, head in enumerate(heads):
size = int(head["tree_size"])
if size > len(leaves):
failures.append(f"STH #{position} claims size {size} > {len(leaves)} leaves")
expected = merkle_root(leaves[:size]).hex()
structural = "OK" if head["root_hash"] == expected and size >= previous else "MISMATCH"
if structural != "OK":
failures.append(f"STH #{position} prefix-root/monotonicity")
if log_id is None:
log_id = head.get("log_id")
elif head.get("log_id") != log_id:
failures.append(f"STH #{position} log_id changed mid-history")
signature = check_sth_signature(head)
if signature == "INVALID" or (signature == "UNAVAILABLE" and not args.structural_only):
failures.append(f"STH #{position} signature {signature}")
print(f"STH #{position} size={size} root={head['root_hash'][:16]}… prefix-root:{structural} signature:{signature}")
previous = max(previous, size)
latest_path = HERE / "latest-sth.json"
if latest_path.exists() and heads:
latest = json.loads(latest_path.read_text())
if canonical_json(latest) != canonical_json(heads[-1]):
failures.append("latest-sth.json is not the final sth-history head")
elif int(latest["tree_size"]) != len(leaves):
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:
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:
print(f"RESULT: FAILED ({len(failures)} problems) [{mode}]")
return 1
print(f"RESULT: OK [{mode}]"
+ ("" if not args.structural_only else " — signatures were NOT verified; this is not full verification"))
return 0
if __name__ == "__main__":
sys.exit(main())