#!/usr/bin/env python3 """Standalone verifier for the SDN corpus transparency chain. No arguments. Chain integrity (fails the run on any break): * seq is 1,2,3,… no gaps * prev linkage: entry.prev == previous entry.h (null for seq 1) * h integrity: sha256(RFC8785(entry without "h")) == entry.h (tolerant of entries with or without optional fields like verified_by — it hashes whatever fields are present) * archive integrity: every entry with changed=True has its .xml.xz, and each archive decompresses to bytes whose sha256 == corpus_sha256. OTS anchoring (SEPARATE information, never fails the run): * per seq, reports attested / pending / missing based on `ots verify`. """ import hashlib import json import os import subprocess import sys import rfc8785 HERE = os.path.dirname(os.path.abspath(__file__)) CHAIN = os.path.join(HERE, "corpus_chain.jsonl") ARCHIVE = os.path.join(HERE, "archive") HEADS = os.path.join(HERE, "heads") OTS_BIN = os.path.join(HERE, "venv", "bin", "ots") OTS_TIMEOUT = 45 def _sha256_hex(b: bytes) -> str: return hashlib.sha256(b).hexdigest() def fail(seq, msg): print(f"CHAIN INCONSISTENT at seq={seq}: {msg}") sys.exit(1) def _ots_status(seq, head_h): """Return (status, detail): attested | pending | missing | error. head_h is written inside heads/.txt; we cross-check it too.""" head_path = os.path.join(HEADS, f"{seq}.txt") ots_path = head_path + ".ots" if not os.path.exists(ots_path): return "missing", "no .ots" # sanity: the head file must actually contain this entry's h try: content = open(head_path).read().strip() if content != head_h: return "error", f"head file content {content[:16]}… != entry.h {head_h[:16]}…" except Exception as e: return "error", f"head unreadable: {e}" try: p = subprocess.run([OTS_BIN, "verify", head_path], capture_output=True, text=True, timeout=OTS_TIMEOUT) out = (p.stdout + p.stderr).lower() if "success" in out and ("block" in out or "bitcoin" in out): return "attested", "confirmed in a Bitcoin block" if "pending" in out: return "pending", "calendar commitment; awaiting Bitcoin confirmation" # No node / cannot confirm: fall back to structural info info = subprocess.run([OTS_BIN, "info", ots_path], capture_output=True, text=True, timeout=OTS_TIMEOUT) itxt = (info.stdout + info.stderr).lower() if "bitcoin" in itxt and "verify" in itxt: return "attested", "has Bitcoin attestation (no local node to reconfirm)" if "pendingattestation" in itxt or "calendar" in itxt or "pending" in itxt: return "pending", "calendar commitment only (structural)" return "pending", f"unrecognised ots output: {out.strip()[:120]}" except subprocess.TimeoutExpired: return "error", "ots verify timed out" except Exception as e: return "error", f"ots verify error: {type(e).__name__}: {e}" def main() -> int: if not os.path.exists(CHAIN): print("OK (empty): no chain file yet") return 0 prev_h = None expected_seq = 1 entries = [] seen_archives = set() with open(CHAIN, "r", encoding="utf-8") as f: for raw_line in f: raw_line = raw_line.strip() if not raw_line: continue try: e = json.loads(raw_line) except Exception as ex: fail(expected_seq, f"unparseable line: {ex}") seq = e.get("seq") if seq != expected_seq: fail(seq, f"seq gap: expected {expected_seq}, found {seq}") if e.get("prev") != prev_h: fail(seq, f"prev linkage broken: entry.prev={e.get('prev')!r} != previous h={prev_h!r}") body = {k: v for k, v in e.items() if k != "h"} recomputed = _sha256_hex(rfc8785.dumps(body)) if recomputed != e.get("h"): fail(seq, f"h mismatch: stored={e.get('h')} recomputed={recomputed}") csha = e.get("corpus_sha256") if e.get("changed"): arc = os.path.join(ARCHIVE, f"{csha}.xml.xz") if not os.path.exists(arc): fail(seq, f"changed=True but archive missing: {csha}.xml.xz") if arc not in seen_archives: import lzma with lzma.open(arc, "rb") as fh: got = _sha256_hex(fh.read()) if got != csha: fail(seq, f"archive corrupt: {csha}.xml.xz hashes to {got}") seen_archives.add(arc) entries.append(e) prev_h = e["h"] expected_seq += 1 print(f"CHAIN OK: {len(entries)} entries, prev-linkage + h + {len(seen_archives)} archive(s) verified. head={prev_h}") # --- OTS column (separate; does not affect chain result) --- print("\nOTS anchoring (informational — pending/missing does NOT fail the chain):") counts = {"attested": 0, "pending": 0, "missing": 0, "error": 0} for e in entries: status, detail = _ots_status(e["seq"], e["h"]) counts[status] = counts.get(status, 0) + 1 print(f" seq {e['seq']:>3} h={e['h'][:16]}… OTS: {status:<9} ({detail})") print(f" totals: attested={counts['attested']} pending={counts['pending']} " f"missing={counts['missing']} error={counts.get('error',0)}") return 0 if __name__ == "__main__": sys.exit(main())