mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
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>
362 lines
15 KiB
Python
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)
|