proof-aware-crypto-tooling-.../src/pacta/quorum.py
mrwulf a7cc3d2a5f warden re-audit (Fable 5): fix ledger race, quorum perf, small-order honesty
Correctness:
- ledger: single-flight fcntl lock over read-modify-append + fsync. Two
  concurrent writers previously could fork the hash chain (read same tail,
  same prev_hash). New test races 8 threads x6 appends; chain stays intact
  with contiguous indices.
- small-order list: the order-8 encodings were hand-typed and unverifiable
  and diverged from the canonical libsodium blocklist. A BOGUS entry is the
  only dangerous direction (it down-grades a real tamper to a note, skipping
  the latch), so the list is now the certain-low-order set only (y in
  {0,1,-1}, reduced/non-reduced, both sign bits); order-8 edges escalate to
  tamper until a derived list lands. Fail-safe asymmetry documented + tested.
- freshness: removed a tautological .

Non-functional:
- quorum members now run concurrently (ThreadPoolExecutor): a verify costs
  one member's latency, not the sum (~17ms for 4 members, live).
- Wallet.quorum() memoized per state_dir: binary swap-detection hashes run
  once at assembly, not on every verify; documented rationale.

85 tests green; live 4-fork wallet re-verified end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:24:51 +02:00

362 lines
15 KiB
Python

