#!/usr/bin/env python3 """Independent verifier for CAIN multi-process Byzantine-consensus evidence. stdlib + `cryptography` only; imports NOTHING from CAIN. python3 verify_bft_evidence.py DIR_OR_FILES... # e.g. the bft/ folder of the frontier evidence bundle It re-derives everything from the RAW SIGNED MESSAGES each node exported, and does not trust any node's own summary: * every vote's Ed25519 signature is re-verified over its canonical hash against the key pinned for the claimed node_id; each node's recorded accept/reject verdict must match * all nodes must have pinned the SAME peer keys, and each live node's own key must equal the pinned key for its id * a node that reports COMMITTED must be backed by >= quorum distinct valid PREPARE and COMMIT votes for the leader's commitment * SAFETY: no two nodes committed different commitments * BYZANTINE PROOFS are derived from the messages: EQUIVOCATION (two valid signatures by one node for the same phase/view/seq with different commitments) and DEVIATION (a valid PREPARE whose commitment differs from the leader's valid signed PRE_PREPARE) * forged / relabeled votes: every injected vote must fail verification, and no valid vote may have been rejected * scenario rules (by name): honest, byzantine_wrong_commitment, equivocation, forged_and_relabeled_votes, crash_one_node (live nodes must commit), crash_two_nodes (f=1 exceeded: nobody may commit), crash_one_node__before_fix (a documented liveness bug: the verifier EXPECTS the stall and reports it as such) SCOPE (also printed): all nodes ran on ONE host over 127.0.0.1. This verifies protocol behaviour and signatures, not independence of failure domains.""" import base64, glob, hashlib, json, os, sys from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey def vhash(v): return hashlib.sha256(json.dumps({"phase": v["phase"], "view": v["view_number"], "seq": v["sequence_number"], "traj": v["trajectory_id"], "commit": v["commitment"], "node": v["node_id"], "time": v["timestamp"]}, sort_keys=True, separators=(",", ":")).encode()).hexdigest() def sig_ok(v, pub): try: Ed25519PublicKey.from_public_bytes(base64.b64decode(pub)).verify(base64.b64decode(v["signature_b64"]), vhash(v).encode()); return True except Exception: return False def vote_valid(v, pinned): p = pinned.get(v.get("node_id")) return bool(p) and v.get("public_key_b64") == p and sig_ok(v, p) def key(v): return (v["node_id"], v["phase"], v["view_number"], v["sequence_number"]) def verify(d): C = {}; live = {n: x["evidence"] for n, x in d["nodes"].items() if x["alive"]}; name = d["scenario"]; Q = d["quorum"] pins = {n: e["pinned_peers"] for n, e in live.items()}; ref = next(iter(pins.values())) C["all_nodes_pinned_identical_keys"] = all(p == ref for p in pins.values()) C["each_live_node_key_matches_pinned"] = all(e["public_key_b64"] == ref.get(n) for n, e in live.items()) dead_keys_ok = all(x.get("public_key_b64") == ref.get(n) for n, x in d["nodes"].items() if not x["alive"]); C["dead_nodes_keys_pinned"] = dead_keys_ok verdicts_ok = True; seen = {}; all_votes = {} for n, e in live.items(): for m in e["raw_inbox"]: if vote_valid(m["vote"], ref) != m["accepted"]: verdicts_ok = False if vote_valid(m["vote"], ref): all_votes[(n,) + (vhash(m["vote"]),)] = m["vote"] for v in e["raw_outbox"]: if not vote_valid(v, ref): verdicts_ok = False all_votes[(n, vhash(v))] = v C["every_recorded_verdict_matches_recomputation"] = verdicts_ok unique = {vhash(v): v for v in all_votes.values()} leader_pp = [v for v in unique.values() if v["phase"] == "PRE_PREPARE"]; C["leader_pre_prepare_present_and_valid"] = len(leader_pp) >= 1 and all(vote_valid(v, ref) for v in leader_pp) lc = leader_pp[0]["commitment"] if leader_pp else None committed = {} for n, e in live.items(): st = e["final_state"] if st["phase"] == "COMMITTED": mine = {} for m in e["raw_inbox"]: if m["accepted"]: mine[key(m["vote"])] = m["vote"] for v in e["raw_outbox"]: mine[key(v)] = v prep = {k[0] for k, v in mine.items() if k[1] == "PREPARE" and v["commitment"] == lc and vote_valid(v, ref)}; com = {k[0] for k, v in mine.items() if k[1] == "COMMIT" and v["commitment"] == lc and vote_valid(v, ref)} committed[n] = {"prepares": len(prep), "commits": len(com), "backed": len(prep) >= Q and len(com) >= Q, "commitment": st["committed_commitment"]} C["every_COMMITTED_node_is_backed_by_a_quorum_of_valid_signed_votes"] = all(c["backed"] for c in committed.values()) C["safety_no_two_nodes_committed_different_values"] = len({c["commitment"] for c in committed.values()}) <= 1 C["committed_value_equals_leader_signed_proposal"] = all(c["commitment"] == lc for c in committed.values()) # byzantine proofs from the messages themselves by_key = {} for v in unique.values(): if v["phase"] in ("PREPARE", "COMMIT", "PRE_PREPARE") and vote_valid(v, ref): by_key.setdefault(key(v), []).append(v) equiv = sorted({k[0] for k, vs in by_key.items() if len({x["commitment"] for x in vs}) > 1}) deviate = sorted({v["node_id"] for v in unique.values() if v["phase"] == "PREPARE" and lc and vote_valid(v, ref) and v["commitment"] != lc}) # injected forgeries inj = d.get("injected_messages", []); forged_rejected = all(not vote_valid(m["vote"], ref) for m in inj); C["all_injected_votes_fail_verification"] = forged_rejected inj_accepted = [m for m in inj if any(x["vote"] == m["vote"] and x["accepted"] for e in live.values() for x in e["raw_inbox"])]; C["no_injected_vote_was_accepted_by_any_node"] = not inj_accepted n_committed = sorted(committed) S = {"committed_nodes": n_committed, "equivocation_proofs_against": equiv, "deviation_proofs_against": deviate, "injected": len(inj)} rules = { "honest": lambda: n_committed == sorted(d["nodes"]) and not equiv and not deviate, "byzantine_wrong_commitment": lambda: len(n_committed) >= 3 and {"node3"} == set(deviate) and not equiv, "equivocation": lambda: len(n_committed) >= 3 and equiv == ["node3"], "forged_and_relabeled_votes": lambda: len(n_committed) == 4 and len(inj) == 3 and not equiv and not deviate, "crash_one_node": lambda: n_committed == sorted(live) and len(live) == 3, "crash_two_nodes": lambda: n_committed == [] and len(live) == 2, "crash_one_node__before_fix": lambda: n_committed == [] and len(live) == 3} # documented liveness bug: survivors stalled C["scenario_rule_satisfied:" + name] = bool(rules.get(name, lambda: False)()); S["scenario_rule_known"] = name in rules return C, S def main(): args = sys.argv[1:] or ["."]; files = [] for a in args: files += sorted(glob.glob(os.path.join(a, "*.json"))) if os.path.isdir(a) else [a] files = [f for f in files if not f.endswith("manifest.json")]; res = {}; ok = True for f in files: d = json.load(open(f)); C, S = verify(d); res[d["scenario"]] = {"checks": C, "summary": S, "passed": all(C.values())}; ok &= all(C.values()) print(json.dumps({"scenarios": res, "overall": "ALL_VERIFIED_WITH_LIMITATIONS" if ok and res else "FAILED", "limitations": ["all nodes ran on ONE host over 127.0.0.1 (independent processes and keys, not independent failure domains)", "N=4, quorum 3, f=1; a single leader and one view; no view change, no network partitions, no message loss", "self-generated by the operator; the raw signed messages are exported so you can re-verify signatures, but the run itself was not observed by a third party", "this is the CAIN 23.0 test node service (with a documented liveness fix), not the production PBFT engine"]}, indent=1)); return 0 if ok and res else 1 if __name__ == "__main__": sys.exit(main())