#!/usr/bin/env python3 """Standalone verifier for the CAIN-42 multi-region PBFT evidence bundle. No CAIN imports. Needs Python 3.8+, `cryptography`, and verify_pbft_qc_bundle.py (the unchanged QC verifier) in the same directory. python3 verify_multi_region_bundle.py MULTI_REGION_BUNDLE.json 1. Runs every check of verify_pbft_qc_bundle.py (Ed25519 votes, quorums, decision chain identical on all 4 replicas, view-change certificates, authorization certificates, 7 tamper controls). 2. Checks the fault-injection claims against the signatures themselves: * every decision carries signatures from replicas in at least 2 regions (no host holds more than 2 replicas and quorum is 3); * decisions taken while a replica was stopped carry NO signature from it (a stopped replica cannot sign; if it did, the fault schedule is false): mia while mia was down, node-3 while it was down, and NO atl signature at all while the atl region (the view-0 primary) was down; * no request submitted while the two-replica lax host was stopped appears anywhere in any replica's decision chain (fail closed, never executed later); * decisions after the primary was killed are in a higher view and proposed by that view's primary, not the killed one; * every stated phase result is recomputed from the recorded responses. What it cannot prove: WHERE each key physically lives. Placement is operator-attested (see multi_region.hosts). Private keys were generated on their hosts and never left them, which is also an operator statement. """ from __future__ import annotations import json import os import sys from typing import Any, Dict, List sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import verify_pbft_qc_bundle as Q # noqa: E402 def signers(qc: Dict[str, Any]) -> set: s = set(qc["bundle"]["individual_signatures"]) if qc.get("leader_proposal"): s.add(qc["leader_proposal"]["sender_id"]) return s def verify(bundle: Dict[str, Any]) -> Dict[str, Any]: base = Q.verify_bundle(bundle) checks: List[Dict[str, Any]] = list(base["checks"]) def check(name, ok, detail=""): checks.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": detail}) mr = bundle["multi_region"] placement = mr["topology"]["placement"] mb = Q.Membership(bundle["membership"]) check("placement covers exactly the 4 members", sorted(placement) == sorted(mb.members), str(placement)) regions = sorted(set(placement.values())) check("replicas placed in 3 distinct regions", len(regions) == 3, ", ".join(regions)) by_region = {r: {n for n, rr in placement.items() if rr == r} for r in regions} # every (node, seq) -> commit_qc qcs: Dict[int, List[Dict[str, Any]]] = {} for node in bundle["nodes"].values(): for e in node["certificates"]: qcs.setdefault(e["sequence"], []).append(e["commit_qc"]) bad = [s for s, l in qcs.items() if any(len({placement[x] for x in signers(q)}) < 2 for q in l)] check("every decision carries signatures from at least 2 regions", not bad and bool(qcs), f"{len(qcs)} decisions" if not bad else f"single-region sequences: {bad[:10]}") phases = {p["phase"]: p for p in mr["phases"]} for p in mr["phases"]: allowed = sum(1 for r in p["responses"] if r["decision"] == "ALLOW") want = p["requests"] if p["expected"] == "COMMIT" else 0 ok = allowed == p["allowed"] and ((allowed == want) == (p["result"] == "PASS")) if p["expected"] != "COMMIT": same = all(p["commit_index_after"].get(n) == p["commit_index_before"].get(n) for n in p["commit_index_after"]) ok = ok and ((allowed == 0 and same) == (p["result"] == "PASS")) check(f"phase {p['phase']}: stated result {p['result']} recomputed from responses", ok, f"{allowed}/{p['requests']} ALLOW, expected {p['expected']}") check(f"phase {p['phase']}: PASS", p["result"] == "PASS") def absent_during(phase, down, label): seqs = [r["sequence"] for r in phases[phase]["responses"] if r["decision"] == "ALLOW"] hit = [s for s in seqs for q in qcs.get(s, []) if signers(q) & down] missing = [s for s in seqs if s not in qcs] check(f"{phase}: {len(seqs)} decisions carry no signature from stopped {label} {sorted(down)}", bool(seqs) and not hit and not missing, f"sequences {seqs}" if not (hit or missing) else f"signed by stopped replica: {hit} missing: {missing}") absent_during("B_mia_down", by_region["mia"], "mia region") absent_during("C_one_lax_replica_down", {sorted(by_region["lax"])[-1]}, "lax replica") absent_during("E_atl_region_and_primary_down", by_region["atl"], "atl region") d_resources = {f"mr/{r['request_id']}" for r in phases["D_lax_host_down"]["responses"]} leaked = [] for node_id, node in bundle["nodes"].items(): for e in node["certificates"]: lp = e["prepare_qc"].get("leader_proposal") or {} if any(res in json.dumps(lp.get("payload"), sort_keys=True) for res in d_resources): leaked.append((node_id, e["sequence"])) check("fail-closed phase: none of its requests is in any replica's decision chain (not executed later)", not leaked, "absent" if not leaked else f"found: {leaked[:6]}") e_seqs = [r["sequence"] for r in phases["E_atl_region_and_primary_down"]["responses"] if r["decision"] == "ALLOW"] killed_ok = True for s in e_seqs: for q in qcs.get(s, []): lp_q = next(e["prepare_qc"] for n in bundle["nodes"].values() for e in n["certificates"] if e["sequence"] == s) if q["view"] < 1 or lp_q["leader_proposal"]["sender_id"] != mb.primary_for(q["view"]): killed_ok = False view0_primary = mb.primary_for(0) proposers = {next(e["prepare_qc"]["leader_proposal"]["sender_id"] for n in bundle["nodes"].values() for e in n["certificates"] if e["sequence"] == s) for s in e_seqs} check("E_atl_region_and_primary_down: decisions in a higher view, proposed by that view's primary, not the killed view-0 primary", bool(e_seqs) and killed_ok and view0_primary not in proposers, f"proposers {sorted(proposers)}") failed = [c for c in checks if c["result"] == "FAIL"] return {"verifier": "verify_multi_region_bundle.py + verify_pbft_qc_bundle.py (no CAIN imports)", "certificates_verified": base["certificates_verified"], "checks": checks, "passed": len(checks) - len(failed), "total": len(checks), "verdict": "ALL_CHECKS_PASSED" if not failed else "FAILED"} def main(argv: List[str]) -> int: if len(argv) < 2: print(__doc__) return 2 res = verify(Q.load(argv[1])) if "--json" in argv: print(json.dumps(res, indent=1)) else: for c in res["checks"]: print(f"[{c['result']}] {c['check']}" + (f" ({c['detail']})" if c["detail"] else "")) print(f"\n{res['verdict']}: {res['passed']}/{res['total']} checks, " f"{res['certificates_verified']} certificates verified") return 0 if res["verdict"] == "ALL_CHECKS_PASSED" else 1 if __name__ == "__main__": sys.exit(main(sys.argv))