mirror of
https://github.com/saymrwulf/lean-transparency-log.git
synced 2026-09-03 19:53:47 +00:00
verify.py fail-closed + full receipt bindings; README drops the 'honesty'/'first' overclaim (review round 10)
GPT-5.6 live-site review + Fable live verification: - verify.py §4: was fail-OPEN — no openssl ⇒ signatures 'skipped' ⇒ still 'OK'. Now fail-CLOSED (exit 2 unless --structural-only, which is explicitly labelled a reduced check). --receipt was Merkle-path-only; now verifies the receipt's STH signature, key fingerprint, log_id, tree_size agreement, leaf_hash-vs-entry, and history membership before the inclusion proof. --all now also checks latest-sth == final history head and size == leaf count, and constant log_id. Adversarially tested: fail-closed exit 2; forged unsigned-root receipt REJECTED (was the 'inclusion VALID' hole); honest receipt + full run still exit 0. - README §8: I had written 'the first deployed transparency log to carry proofs of its own honesty' this session — an overclaim (the corpus proves properties of the accumulator MODEL, not operator honesty). Now: 'kernel-checked proofs about the accumulator model underlying its inclusion/consistency reasoning', 'first' softened to 'unaware of a precedent'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
dea415a60c
commit
4e5c39b93c
2 changed files with 109 additions and 26 deletions
|
|
@ -6,11 +6,12 @@ of specific software, at specific git commits, re-check with exactly their
|
||||||
documented assumptions. Its first twelve leaves attest four cryptographic
|
documented assumptions. Its first twelve leaves attest four cryptographic
|
||||||
Rust libraries (Ed25519 implementations); as of **entry 13 (2026-07-16)**
|
Rust libraries (Ed25519 implementations); as of **entry 13 (2026-07-16)**
|
||||||
the log also attests **its own accumulator machinery** — a kernel-checked
|
the log also attests **its own accumulator machinery** — a kernel-checked
|
||||||
mechanization of the log's own security analysis, making this the first
|
mechanization of the log's security analysis, so the log carries
|
||||||
deployed transparency log to carry proofs of its own honesty as one of
|
kernel-checked proofs *about the accumulator model* underlying its own
|
||||||
its own entries (subject
|
inclusion and consistency reasoning, as one of its own entries (subject
|
||||||
[`ltl-accumulator-verified`](https://github.com/saymrwulf/ltl-accumulator-verified);
|
[`ltl-accumulator-verified`](https://github.com/saymrwulf/ltl-accumulator-verified);
|
||||||
scoped to the mechanized model). Current head: tree size 13, root
|
scoped to the mechanized model — it does not prove operator honesty,
|
||||||
|
signing, or execution provenance). Current head: tree size 13, root
|
||||||
`3488a2d0…`.
|
`3488a2d0…`.
|
||||||
|
|
||||||
Layout:
|
Layout:
|
||||||
|
|
|
||||||
126
verify.py
126
verify.py
|
|
@ -1,18 +1,29 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Standalone verifier for the published Lean Transparency Log.
|
"""Standalone verifier for the published Lean Transparency Log.
|
||||||
|
|
||||||
Python 3 standard library ONLY - no pacta, no pip. Verifies, from the
|
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:
|
files in this repository alone:
|
||||||
|
|
||||||
1. every entry's leaf hash,
|
1. every entry's leaf hash,
|
||||||
2. every historical Signed Tree Head against the recomputed prefix root
|
2. every historical Signed Tree Head against the recomputed prefix root
|
||||||
(this is the witness check: a split view or tampered entry fails here),
|
(a split view or tampered entry fails here),
|
||||||
3. every STH Ed25519 signature (via the openssl binary, if available),
|
3. every STH Ed25519 signature,
|
||||||
4. any receipt's inclusion proof (--receipt FILE).
|
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.
|
||||||
|
|
||||||
|
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:
|
Usage:
|
||||||
python3 verify.py --all
|
python3 verify.py --all
|
||||||
python3 verify.py --receipt receipts/dalek-ed25519-verified.receipt.json
|
python3 verify.py --receipt receipts/dalek-ed25519-verified.receipt.json
|
||||||
|
python3 verify.py --all --structural-only # explicit reduced check
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
|
|
@ -82,11 +93,21 @@ def load_leaves():
|
||||||
return leaves, problems
|
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:
|
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")
|
openssl = shutil.which("openssl")
|
||||||
key = HERE / "provider.ed25519.pub"
|
key = HERE / "provider.ed25519.pub"
|
||||||
if not openssl or not key.exists():
|
if not openssl or not key.exists():
|
||||||
return "skipped (openssl or provider.ed25519.pub missing)"
|
return "UNAVAILABLE"
|
||||||
signatures = head.get("signatures") or {}
|
signatures = head.get("signatures") or {}
|
||||||
ed = signatures.get("ed25519") or {}
|
ed = signatures.get("ed25519") or {}
|
||||||
payload = canonical_json({k: v for k, v in head.items() if k != "signatures"})
|
payload = canonical_json({k: v for k, v in head.items() if k != "signatures"})
|
||||||
|
|
@ -107,46 +128,107 @@ def main() -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--all", action="store_true")
|
parser.add_argument("--all", action="store_true")
|
||||||
parser.add_argument("--receipt")
|
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()
|
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()
|
leaves, problems = load_leaves()
|
||||||
print(f"entries: {len(leaves)}")
|
print(f"entries: {len(leaves)}")
|
||||||
failures = list(problems)
|
failures = list(problems)
|
||||||
for problem in problems:
|
for problem in problems:
|
||||||
print("PROBLEM:", problem)
|
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:
|
if args.all or not args.receipt:
|
||||||
history_path = HERE / "sth-history.jsonl"
|
# log-wide checks (GPT §4.3): history internally consistent AND the
|
||||||
heads = [json.loads(line) for line in history_path.read_text().splitlines() if line.strip()] if history_path.exists() else []
|
# published latest-sth.json is exactly the final history head.
|
||||||
previous = -1
|
previous = -1
|
||||||
|
log_id = None
|
||||||
for position, head in enumerate(heads):
|
for position, head in enumerate(heads):
|
||||||
size = int(head["tree_size"])
|
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()
|
expected = merkle_root(leaves[:size]).hex()
|
||||||
structural = "OK" if head["root_hash"] == expected and size >= previous else "MISMATCH"
|
structural = "OK" if head["root_hash"] == expected and size >= previous else "MISMATCH"
|
||||||
if structural != "OK":
|
if structural != "OK":
|
||||||
failures.append(f"STH #{position}")
|
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)
|
signature = check_sth_signature(head)
|
||||||
if signature == "INVALID":
|
if signature == "INVALID" or (signature == "UNAVAILABLE" and not args.structural_only):
|
||||||
failures.append(f"STH #{position} signature")
|
failures.append(f"STH #{position} signature {signature}")
|
||||||
print(f"STH #{position} size={size} root={head['root_hash'][:16]}… prefix-root:{structural} signature:{signature}")
|
print(f"STH #{position} size={size} root={head['root_hash'][:16]}… prefix-root:{structural} signature:{signature}")
|
||||||
previous = max(previous, size)
|
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")
|
||||||
|
|
||||||
if args.receipt:
|
if args.receipt:
|
||||||
receipt = json.loads(Path(args.receipt).read_text())
|
receipt = json.loads(Path(args.receipt).read_text())
|
||||||
|
sth = receipt["sth"]
|
||||||
index = int(receipt["leaf_index"])
|
index = int(receipt["leaf_index"])
|
||||||
entry = json.loads((HERE / "entries" / f"{index:06d}.json").read_text())
|
entry = json.loads((HERE / "entries" / f"{index:06d}.json").read_text())
|
||||||
ok = verify_inclusion(
|
leaf_bytes = canonical_json(entry["leaf"])
|
||||||
canonical_json(entry["leaf"]),
|
|
||||||
index,
|
|
||||||
int(receipt["tree_size"]),
|
|
||||||
[bytes.fromhex(h) for h in receipt["inclusion_proof"]],
|
|
||||||
bytes.fromhex(receipt["sth"]["root_hash"]),
|
|
||||||
)
|
|
||||||
print(f"receipt leaf {index} of {receipt['tree_size']}: inclusion {'VALID' if ok else 'INVALID'}")
|
|
||||||
if not ok:
|
|
||||||
failures.append("receipt inclusion")
|
|
||||||
|
|
||||||
print("RESULT:", "OK - the log is internally consistent" if not failures else f"FAILED ({len(failures)} problems)")
|
# (a) the receipt's STH must be signed by THIS log's key ...
|
||||||
return 0 if not failures else 1
|
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"
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue