#!/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, all the way to a Bitcoin block. A proof that is still calendar-pending, or an anchor with no proof at all, is reported as NOT BOUND; a proof this machine could not examine (no `ots` client) is reported as NOT CHECKED. Neither is a pass, neither is a failure, and the final verdict cannot claim "anchors bound" while either stands. Usage: python3 verify.py # verify https://ainglish.org python3 verify.py --base URL # verify another deployment python3 verify.py --require-bound # also FAIL if any version is unbound or unchecked 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 = nothing disagreed; 1 = a check failed (or --require-bound and the binding is incomplete). """ import hashlib import json import re import shutil import subprocess import sys import tempfile import urllib.error 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: """Three outcomes, kept apart, because collapsing them is what produced a false conclusion. A check can pass or fail. But a version can also be NOT BOUND (it exists and demonstrably has no block behind it — no proof uploaded, or a proof still sitting with the calendars) or NOT CHECKED (this machine could not look, because the `ots` client is absent). Those are different claims and neither is a pass: "definitely not anchored" and "unknown" must not read as "independently verified", and on a clean machine with no OTS client installed — the ordinary third-party case — NOT CHECKED is the whole anchor section. (@dexagon-ai on #24.) """ def __init__(self): self.failures = 0 self.not_bound = [] self.not_checked = [] 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 unbound(self, version, detail): """No block stands behind this version yet. Not a failure — a young or unstamped anchor is not misconduct — but the summary may not call it bound.""" print(f" [note] v{version}: {detail}") self.not_bound.append(version) def unchecked(self, version, detail): """This machine could not check the proof, so it verified nothing about it. Silence here used to be indistinguishable from success.""" print(f" [note] v{version}: {detail}") self.not_checked.append(version) def result_line(failures, not_bound, not_checked): """The one place the verdict sentence is built, so "anchors bound" is STRUCTURALLY unreachable while anything is unbound or unchecked, rather than merely absent from the branch we happened to take. selftest() asserts that for every combination.""" if failures: return f"RESULT: {failures} CHECK(S) FAILED — do not trust this register state." caveats = [] if not_bound: caveats.append("NOT BOUND TO BITCOIN: v" + ", v".join(not_bound)) if not_checked: caveats.append("NOT CHECKED HERE (install opentimestamps-client and re-run): v" + ", v".join(not_checked)) if caveats: return ("RESULT: register digest recomputed and chain intact, and nothing checked here " "disagreed — but the Bitcoin binding is INCOMPLETE. " + ". ".join(caveats) + ".") return "RESULT: REGISTER VERIFIED — digest recomputed, chain intact, anchors bound. Trusted no one." def ots_verdict(out): """Classify `ots verify` output: 'pending' | 'confirmed' | 'unknown'. Extracted and ordered so the pending case is decided FIRST, because the confirmed marker is a SUBSTRING of the pending one: "Pending confirmation in Bitcoin blockchain" contains the literal "Bitcoin block". The previous `if "Bitcoin block" in out` therefore claimed "Bitcoin-confirmed via local node" for every calendar-pending proof, on a machine with no Bitcoin node, and left its own `elif "Pending confirmation"` branch unreachable. Observed against a real stamp of register v0.2.0 minutes after submitting it to four calendars. """ if "Pending confirmation" in out: return "pending" if re.search(r"Bitcoin block \d+", out) and "Could not connect" not in out: return "confirmed" return "unknown" def selftest(): """Two invariants: the three real `ots verify` output shapes, and that the verdict sentence cannot claim a binding it does not have.""" # "anchors bound" must be reachable ONLY from the all-clear. Every other combination — a # version with no proof, a proof nobody examined, both at once — must withhold it. This is the # branch @dexagon-ai found still open on #24: `not_bound` was populated only when an installed # client returned pending, so the no-proof and no-client paths printed a note, continued, and # let the summary claim everything was bound. assert "anchors bound" in result_line(0, [], []), "the all-clear must still be sayable" for bound, checked in ([["0.2.0"], []], [[], ["0.1.0"]], [["0.2.0"], ["0.1.0"]]): line = result_line(0, bound, checked) assert "anchors bound" not in line, f"claimed a binding with not_bound={bound} not_checked={checked}" assert "INCOMPLETE" in line, "and it must say so, not merely omit the claim" assert "NOT BOUND TO BITCOIN: v0.2.0" in result_line(0, ["0.2.0"], []) assert "NOT CHECKED HERE" in result_line(0, [], ["0.1.0"]) # A real failure outranks both and is the only case that must exit non-zero. assert result_line(1, ["0.2.0"], ["0.1.0"]).startswith("RESULT: 1 CHECK(S) FAILED") # The recording methods must feed those lists, or the invariant above guards nothing. v = Verifier() v.unbound("0.2.0", "no .ots uploaded") v.unchecked("0.1.0", "not examined") assert (v.not_bound, v.not_checked, v.failures) == (["0.2.0"], ["0.1.0"], 0), \ "unbound/unchecked must record without counting as failures" assert "anchors bound" not in result_line(v.failures, v.not_bound, v.not_checked) pending = ("Calendar https://btc.calendar.catallaxy.com: Pending confirmation in Bitcoin blockchain\n" "Calendar https://alice.btc.calendar.opentimestamps.org: Pending confirmation in Bitcoin blockchain") confirmed = "Success! Bitcoin block 960468 attests existence as of 2026-08-01" no_node = ("Could not connect to Bitcoin node: Cookie file unusable ([Errno 2] No such file or " "directory: '/root/.bitcoin/.cookie')") assert ots_verdict(pending) == "pending", "a calendar-pending proof must never read as confirmed" assert ots_verdict(confirmed) == "confirmed", "a real Bitcoin attestation must be recognised" assert ots_verdict(no_node) == "unknown", "no node must fall through to the explorer cross-check" # The exact collision, stated as an assertion rather than a comment. assert "Bitcoin block" in pending, "premise: the pending text contains the confirmed marker" assert ots_verdict(pending) != "confirmed", "so ordering, not substring matching, is what saves us" # A version whose canonical bytes are gone must be RECORDED as unbound, not end the walk. # verify_anchors fetched /anchor/{v}.canonical before it classified anything, so a single 404 — # exactly what an `unreconstructable` version serves — raised out of the loop with a traceback. # Every later version, including every confirmed one, then went unexamined and no verdict was # ever printed. The classification below is the whole point of the walk, so reaching it must not # depend on the one version that cannot be fetched. global fetch real_fetch = fetch def stub_fetch(base, path, binary=False): if path.endswith("/0.27.0.canonical"): raise urllib.error.HTTPError(base + path, 404, "Not Found", None, None) if path.endswith(".canonical"): return b"bytes" raise AssertionError(f"unexpected fetch of {path}") fetch = stub_fetch try: walked = Verifier() verify_anchors(walked, "https://example.invalid", [ {"version": "0.27.0", "status": "unreconstructable", "has_ots": False}, {"version": "0.28.0", "status": "confirmed", "has_ots": False}, ], {"0.28.0": sha256(b"bytes")}) finally: fetch = real_fetch assert "0.27.0" in walked.not_bound, "a version with no canonical bytes must be recorded unbound" assert "0.28.0" in walked.not_bound, "and the walk must continue to every later version" assert walked.failures == 0, "an absent canonical is an incomplete binding, not a failed check" print("verify.py selftest OK: pending/confirmed/no-node classified, the 'Bitcoin blockchain' " "substring collision pinned, and 'anchors bound' unreachable while anything is unbound " "or unchecked.") return 0 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"] try: canonical = fetch(base, f"/anchor/{ver}.canonical", binary=True) except urllib.error.HTTPError as e: # An `unreconstructable` version serves no canonical bytes, so there is nothing to hash # and nothing for a proof to bind. That is an incomplete binding to record and walk past, # not an error to raise: raising here ended the whole walk and left every later version # unexamined with no verdict printed at all. v.unbound(ver, f"canonical bytes unavailable (HTTP {e.code}, status {status}) — " "nothing to verify against") continue 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"): # An anchor slot with no proof binds nothing at all. This printed and continued, so the # summary still said "anchors bound" for a version that had never been stamped. v.unbound(ver, f"NO .ots uploaded (status {status}) — nothing binds this version yet") continue proof = fetch(base, f"/anchor/{ver}.ots", binary=True) if ots_bin is None: # The common third-party path: no client, so the proof was never examined. Recording it # as UNCHECKED is the difference between "we verified this" and "we did not look". v.unchecked(ver, f".ots present ({len(proof)} bytes, status {status}) but NOT examined — " "no opentimestamps-client on this machine") 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 verdict = ots_verdict(out) if verdict == "pending": # NOT a check: a stamped-but-unconfirmed proof is a promise from a calendar, not a # binding to Bitcoin, so it must not count toward "anchors bound". v.unbound(ver, "stamped, calendar-pending — no block carries it yet; re-run after `ots upgrade`") elif verdict == "confirmed": v.check(True, f"v{ver}: OTS proof verifies", "Bitcoin-confirmed via local node") 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): if "--selftest" in argv: return selftest() 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() print(result_line(v.failures, v.not_bound, v.not_checked)) if v.failures: return 1 # Exit 0 for unbound/unchecked by default: a young timestamp is not misconduct, and a stranger # without the OTS client should still be told the digest and chain check out. But a CI job # gating on this script would then go green on a machine that examined no proof at all, so # --require-bound makes the incomplete state fail rather than merely print. if "--require-bound" in argv and (v.not_bound or v.not_checked): print(" (--require-bound: an incomplete Bitcoin binding is a failure in this mode)") return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))