#!/usr/bin/env python3 """Independent checker for the CAIN-42 Byzantine-cluster bundle. Imports NOTHING from CAIN. Needs Python 3 and (for the signature) the `cryptography` package. python3 verify_cluster_bundle.py [DIR_OR_BASE_URL] [--live] Checks: (1) every file's SHA-256 and size; (2) the bundle root; (3) the Ed25519 signature over the canonical manifest; (4) every number in manifest["claims"] recomputed from the recorded probe results and logs. --live additionally runs cluster_probe.py (published in this bundle) against the public site NOW and prints whether the live verdict still matches the recorded one, so anyone can see drift instead of trusting a snapshot. Exit 0 only if every check passes. It does not re-run the consensus tests: see REPRODUCE.txt. """ import base64, hashlib, json, re, subprocess, sys, tempfile, urllib.request from pathlib import Path from urllib.parse import urlparse src = next((a for a in sys.argv[1:] if not a.startswith("--")), ".") def read(name): if src.startswith("http"): return urllib.request.urlopen(src.rstrip("/") + "/" + name, timeout=30).read() return (Path(src) / name).read_bytes() problems = [] m = json.loads(read("manifest.json")) sha = lambda b: hashlib.sha256(b).hexdigest() leaves = [] for f in m["files"]: try: b = read(f["path"]) except Exception as e: problems.append(f"cannot read {f['path']}: {e}"); continue if sha(b) != f["sha256"] or len(b) != f["bytes"]: problems.append(f"HASH/SIZE MISMATCH {f['path']}") leaves.append(f["sha256"]) if sha("\n".join(sorted(leaves)).encode()) != m["bundle_root"]: problems.append("bundle_root mismatch") canon = lambda o: json.dumps(o, sort_keys=True, separators=(",", ":")).encode() try: from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey Ed25519PublicKey.from_public_bytes(base64.b64decode(m["signer_public_key"])).verify( base64.b64decode(m["signature"]), canon({k: v for k, v in m.items() if k != "signature"})) sig = "valid" except ImportError: sig = "SKIPPED (cryptography not installed)" except Exception: sig = "INVALID"; problems.append("signature invalid") derived = {"site_verdicts": {}, "site_derived": {}} hosts = set() for site in m["sites"]: p = json.loads(read(f"probe-{site}.json")) derived["site_verdicts"][site] = p["verdict"] derived["site_derived"][site] = p["derived"] for label, n in p["nodes"].items(): if not label.startswith("gateway@"): # the three site domains are one gateway (node2), not three hosts hosts.add(urlparse(label.split("@", 1)[1]).hostname) derived["distinct_remote_node_hosts"] = len(hosts) for name in ("consensus-test-run.txt", "probe-test-run.txt"): t = read(name).decode() derived[name + ":passed"] = sum(int(x) for x in re.findall(r"(\d+) passed", t)) derived[name + ":failed"] = sum(int(x) for x in re.findall(r"(\d+) failed", t)) for k, v in derived.items(): if m["claims"].get(k) != v: problems.append(f"claim {k}: manifest says {m['claims'].get(k)!r}, files give {v!r}") print(json.dumps({"bundle": m["bundle_id"], "files": len(m["files"]), "signature": sig, "recorded_verdicts": derived["site_verdicts"], "recorded_derived": derived["site_derived"]}, indent=1)) if "--live" in sys.argv: base = m["sites"][next(iter(m["sites"]))] with tempfile.NamedTemporaryFile("wb", suffix=".py", delete=False) as fh: fh.write(read("cluster_probe.py.txt")); probe = fh.name r = subprocess.run([sys.executable, probe, base], capture_output=True, text=True, timeout=300) live = json.loads(r.stdout) print(f"LIVE now ({live['probed_utc']}): verdict={live['verdict']} derived={live['derived']}") for x in live["reasons"]: print(" -", x) if live["verdict"] != derived["site_verdicts"].get(next(iter(m["sites"]))): print(" ** live verdict differs from the recorded snapshot **") print("VALID" if not problems else "INVALID:\n " + "\n ".join(problems)) sys.exit(0 if not problems else 1)