lean-transparency-log/verify.py

347 lines
16 KiB
Python
Raw Normal View History

#!/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),
entries 13-18: re-attest the estate at 44 certs/fork + first SLH-DSA leaf; heads now dual-signed Six new leaves (tree 13 -> 19, root 7ee23940…): [13-16] dalek/anza/risc0/betrusted-ed25519-verified — re-attested at 44 certificates each (27 main + 4 apex + 13 scalar; the leaf 8-11 generation recorded 16). The delta is the P0-P2 hardening campaign: scalar statements bound, kernel-side axiom gate, driver surface with cones, declaration coverage both directions, the accounting identity. [17] ltl-accumulator-verified — 61 certificates; the log again carries proofs of its own Merkle machinery, at the hardened state. [18] fips205-slhdsa-verified — FIRST post-quantum leaf: 11 certificates over the SLH-DSA-SHA2-128s verify path, apex fips205.slh_verify_128s_accepts_iff. Heads from tree 14 on carry a second, ADDITIVE signature: SLH-DSA-SHA2-128s (FIPS 205), deterministic, over the same payload as the Ed25519 signature. provider.slhdsa.pub ships beside the Ed25519 key. Ed25519 remains the signature consumers must check; verify.py now judges slh_dsa fail-closed where present (INVALID/WRONG-KEY/NO-PUBKEY fail; older heads report ABSENT, allowed; pre-3.5 OpenSSL degrades loudly, never silently). The ml_dsa slot stays not_configured — truthfully. Honesty, unchanged by any of it: the certificates cover VERIFICATION paths of the extracted Lean models; no signing operation is proven for any algorithm; leaves are Ed25519-signed at issuance only. Append-only law checked byte-for-byte before this commit: entries 000000-000012 identical, the six prior heads an exact prefix of the history. verify.py --all: RESULT OK [full]. verify_selftest.py: GREEN (13 cases). Quorum gate: 5-way Ed25519 + 2-way SLH-DSA (incl. the verifier built from the pinned proven source), all accept, all reject corruption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 15:11:59 +00:00
3. every STH Ed25519 signature and, where a head carries the ADDITIVE
SLH-DSA-SHA2-128s signature (FIPS 205, heads from 2026-08 on), that
too: a present-but-wrong post-quantum signature FAILS the run, a head
without one is allowed, and an OpenSSL too old to check it (pre-3.5)
is reported loudly as a degradation, never counted as verified,
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"
entries 13-18: re-attest the estate at 44 certs/fork + first SLH-DSA leaf; heads now dual-signed Six new leaves (tree 13 -> 19, root 7ee23940…): [13-16] dalek/anza/risc0/betrusted-ed25519-verified — re-attested at 44 certificates each (27 main + 4 apex + 13 scalar; the leaf 8-11 generation recorded 16). The delta is the P0-P2 hardening campaign: scalar statements bound, kernel-side axiom gate, driver surface with cones, declaration coverage both directions, the accounting identity. [17] ltl-accumulator-verified — 61 certificates; the log again carries proofs of its own Merkle machinery, at the hardened state. [18] fips205-slhdsa-verified — FIRST post-quantum leaf: 11 certificates over the SLH-DSA-SHA2-128s verify path, apex fips205.slh_verify_128s_accepts_iff. Heads from tree 14 on carry a second, ADDITIVE signature: SLH-DSA-SHA2-128s (FIPS 205), deterministic, over the same payload as the Ed25519 signature. provider.slhdsa.pub ships beside the Ed25519 key. Ed25519 remains the signature consumers must check; verify.py now judges slh_dsa fail-closed where present (INVALID/WRONG-KEY/NO-PUBKEY fail; older heads report ABSENT, allowed; pre-3.5 OpenSSL degrades loudly, never silently). The ml_dsa slot stays not_configured — truthfully. Honesty, unchanged by any of it: the certificates cover VERIFICATION paths of the extracted Lean models; no signing operation is proven for any algorithm; leaves are Ed25519-signed at issuance only. Append-only law checked byte-for-byte before this commit: entries 000000-000012 identical, the six prior heads an exact prefix of the history. verify.py --all: RESULT OK [full]. verify_selftest.py: GREEN (13 cases). Quorum gate: 5-way Ed25519 + 2-way SLH-DSA (incl. the verifier built from the pinned proven source), all accept, all reject corruption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 15:11:59 +00:00
def check_slh_dsa_signature(head):
"""ADDITIVE post-quantum check (SLH-DSA-SHA2-128s, FIPS 205).
Returns (status, hard_failure). Ed25519 remains the REQUIRED signature;
this one is verified when the head carries it and the local OpenSSL
(>= 3.5) can check it. The distinctions matter:
ABSENT - head predates the second signature. Allowed: additive.
VALID - verified against provider.slhdsa.pub.
INVALID - present and WRONG. Hard failure - a bad signature is never
a degradation.
WRONG-KEY - the head names a different key than the mirror ships.
Hard failure.
NO-PUBKEY - the head claims the signature but the mirror ships no
provider.slhdsa.pub. Broken publication: hard failure.
TOOLING - this OpenSSL cannot read SLH-DSA keys (pre-3.5). Honest
degradation: reported loudly, never counted as verified,
never failed - the required Ed25519 check still gates.
"""
slh = (head.get("signatures") or {}).get("slh_dsa") or {}
if slh.get("status") != "signed":
return "ABSENT", False
key = HERE / "provider.slhdsa.pub"
openssl = shutil.which("openssl")
if not openssl:
return "TOOLING", False
if not key.exists():
return "NO-PUBKEY", True
fp = slh.get("public_key_fingerprint_sha256")
if fp and fp != hashlib.sha256(key.read_bytes()).hexdigest():
return "WRONG-KEY", True
probe = subprocess.run([openssl, "pkey", "-pubin", "-in", str(key), "-noout"],
capture_output=True)
if probe.returncode != 0:
return "TOOLING", False
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)
try:
signature_path.write_bytes(base64.b64decode(slh.get("signature_base64", "")))
except Exception:
return "INVALID", True
result = subprocess.run(
[openssl, "pkeyutl", "-verify", "-pubin", "-inkey", str(key), "-rawin",
"-in", str(payload_path), "-sigfile", str(signature_path)],
capture_output=True,
)
return ("VALID", False) if result.returncode == 0 else ("INVALID", True)
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
entries 13-18: re-attest the estate at 44 certs/fork + first SLH-DSA leaf; heads now dual-signed Six new leaves (tree 13 -> 19, root 7ee23940…): [13-16] dalek/anza/risc0/betrusted-ed25519-verified — re-attested at 44 certificates each (27 main + 4 apex + 13 scalar; the leaf 8-11 generation recorded 16). The delta is the P0-P2 hardening campaign: scalar statements bound, kernel-side axiom gate, driver surface with cones, declaration coverage both directions, the accounting identity. [17] ltl-accumulator-verified — 61 certificates; the log again carries proofs of its own Merkle machinery, at the hardened state. [18] fips205-slhdsa-verified — FIRST post-quantum leaf: 11 certificates over the SLH-DSA-SHA2-128s verify path, apex fips205.slh_verify_128s_accepts_iff. Heads from tree 14 on carry a second, ADDITIVE signature: SLH-DSA-SHA2-128s (FIPS 205), deterministic, over the same payload as the Ed25519 signature. provider.slhdsa.pub ships beside the Ed25519 key. Ed25519 remains the signature consumers must check; verify.py now judges slh_dsa fail-closed where present (INVALID/WRONG-KEY/NO-PUBKEY fail; older heads report ABSENT, allowed; pre-3.5 OpenSSL degrades loudly, never silently). The ml_dsa slot stays not_configured — truthfully. Honesty, unchanged by any of it: the certificates cover VERIFICATION paths of the extracted Lean models; no signing operation is proven for any algorithm; leaves are Ed25519-signed at issuance only. Append-only law checked byte-for-byte before this commit: entries 000000-000012 identical, the six prior heads an exact prefix of the history. verify.py --all: RESULT OK [full]. verify_selftest.py: GREEN (13 cases). Quorum gate: 5-way Ed25519 + 2-way SLH-DSA (incl. the verifier built from the pinned proven source), all accept, all reject corruption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 15:11:59 +00:00
slh_tooling_seen = False
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}")
entries 13-18: re-attest the estate at 44 certs/fork + first SLH-DSA leaf; heads now dual-signed Six new leaves (tree 13 -> 19, root 7ee23940…): [13-16] dalek/anza/risc0/betrusted-ed25519-verified — re-attested at 44 certificates each (27 main + 4 apex + 13 scalar; the leaf 8-11 generation recorded 16). The delta is the P0-P2 hardening campaign: scalar statements bound, kernel-side axiom gate, driver surface with cones, declaration coverage both directions, the accounting identity. [17] ltl-accumulator-verified — 61 certificates; the log again carries proofs of its own Merkle machinery, at the hardened state. [18] fips205-slhdsa-verified — FIRST post-quantum leaf: 11 certificates over the SLH-DSA-SHA2-128s verify path, apex fips205.slh_verify_128s_accepts_iff. Heads from tree 14 on carry a second, ADDITIVE signature: SLH-DSA-SHA2-128s (FIPS 205), deterministic, over the same payload as the Ed25519 signature. provider.slhdsa.pub ships beside the Ed25519 key. Ed25519 remains the signature consumers must check; verify.py now judges slh_dsa fail-closed where present (INVALID/WRONG-KEY/NO-PUBKEY fail; older heads report ABSENT, allowed; pre-3.5 OpenSSL degrades loudly, never silently). The ml_dsa slot stays not_configured — truthfully. Honesty, unchanged by any of it: the certificates cover VERIFICATION paths of the extracted Lean models; no signing operation is proven for any algorithm; leaves are Ed25519-signed at issuance only. Append-only law checked byte-for-byte before this commit: entries 000000-000012 identical, the six prior heads an exact prefix of the history. verify.py --all: RESULT OK [full]. verify_selftest.py: GREEN (13 cases). Quorum gate: 5-way Ed25519 + 2-way SLH-DSA (incl. the verifier built from the pinned proven source), all accept, all reject corruption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 15:11:59 +00:00
if args.structural_only:
slh = "SKIPPED"
else:
slh, slh_hard = check_slh_dsa_signature(head)
if slh_hard:
failures.append(f"STH #{position} slh_dsa {slh}")
if slh == "TOOLING":
slh_tooling_seen = True
print(f"STH #{position} size={size} root={head['root_hash'][:16]}… prefix-root:{structural} signature:{signature} slh_dsa:{slh}")
previous = max(previous, size)
entries 13-18: re-attest the estate at 44 certs/fork + first SLH-DSA leaf; heads now dual-signed Six new leaves (tree 13 -> 19, root 7ee23940…): [13-16] dalek/anza/risc0/betrusted-ed25519-verified — re-attested at 44 certificates each (27 main + 4 apex + 13 scalar; the leaf 8-11 generation recorded 16). The delta is the P0-P2 hardening campaign: scalar statements bound, kernel-side axiom gate, driver surface with cones, declaration coverage both directions, the accounting identity. [17] ltl-accumulator-verified — 61 certificates; the log again carries proofs of its own Merkle machinery, at the hardened state. [18] fips205-slhdsa-verified — FIRST post-quantum leaf: 11 certificates over the SLH-DSA-SHA2-128s verify path, apex fips205.slh_verify_128s_accepts_iff. Heads from tree 14 on carry a second, ADDITIVE signature: SLH-DSA-SHA2-128s (FIPS 205), deterministic, over the same payload as the Ed25519 signature. provider.slhdsa.pub ships beside the Ed25519 key. Ed25519 remains the signature consumers must check; verify.py now judges slh_dsa fail-closed where present (INVALID/WRONG-KEY/NO-PUBKEY fail; older heads report ABSENT, allowed; pre-3.5 OpenSSL degrades loudly, never silently). The ml_dsa slot stays not_configured — truthfully. Honesty, unchanged by any of it: the certificates cover VERIFICATION paths of the extracted Lean models; no signing operation is proven for any algorithm; leaves are Ed25519-signed at issuance only. Append-only law checked byte-for-byte before this commit: entries 000000-000012 identical, the six prior heads an exact prefix of the history. verify.py --all: RESULT OK [full]. verify_selftest.py: GREEN (13 cases). Quorum gate: 5-way Ed25519 + 2-way SLH-DSA (incl. the verifier built from the pinned proven source), all accept, all reject corruption. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 15:11:59 +00:00
if slh_tooling_seen:
print("NOTE: this log carries an ADDITIVE SLH-DSA (FIPS 205) signature that "
"your OpenSSL cannot check (needs >= 3.5). The required Ed25519 checks "
"above still gate this result; the post-quantum signature was NOT verified.")
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())