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>
This commit is contained in:
mrwulf 2026-07-07 08:24:51 +02:00
parent 6b9f033151
commit a7cc3d2a5f
4 changed files with 129 additions and 34 deletions

View file

@ -30,6 +30,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -80,23 +81,36 @@ QUORUM_BACKENDS: dict[str, dict[str, Any]] = {
}, },
} }
# The eight small-order points' canonical encodings plus their sign-flipped # Encodings of low-order points, used ONLY to decide whether an inter-fork
# variants - the classic excluded-R list (as used by Solana's legacy # divergence is a documented semantic edge (severity "note") rather than an
# exclusion and libsodium's checks). Presence of R (or A) on this list is # unexplained one (severity "tamper" -> latch).
# what makes an inter-fork divergence a *documented semantic edge*. #
# 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( SMALL_ORDER_ENCODINGS: frozenset[bytes] = frozenset(
bytes.fromhex(h) bytes.fromhex(h)
for h in ( for h in (
"0100000000000000000000000000000000000000000000000000000000000000", # identity "0000000000000000000000000000000000000000000000000000000000000000", # y=0 (order 4)
"0000000000000000000000000000000000000000000000000000000000000000", # (0, 0)-ish y=0 encoding "0000000000000000000000000000000000000000000000000000000000000080", # y=0, sign set
"0000000000000000000000000000000000000000000000000000000000000080", # y=0, sign flipped "0100000000000000000000000000000000000000000000000000000000000000", # y=1 identity (order 1)
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # -1 = p-1 (order 2) "0100000000000000000000000000000000000000000000000000000000000080", # y=1, sign set
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", # order-8 point "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # y=p-1 = -1 (order 2)
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", # order-8, sign flipped "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # y=p-1, sign set
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", # order-8 point "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # y=p ≡ 0 (non-canonical, order 4)
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", # order-8, sign flipped "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # y=p+1 ≡ 1 (non-canonical, order 1)
"0100000000000000000000000000000000000000000000000000000000000080", # identity, sign flipped
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # p-1 with high bit
) )
) )
@ -263,12 +277,23 @@ class QuorumVerifier:
def verify(self, payload: bytes, signature: bytes, public_key: bytes) -> QuorumResult: def verify(self, payload: bytes, signature: bytes, public_key: bytes) -> QuorumResult:
if len(signature) != 64 or len(public_key) != 32: if len(signature) != 64 or len(public_key) != 32:
raise ValueError("signature must be 64 bytes and public key 32 bytes") raise ValueError("signature must be 64 bytes and public key 32 bytes")
verdicts: list[MemberVerdict] = []
with tempfile.TemporaryDirectory(prefix="pacta-quorum-") as tmp: with tempfile.TemporaryDirectory(prefix="pacta-quorum-") as tmp:
payload_path = Path(tmp) / "payload.bin" payload_path = Path(tmp) / "payload.bin"
payload_path.write_bytes(payload) payload_path.write_bytes(payload)
for name, binary in sorted(self.members.items()): # Members are independent subprocesses; run them concurrently so a
verdicts.append(self._run_member(name, binary, public_key, signature, payload_path)) # 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) return self._judge(verdicts, payload, signature, public_key)
def _run_member( def _run_member(

View file

@ -32,6 +32,7 @@ boundary does not pretend to certify its apologies.
from __future__ import annotations from __future__ import annotations
import fcntl
import hashlib import hashlib
import json import json
import os import os
@ -224,6 +225,7 @@ class Wallet:
self.receipts_dir = self.dir / "receipts" self.receipts_dir = self.dir / "receipts"
self.quarantine_dir = self.dir / "quarantine" self.quarantine_dir = self.dir / "quarantine"
self.airgap_dir = self.dir / "airgap" self.airgap_dir = self.dir / "airgap"
self._quorum_cache: dict[str, QuorumVerifier] = {}
# -- init / R4 gate ------------------------------------------------------ # -- init / R4 gate ------------------------------------------------------
@ -450,19 +452,34 @@ class Wallet:
] ]
def _append_ledger(self, entry_type: str, body: dict[str, Any]) -> dict[str, Any]: def _append_ledger(self, entry_type: str, body: dict[str, Any]) -> dict[str, Any]:
entries = self._ledger_entries() # Single-flight: the chain is read-modify-append, so two concurrent
prev_hash = entries[-1]["entry_hash"] if entries else "0" * 64 # writers (threads, or two processes sharing a wallet dir) could both
entry = { # read the same tail, compute the same prev_hash, and fork the chain.
"index": len(entries), # An advisory exclusive lock over the whole critical section - taken
"timestamp": _now(), # AFTER reopening under the lock so the prev-read reflects any writer
"entry_type": entry_type, # that just finished - makes appends serialize. fsync so a crash can't
"body": body, # leave a torn line that verify_ledger would read as tampering.
"prev_hash": prev_hash, self.ledger_path.touch(exist_ok=True)
} with self.ledger_path.open("r+", encoding="utf-8") as handle:
entry["entry_hash"] = _sha256(_canonical(entry)) fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
with self.ledger_path.open("a", encoding="utf-8") as handle: try:
handle.write(json.dumps(entry, sort_keys=True) + "\n") existing = [json.loads(line) for line in handle.read().splitlines() if line.strip()]
return entry prev_hash = existing[-1]["entry_hash"] if existing else "0" * 64
entry = {
"index": len(existing),
"timestamp": _now(),
"entry_type": entry_type,
"body": body,
"prev_hash": prev_hash,
}
entry["entry_hash"] = _sha256(_canonical(entry))
handle.seek(0, os.SEEK_END)
handle.write(json.dumps(entry, sort_keys=True) + "\n")
handle.flush()
os.fsync(handle.fileno())
return entry
finally:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def verify_ledger(self) -> tuple[bool, list[str]]: def verify_ledger(self) -> tuple[bool, list[str]]:
problems: list[str] = [] problems: list[str] = []
@ -484,6 +501,18 @@ class Wallet:
# -- quorum assembly ------------------------------------------------------- # -- quorum assembly -------------------------------------------------------
def quorum(self, state_dir: str | Path | None = None) -> QuorumVerifier: def quorum(self, state_dir: str | Path | None = None) -> QuorumVerifier:
# Memoized per state_dir: assembling the quorum re-reads and SHA-256s
# every member binary (a swap-detection control). Doing that on every
# verify would hash ~4 binaries per operation; instead we pin once at
# first assembly and hold the verifier. A binary swapped mid-process
# is therefore caught at the next assembly (restart / re-init), which
# is the right granularity: an attacker who can rewrite the on-disk
# binary already outranks this check, and re-hashing per call buys
# nothing against them while taxing every honest verification.
cache_key = str(state_dir) if state_dir is not None else "__default__"
cached = self._quorum_cache.get(cache_key)
if cached is not None:
return cached
capsule = self.capsule() capsule = self.capsule()
members: dict[str, Path] = {} members: dict[str, Path] = {}
for member in capsule["members"]: for member in capsule["members"]:
@ -498,7 +527,9 @@ class Wallet:
f"({actual[:12]} != {member['binary_sha256'][:12]}); rebuild or re-init" f"({actual[:12]} != {member['binary_sha256'][:12]}); rebuild or re-init"
) )
members[name] = binary members[name] = binary
return QuorumVerifier(members, min_members=int(capsule["policy"]["min_members"])) verifier = QuorumVerifier(members, min_members=int(capsule["policy"]["min_members"]))
self._quorum_cache[cache_key] = verifier
return verifier
# -- inbound --------------------------------------------------------------- # -- inbound ---------------------------------------------------------------
@ -625,9 +656,9 @@ class Wallet:
request, request,
) )
capsule = self.capsule() capsule = self.capsule()
freshness = self._check_freshness(capsule) stale = self._check_freshness(capsule)
if freshness is not None: if stale is not None:
return freshness if isinstance(freshness, Refusal) else freshness return stale
problem = self._validate_intent(intent, payload) problem = self._validate_intent(intent, payload)
if problem: if problem:
return self._refuse( return self._refuse(

View file

@ -92,6 +92,18 @@ def test_edge_flags_detect_classes():
assert any("non-canonical-s" in f for f in flags) assert any("non-canonical-s" in f for f in flags)
def test_small_order_list_is_conservative():
# The fail-safe contract: every listed encoding must be a certain
# low-order y-value (0, 1, or -1), reduced or non-reduced, sign bit
# either way. No order-8 or unverifiable entries may sneak in, because a
# bogus entry would down-grade a real tamper to a note.
p = 2**255 - 19
allowed_y = {0, 1, p - 1, p, p + 1}
for enc in SMALL_ORDER_ENCODINGS:
y = int.from_bytes(enc, "little") & ((1 << 255) - 1) # strip sign bit
assert y in allowed_y, f"non-low-order encoding in list: {enc.hex()}"
def test_quorum_requires_min_members(tmp_path): def test_quorum_requires_min_members(tmp_path):
with pytest.raises(ValueError): with pytest.raises(ValueError):
_quorum(tmp_path, {"only": "accept"}) _quorum(tmp_path, {"only": "accept"})

View file

@ -94,6 +94,33 @@ def test_ledger_is_hash_chained(tmp_path):
assert not ok2 and problems2 assert not ok2 and problems2
def test_ledger_survives_concurrent_appends(tmp_path):
# The single-flight lock must keep the chain intact under parallel writers
# (two threads racing on the same wallet's ledger).
import threading
wallet = _seal_wallet(tmp_path, {"a": "accept", "b": "accept"}, tmp_path / "state")
start = threading.Barrier(8)
def worker(n):
start.wait()
for i in range(6):
wallet._append_ledger("stress", {"worker": n, "i": i})
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
ok, problems = wallet.verify_ledger()
assert ok, problems
# genesis + 48 appends, all with unique strictly-increasing indices
entries = wallet._ledger_entries()
indices = [e["index"] for e in entries]
assert indices == list(range(len(entries)))
assert len(entries) == 1 + 8 * 6
def test_outbound_firewall_releases_when_quorum_agrees(tmp_path): def test_outbound_firewall_releases_when_quorum_agrees(tmp_path):
from pacta.dogfood import locate_verifier from pacta.dogfood import locate_verifier