"""warden's quorum acceptance boundary.
N-version verification where the versions are provably equivalent on the
proven domain: each quorum member is an Ed25519 verifier compiled from a
source workspace whose correctness certificates are machine-checked in
Lean 4 and replay-attested in the transparency log. Classic N-version
programming fails because independent implementations share design bugs;
here each member's accept() is characterized by a theorem, so runtime
disagreement cannot be a semantics bug on the proven domain - it is
either a documented semantic edge between the forks' accept() predicates
(anza rejects A = 0 and a legacy excluded-R list) or evidence of build
corruption / fault / tampering.
Fail-closed is unconditional: acceptance requires unanimity. The
divergence taxonomy only grades the alarm:
- ``unanimous-accept`` / ``unanimous-reject``: the boring, common cases.
- ``semantic-edge``: members disagree AND the input lies in a documented
degenerate class (small-order R on the legacy exclusion list, zero A,
non-canonical scalar). Verdict: reject; incident severity ``note``.
- ``unexplained``: members disagree and no documented edge explains it.
Verdict: reject; incident severity ``tamper`` - the wallet latches.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .dogfood import _git_commit, _tool_version # shared provenance helpers
QUORUM_STATE_DIR = Path("dogfood") / "state" / "quorum"
# The four proven forks. `source_subdir` is the conventional checkout name
# under --sources-root; `component` names the transparency-log leaf whose
# certificates cover this member's verify path.
QUORUM_BACKENDS: dict[str, dict[str, Any]] = {
"dalek": {
"component": "dalek-ed25519-verified",
"source_subdir": "curve25519-dalek-source",
"crate": Path("dogfood") / "quorum" / "verify-dalek",
"backend_cfg": 'curve25519_dalek_backend="serial"',
"entry_point": "ed25519_dalek::VerifyingKey::verify (pinned workspace)",
"semantics": "upstream-canonical",
"workspace_marker": Path("ed25519-dalek") / "Cargo.toml",
},
"anza": {
"component": "anza-ed25519-verified",
"source_subdir": "anza-cryptography-source",
"crate": Path("dogfood") / "quorum" / "verify-anza",
"backend_cfg": "curve25519_serial_only",
"entry_point": "curve25519::ed_sigs::VerificationKey::verify_sha512 (pinned workspace; NOT the default Zebra-lineage verify())",
"semantics": "anza-strict (rejects A=0 and the legacy excluded-R list)",
"workspace_marker": Path("curve25519") / "solana-ed25519" / "Cargo.toml",
},
"risc0": {
"component": "risc0-ed25519-verified",
"source_subdir": "risc0-curve25519-dalek-source",
"crate": Path("dogfood") / "quorum" / "verify-risc0",
"backend_cfg": 'curve25519_dalek_backend="serial"',
"entry_point": "ed25519_dalek::VerifyingKey::verify (pinned workspace)",
"semantics": "upstream-canonical",
"workspace_marker": Path("ed25519-dalek") / "Cargo.toml",
},
"betrusted": {
"component": "betrusted-ed25519-verified",
"source_subdir": "betrusted-curve25519-dalek-source",
"crate": Path("dogfood") / "quorum" / "verify-betrusted",
"backend_cfg": 'curve25519_dalek_backend="serial"',
"entry_point": "ed25519_dalek::VerifyingKey::verify (pinned workspace)",
"semantics": "upstream-canonical",
"workspace_marker": Path("ed25519-dalek") / "Cargo.toml",
},
}
# Encodings of low-order points, used ONLY to decide whether an inter-fork
# divergence is a documented semantic edge (severity "note") rather than an
# unexplained one (severity "tamper" -> latch).
#
# FAIL-SAFE ASYMMETRY (why this list is deliberately conservative):
# - A MISSING low-order encoding is safe: a genuine edge on it is instead
# classified "unexplained" -> tamper -> custody latches. That is a false
# alarm / availability cost, never a security loss.
# - A BOGUS entry is dangerous: it would down-grade a real tamper to a mere
# "note" and skip the latch. So an entry may appear here ONLY if it is
# provably a low-order encoding.
# Therefore this set contains exactly the encodings that are certainly
# low-order from first principles: y in {0, 1, -1} (orders 4, 1, 2), in both
# their reduced and non-reduced representatives, each with the sign bit clear
# and set. These match RFC 8032 / libsodium's canonical low-order rows for
# those y-values. The order-8 points (whose encodings must be *derived* via
# field arithmetic, not transcribed) are intentionally OMITTED for now: an
# edge on an order-8 R will escalate to tamper until a derived, tested list
# lands. See test_quorum.py::test_small_order_list_is_conservative.
SMALL_ORDER_ENCODINGS: frozenset[bytes] = frozenset(
bytes.fromhex(h)
for h in (
"0000000000000000000000000000000000000000000000000000000000000000", # y=0 (order 4)
"0000000000000000000000000000000000000000000000000000000000000080", # y=0, sign set
"0100000000000000000000000000000000000000000000000000000000000000", # y=1 identity (order 1)
"0100000000000000000000000000000000000000000000000000000000000080", # y=1, sign set
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # y=p-1 = -1 (order 2)
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # y=p-1, sign set
"edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # y=p ≡ 0 (non-canonical, order 4)
"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # y=p+1 ≡ 1 (non-canonical, order 1)
)
)
# Group order l (little-endian comparison target for canonical s).
_L = 2**252 + 27742317777372353535851937790883648493
@dataclass(slots=True)
class MemberVerdict:
backend: str
verdict: str # "accept" | "reject" | "error"
detail: str | None = None
binary_sha256: str | None = None
@dataclass(slots=True)
class QuorumResult:
accepted: bool
classification: str # unanimous-accept | unanimous-reject | semantic-edge | unexplained
verdicts: list[MemberVerdict] = field(default_factory=list)
edge_flags: list[str] = field(default_factory=list)
incident: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
return {
"accepted": self.accepted,
"classification": self.classification,
"verdicts": [
{
"backend": v.backend,
"verdict": v.verdict,
"detail": v.detail,
"binary_sha256": v.binary_sha256,
}
for v in self.verdicts
],
"edge_flags": self.edge_flags,
"incident": self.incident,
}
def semantic_edge_flags(public_key: bytes, signature: bytes) -> list[str]:
"""Name the documented degenerate classes this input falls into.
These are exactly the classes where the four proven accept() predicates
are allowed to differ; anything outside them that still diverges is
treated as tampering.
"""
flags: list[str] = []
r_bytes, s_bytes = signature[:32], signature[32:]
if public_key == b"\x00" * 32:
flags.append("zero-public-key")
if public_key in SMALL_ORDER_ENCODINGS:
flags.append("small-order-public-key")
if r_bytes in SMALL_ORDER_ENCODINGS:
flags.append("small-order-R (legacy exclusion list)")
if int.from_bytes(s_bytes, "little") >= _L:
flags.append("non-canonical-s (s >= group order)")
return flags
def binary_path(backend: str, state_dir: str | Path | None = None) -> Path:
return Path(state_dir or QUORUM_STATE_DIR) / f"pacta-verify-{backend}"
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def build_quorum_member(
backend: str,
sources_root: str | Path,
state_dir: str | Path | None = None,
timeout: int = 900,
) -> dict[str, Any]:
"""Build one quorum member from its pinned proven source workspace.
Returns a provenance dict on success; raises RuntimeError with the
cargo tail on failure. Serial backend pinned per fork exactly as the
verified extraction pins it.
"""
spec = QUORUM_BACKENDS[backend]
source = (Path(sources_root).expanduser() / spec["source_subdir"]).resolve()
crate = Path(spec["crate"]).resolve()
if not (source / spec["workspace_marker"]).exists():
raise RuntimeError(
f"{source} does not look like the pinned {backend} source workspace "
f"(missing {spec['workspace_marker']})"
)
cargo = shutil.which("cargo")
if not cargo:
raise RuntimeError("cargo is not available on PATH")
template = (crate / "Cargo.toml.template").read_text(encoding="utf-8")
(crate / "Cargo.toml").write_text(template.replace("{{SOURCE}}", str(source)), encoding="utf-8")
env = dict(os.environ)
env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + f" --cfg {spec['backend_cfg']}").strip()
completed = subprocess.run(
[cargo, "build", "--release", "--quiet"],
cwd=str(crate),
env=env,
capture_output=True,
text=True,
timeout=timeout,
)
if completed.returncode != 0:
tail = "\n".join((completed.stderr or completed.stdout or "").strip().splitlines()[-12:])
raise RuntimeError(f"cargo build failed for quorum member {backend}:\n{tail}")
built = crate / "target" / "release" / f"pacta-verify-{backend}"
if not built.exists():
raise RuntimeError(f"cargo reported success but {built} does not exist")
out = binary_path(backend, state_dir)
out.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(built, out)
provenance = {
"type": "pacta.quorum.member_provenance.v1",
"backend": backend,
"component": spec["component"],
"semantics": spec["semantics"],
"source_workspace": str(source),
"source_commit": _git_commit(source),
"backend_cfg": spec["backend_cfg"],
"entry_point": spec["entry_point"],
"rustc_version": _tool_version("rustc"),
"cargo_version": _tool_version("cargo"),
"binary_sha256": _sha256_file(out),
}
out.with_suffix(".provenance.json").write_text(
json.dumps(provenance, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
return provenance
def member_provenance(backend: str, state_dir: str | Path | None = None) -> dict[str, Any]:
sidecar = binary_path(backend, state_dir).with_suffix(".provenance.json")
if sidecar.exists():
return json.loads(sidecar.read_text(encoding="utf-8"))
return {}
class QuorumVerifier:
"""Run every member on the same bytes; demand unanimity; grade dissent."""
def __init__(
self,
members: dict[str, Path],
min_members: int = 2,
timeout: int = 30,
) -> None:
if len(members) < min_members:
raise ValueError(
f"quorum needs at least {min_members} members, got {len(members)}: "
f"{sorted(members)}"
)
missing = {name: path for name, path in members.items() if not Path(path).exists()}
if missing:
raise ValueError(f"quorum member binaries missing: {missing}")
self.members = {name: Path(path) for name, path in members.items()}
self.timeout = timeout
def verify(self, payload: bytes, signature: bytes, public_key: bytes) -> QuorumResult:
if len(signature) != 64 or len(public_key) != 32:
raise ValueError("signature must be 64 bytes and public key 32 bytes")
with tempfile.TemporaryDirectory(prefix="pacta-quorum-") as tmp:
payload_path = Path(tmp) / "payload.bin"
payload_path.write_bytes(payload)
# Members are independent subprocesses; run them concurrently so a
# verification costs one member's latency, not the sum. subprocess
# releases the GIL while the child runs, so threads suffice. Sort
# the collected verdicts for a deterministic, position-stable trail.
names = sorted(self.members)
with ThreadPoolExecutor(max_workers=len(names)) as pool:
verdicts = list(
pool.map(
lambda name: self._run_member(
name, self.members[name], public_key, signature, payload_path
),
names,
)
)
return self._judge(verdicts, payload, signature, public_key)
def _run_member(
self, name: str, binary: Path, public_key: bytes, signature: bytes, payload_path: Path
) -> MemberVerdict:
sha = _sha256_file(binary)
try:
completed = subprocess.run(
[str(binary), public_key.hex(), signature.hex(), str(payload_path)],
capture_output=True,
text=True,
timeout=self.timeout,
)
except subprocess.TimeoutExpired:
return MemberVerdict(name, "error", "timeout", sha)
if completed.returncode == 0:
return MemberVerdict(name, "accept", None, sha)
if completed.returncode == 1:
return MemberVerdict(name, "reject", None, sha)
detail = (completed.stderr or completed.stdout or "member error").strip()
return MemberVerdict(name, "error", detail, sha)
def _judge(
self,
verdicts: list[MemberVerdict],
payload: bytes,
signature: bytes,
public_key: bytes,
) -> QuorumResult:
kinds = {v.verdict for v in verdicts}
if kinds == {"accept"}:
return QuorumResult(True, "unanimous-accept", verdicts)
if kinds == {"reject"}:
return QuorumResult(False, "unanimous-reject", verdicts)
# Divergence (including any member error): reject, then grade.
flags = semantic_edge_flags(public_key, signature)
classification = "semantic-edge" if flags and "error" not in kinds else "unexplained"
incident = {
"type": "pacta.quorum.divergence.v1",
"severity": "note" if classification == "semantic-edge" else "tamper",
"classification": classification,
"edge_flags": flags,
"verdicts": [
{"backend": v.backend, "verdict": v.verdict, "detail": v.detail, "binary_sha256": v.binary_sha256}
for v in verdicts
],
"payload_sha256": hashlib.sha256(payload).hexdigest(),
"signature_hex": signature.hex(),
"public_key_hex": public_key.hex(),
}
return QuorumResult(False, classification, verdicts, flags, incident)
def load_quorum(
backends: list[str] | None = None,
state_dir: str | Path | None = None,
min_members: int = 2,
) -> QuorumVerifier:
"""Assemble the quorum from built member binaries in the state dir."""
names = backends or list(QUORUM_BACKENDS)
members = {
name: binary_path(name, state_dir)
for name in names
if binary_path(name, state_dir).exists()
}
return QuorumVerifier(members, min_members=min_members)