#!/usr/bin/env python3 """ Ainglish register verifier — walk the whole trust chain from public data, trusting no one. The register's integrity claims are only worth anything if a stranger can check them without running our code or believing our prose. This script re-derives everything from the public API, with an independent JCS implementation (Python stdlib only; the optional Bitcoin step shells out to the OpenTimestamps client if installed): 1. RECOMPUTE the canonical register bytes from /api/v1/register.json's entries (JCS: sorted keys, compact separators, raw unicode) and require them to be byte-identical to /api/v1/register.canonical — and their sha256 to equal the published digest. 2. RECHAIN the changelog from /api/v1/changelog using only its published recipe: entry_hash = sha256(JCS({seq, prev_hash, event, slug, version, register_digest, ts})), genesis prev = 64x'0', each entry linking to the last — and require the final entry's register_digest to equal the digest recomputed in step 1. 3. ANCHOR for each anchored version: /anchor/{v}.canonical must hash to the register digest the chain recorded for that version, and the .ots proof (if the `ots` client is installed) must verify against those exact bytes — calendar-pending or all the way to a Bitcoin block. Usage: python3 verify.py # verify https://ainglish.org python3 verify.py --base URL # verify another deployment python3 verify.py --offline DIR # verify dumped artifacts (register.canonical, # register.json, changelog.json) — used by CI's # clean-room test so recipe and code cannot drift Exit code 0 = everything checked out; 1 = a check failed. """ import hashlib import json import re import shutil import subprocess import sys import tempfile import urllib.request GENESIS = "0" * 64 def explorer_merkleroot(height, api="https://blockstream.info/api"): """A block's merkle root per a public explorer (lite verification — labeled as such).""" try: req = urllib.request.Request(f"{api}/block-height/{height}", headers={"User-Agent": "ainglish-verify"}) with urllib.request.urlopen(req, timeout=20) as r: block_hash = r.read().decode().strip() req = urllib.request.Request(f"{api}/block/{block_hash}", headers={"User-Agent": "ainglish-verify"}) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read()).get("merkle_root") except Exception: return None def jcs(value): """Canonical JSON (JCS subset): sorted keys, compact, raw unicode. Independent of the PHP side.""" return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def sha256(data): return hashlib.sha256(data if isinstance(data, bytes) else data.encode()).hexdigest() def fetch(base, path, binary=False): req = urllib.request.Request(base + path, headers={"User-Agent": "ainglish-verify"}) with urllib.request.urlopen(req, timeout=30) as r: body = r.read() return body if binary else json.loads(body) class Verifier: def __init__(self): self.failures = 0 def check(self, ok, label, detail=""): print(f" [{'ok' if ok else 'FAIL'}] {label}" + (f" — {detail}" if detail else "")) if not ok: self.failures += 1 return ok def recompute_register(release): entries = release["entries"] return jcs({"kind": "ainglish.register", "count": len(entries), "entries": entries}).encode() def verify_register(v, canonical_bytes, release): print("== 1. register digest (recomputed from public entries) ==") rebuilt = recompute_register(release) v.check(rebuilt == canonical_bytes, "recomputed canonical bytes are byte-identical to /register.canonical", f"{len(canonical_bytes)} bytes") digest = sha256(canonical_bytes) v.check(digest == release["digest"], "sha256(canonical) == published digest", digest[:16] + "…") return digest def verify_chain(v, changelog, current_digest): print("== 2. changelog chain (recomputed from the published recipe) ==") prev = GENESIS events = changelog["events"] for e in events: recomputed = sha256(jcs({ "seq": e["seq"], "prev_hash": e["prev_hash"], "event": e["event"], "slug": e["slug"], "version": e["version"], "register_digest": e["register_digest"], "ts": e["ts"], })) if not v.check(e["prev_hash"] == prev and recomputed == e["entry_hash"], f"entry #{e['seq']} ({e['event']} {e['slug']} -> register {e['version']})"): return None prev = e["entry_hash"] if events: v.check(events[-1]["register_digest"] == current_digest, "final chain entry's register_digest == current recomputed digest") else: v.check(current_digest is not None, "empty chain, empty register", "nothing to bind yet") return {e["version"]: e["register_digest"] for e in events} def verify_anchors(v, base, anchors, digests_by_version): print("== 3. Bitcoin anchors (OpenTimestamps) ==") ots_bin = shutil.which("ots") if not anchors: print(" (no anchors yet)") return for a in anchors: ver, status = a["version"], a["status"] canonical = fetch(base, f"/anchor/{ver}.canonical", binary=True) expected = digests_by_version.get(ver) v.check(expected is not None and sha256(canonical) == expected, f"v{ver}: anchored canonical bytes hash to the chain's digest for that version") if not a.get("has_ots"): print(f" [ --] v{ver}: no .ots uploaded yet (status {status})") continue proof = fetch(base, f"/anchor/{ver}.ots", binary=True) if ots_bin is None: print(f" [note] v{ver}: .ots present ({len(proof)} bytes, status {status}) — install " "opentimestamps-client and re-run for the Bitcoin walk") continue with tempfile.TemporaryDirectory() as d: open(f"{d}/reg", "wb").write(canonical) open(f"{d}/reg.ots", "wb").write(proof) subprocess.run([ots_bin, "upgrade", f"{d}/reg.ots"], capture_output=True, text=True) r = subprocess.run([ots_bin, "verify", f"{d}/reg.ots"], capture_output=True, text=True) out = r.stdout + r.stderr if "Bitcoin block" in out and "Could not connect" not in out: v.check(True, f"v{ver}: OTS proof verifies", "Bitcoin-confirmed via local node") elif "Pending confirmation" in out: v.check(True, f"v{ver}: OTS proof verifies", "calendar-pending (not yet in a block)") else: # No Bitcoin node — lite path: ots derives which block must carry which merkleroot # from the proof alone; we cross-check that against two INDEPENDENT explorers. lite = subprocess.run([ots_bin, "--no-bitcoin", "verify", "-f", f"{d}/reg", f"{d}/reg.ots"], capture_output=True, text=True) claims = re.findall(r"Bitcoin block (\d+) has merkleroot ([0-9a-f]{64})", lite.stdout + lite.stderr) if not claims: v.check(False, f"v{ver}: OTS proof did not verify", (lite.stdout + lite.stderr).strip()[:160]) continue for height, root in claims: v.check(explorer_merkleroot(height) == root and explorer_merkleroot(height, "https://mempool.space/api") == root, f"v{ver}: Bitcoin block {height} carries merkleroot {root[:16]}…", "cross-checked on 2 independent explorers (run a Bitcoin node for fully trustless)") def main(argv): v = Verifier() if "--offline" in argv: d = argv[argv.index("--offline") + 1] canonical = open(f"{d}/register.canonical", "rb").read() release = json.load(open(f"{d}/register.json")) changelog = json.load(open(f"{d}/changelog.json")) digest = verify_register(v, canonical, release) verify_chain(v, changelog, digest) print(" (offline mode: anchor walk skipped)") else: base = argv[argv.index("--base") + 1] if "--base" in argv else "https://ainglish.org" print(f"verifying {base}") canonical = fetch(base, "/api/v1/register.canonical", binary=True) release = fetch(base, "/api/v1/register.json") changelog = fetch(base, "/api/v1/changelog") digest = verify_register(v, canonical, release) digests = verify_chain(v, changelog, digest) if digests is not None: verify_anchors(v, base, fetch(base, "/api/v1/anchors").get("anchors", []), digests) print() if v.failures: print(f"RESULT: {v.failures} CHECK(S) FAILED — do not trust this register state.") return 1 print("RESULT: REGISTER VERIFIED — digest recomputed, chain intact, anchors bound. Trusted no one.") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))