From e6c989679ddb32555e100e68298e01d69f652800 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 15:09:09 +0000 Subject: [PATCH 01/22] Fail closed on acceptance keys: whitespace reject, demo flag off (#53) Reject whitespace-only keys; require --allow-demo-key (off by default) on entry points; raise on invalid configured keys even with the demo flag. Persist demo keys at .acceptance_key (0600) in --db mode. Fix ledger docstring and document sign_acceptance. Add subprocess entry-point tests. Co-authored-by: Marcus Richards --- .github/workflows/ci.yml | 3 + .gitignore | 3 + README.md | 7 +- demo.py | 24 +++- demos/demo_admission.py | 35 +++-- demos/demo_approval.py | 16 ++- demos/demo_delegation.py | 16 ++- demos/demo_six_beat.py | 16 ++- demos/demo_sla_escrow.py | 16 ++- permit/cli.py | 47 +++++-- permit/ledger.py | 3 +- scenarios/sla_escrow.py | 8 +- server.py | 38 +++++- settlement/verifier.py | 118 +++++++++++++---- tests/test_acceptance_key.py | 68 +++++++++- tests/test_cli.py | 2 +- tests/test_entry_point_keys.py | 231 +++++++++++++++++++++++++++++++++ trace.py | 38 +++++- 18 files changed, 612 insertions(+), 77 deletions(-) create mode 100644 tests/test_entry_point_keys.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 938fbc7..d85c576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: retention-days: 14 - name: mock demo smoke if: matrix.python-version == '3.12' + env: + # Test-only key for the smoke path (#53: demo key is off by default). + PERMIT_ACCEPTANCE_KEY: permit-ci-only-acceptance-key-0123456789ab run: python demo.py test-optimized: diff --git a/.gitignore b/.gitignore index 26b5000..cc86754 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,8 @@ demo_transcript.replay.jsonl coverage.xml .coverage +# Durable mock-mode demo acceptance keys (server.py --db --allow-demo-key). +*.acceptance_key + # The shipped replay fixture IS committed (judge path); local recordings stay ignored. !demos/fixtures/demo_transcript.jsonl diff --git a/README.md b/README.md index 123f675..3037ca2 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ where `remaining(P) = cap(P) − reserved(P) − captured(P)`. Four clauses, no ```bash python -m pip install pytest python -m pytest -q # full suite: mock rail, no credentials, no network +export PERMIT_ACCEPTANCE_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')" python demo.py # end-to-end mock demo, ends with the ledger chain check +# Or, for a one-shot mock demo only: python demo.py --allow-demo-key ``` More demos (in `demos/`; `trace.py` is the canonical evidence path — @@ -71,9 +73,10 @@ Permit fails closed. When state is uncertain, money does not move. | Tampered delivery evidence | Release refused, hold voided | **What it does not defend (production boundaries):** -- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. +- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key` with mode `0600` so restarts do not rotate it (#53). - HTTP POSTs require a single shared bearer token (`PERMIT_API_TOKEN`, #23) with no role split between principal and agent (F5). GETs, including `/api/ledger`, are unauthenticated; the server binds to 127.0.0.1. -- Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 bytes (`PERMIT_ACCEPTANCE_KEY`); demos opt in explicitly to a per-run generated key (F12, #35). +- Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, under 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53). +- `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of at least 32 bytes. There is no module-level fallback key (#35/#53 API change). - The ledger is a hash chain, not a signature. It detects edits and reordering, but it cannot detect removal of final entries without an external checkpoint. - It is a single-merchant sandbox prototype, not a claim about all agent payments. - E-stop voids uncaptured authorizations. A completed capture needs a refund path, which is out of scope. diff --git a/demo.py b/demo.py index c1937e1..77b6e6a 100644 --- a/demo.py +++ b/demo.py @@ -47,17 +47,22 @@ def show(receipts, prefix=""): print(f"{prefix}#{r.seq:02d} {r.event_type:10s} {detail}") -def build_pipeline(): +def build_pipeline(*, allow_demo_key: bool = False): ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + try: + verifier = ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=allow_demo_key + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) return flow, paypal, permits, ledger -def demo_mock(): - flow, paypal, permits, ledger = build_pipeline() +def demo_mock(*, allow_demo_key: bool = False): + flow, paypal, permits, ledger = build_pipeline(allow_demo_key=allow_demo_key) merchant = "demo_merchant" print("== 1. grant ==") @@ -120,13 +125,22 @@ def demo_mock(): def main(): ap = argparse.ArgumentParser(description="Permit end-to-end demo") ap.add_argument("--sandbox", action="store_true", help="use the real sandbox rail") + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) args = ap.parse_args() if args.sandbox: print("Sandbox mode needs PERMIT_PAYPAL_CLIENT_ID/SECRET and interactive") print("payer approval per order - run from a session with a browser tap.") print("Use mock mode for the scripted camera run.") return - demo_mock() + demo_mock(allow_demo_key=args.allow_demo_key) if __name__ == "__main__": diff --git a/demos/demo_admission.py b/demos/demo_admission.py index f95e0d8..7b1433f 100644 --- a/demos/demo_admission.py +++ b/demos/demo_admission.py @@ -24,6 +24,7 @@ from pathlib import Path as _Path _sys.path.insert(0, str(_Path(__file__).resolve().parents[1])) +import argparse import threading from datetime import datetime, timedelta, timezone @@ -37,11 +38,13 @@ ARTIFACT_HASH = "ab" * 32 -def build(cap_cents): +def build(cap_cents, *, allow_demo_key: bool = False): ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=allow_demo_key + ) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) permit, _ = permits.grant( agent_id="demo-agent", @@ -59,9 +62,9 @@ def attempt(flow, permit_id, amount_cents): ) -def beat_runaway_loop(): +def beat_runaway_loop(*, allow_demo_key: bool = False): print("=== beat A: runaway loop refused at budget exhaustion ===") - flow, paypal, permits, permit = build(3000) + flow, paypal, permits, permit = build(3000, allow_demo_key=allow_demo_key) refused = 0 for i in range(1, 7): a = attempt(flow, permit.permit_id, 1000) @@ -76,9 +79,9 @@ def beat_runaway_loop(): return permits -def beat_concurrent_grab(): +def beat_concurrent_grab(*, allow_demo_key: bool = False): print("=== beat B: parallel overspend attempts cannot exceed the budget ===") - flow, paypal, permits, permit = build(5000) + flow, paypal, permits, permit = build(5000, allow_demo_key=allow_demo_key) outcomes = [] lock = threading.Lock() @@ -105,10 +108,24 @@ def worker(): def main(): + ap = argparse.ArgumentParser(description="Permit admission-gating demo") + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) + args = ap.parse_args() print("Permit admission-gating demo (mock rail)") - permits = beat_runaway_loop() - print() - permits_b = beat_concurrent_grab() + try: + permits = beat_runaway_loop(allow_demo_key=args.allow_demo_key) + print() + permits_b = beat_concurrent_grab(allow_demo_key=args.allow_demo_key) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc print() for label, p in (("beat A", permits), ("beat B", permits_b)): ok, reason = p.ledger.verify_chain() diff --git a/demos/demo_approval.py b/demos/demo_approval.py index e23aaa3..38364f1 100644 --- a/demos/demo_approval.py +++ b/demos/demo_approval.py @@ -99,12 +99,26 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--fast", action="store_true") ap.add_argument("--port", type=int, default=8474) + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) args = ap.parse_args() ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + try: + verifier = ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=args.allow_demo_key + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) dash = Dashboard(permits, ledger, flow, verifier, port=args.port).start() print(f"dashboard: http://127.0.0.1:{args.port}") diff --git a/demos/demo_delegation.py b/demos/demo_delegation.py index 1c51646..c1f26af 100644 --- a/demos/demo_delegation.py +++ b/demos/demo_delegation.py @@ -83,12 +83,26 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--fast", action="store_true") ap.add_argument("--port", type=int, default=8471) + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) args = ap.parse_args() ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + try: + verifier = ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=args.allow_demo_key + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) dash = Dashboard(permits, ledger, flow, verifier, port=args.port).start() print(f"dashboard: http://127.0.0.1:{args.port}") diff --git a/demos/demo_six_beat.py b/demos/demo_six_beat.py index 3536c9e..9284644 100644 --- a/demos/demo_six_beat.py +++ b/demos/demo_six_beat.py @@ -120,6 +120,15 @@ def main(): ap.add_argument("--replay", default=None) ap.add_argument("--fast", action="store_true") ap.add_argument("--port", type=int, default=8471) + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) args = ap.parse_args() if args.replay: import os as _os @@ -133,7 +142,12 @@ def main(): ledger = Ledger() permits = PermitStore(ledger=ledger) paypal = MockPayPalClient() - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + try: + verifier = ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=args.allow_demo_key + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) dash = Dashboard(permits, ledger, flow, verifier, port=args.port).start() print(f"dashboard: http://127.0.0.1:{args.port}") diff --git a/demos/demo_sla_escrow.py b/demos/demo_sla_escrow.py index 6ed7658..b791a7c 100644 --- a/demos/demo_sla_escrow.py +++ b/demos/demo_sla_escrow.py @@ -52,12 +52,26 @@ def beat(n: int, title: str, fast: bool) -> None: def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--fast", action="store_true") + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) args = ap.parse_args() paypal = MockPayPalClient() ledger = Ledger() print("Running SLA escrow on the mock rail (no network, no credentials).") - t = run_sla_escrow(paypal, ledger=ledger) + try: + t = run_sla_escrow( + paypal, ledger=ledger, allow_demo_key=args.allow_demo_key + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc for i, b in enumerate(t["beats"], 1): beat(i, TITLES[b["beat"]], args.fast) diff --git a/permit/cli.py b/permit/cli.py index bee209d..20480b3 100644 --- a/permit/cli.py +++ b/permit/cli.py @@ -24,13 +24,15 @@ def _require(condition: bool, message: str) -> None: raise DemoInvariantError(message) -def _pipeline() -> tuple[ - SpendPipeline, MockPayPalClient, PermitStore, ReleaseVerifier, Ledger -]: +def _pipeline( + *, allow_demo_key: bool = False +) -> tuple[SpendPipeline, MockPayPalClient, PermitStore, ReleaseVerifier, Ledger]: ledger = Ledger() permits = PermitStore(ledger=ledger) provider = MockPayPalClient() - verifier = ReleaseVerifier(provider, permits, ledger=ledger, allow_demo_key=True) + verifier = ReleaseVerifier( + provider, permits, ledger=ledger, allow_demo_key=allow_demo_key + ) flow = SpendPipeline(permits, provider, verifier, ledger=ledger) return flow, provider, permits, verifier, ledger @@ -44,9 +46,11 @@ def _grant(permits: PermitStore, cap_cents: int, merchant: str): )[0] -def run_demo() -> None: +def run_demo(*, allow_demo_key: bool = False) -> None: """Exercise capture, cap blocking, and e-stop using only in-memory mocks.""" - flow, provider, permits, _verifier, ledger = _pipeline() + flow, provider, permits, _verifier, ledger = _pipeline( + allow_demo_key=allow_demo_key + ) merchant = "offline_demo_merchant" artifact = b"offline demo deliverable" digest = hashlib.sha256(artifact).hexdigest() @@ -91,9 +95,11 @@ def run_demo() -> None: print("OFFLINE MOCK ONLY: no network, credentials, or real money were used") -def run_recovery_demo() -> None: +def run_recovery_demo(*, allow_demo_key: bool = False) -> None: """Exercise dropped-response reconciliation using only the mock provider.""" - flow, provider, permits, verifier, ledger = _pipeline() + flow, provider, permits, verifier, ledger = _pipeline( + allow_demo_key=allow_demo_key + ) merchant = "offline_demo_merchant" artifact = b"offline recovery deliverable" digest = hashlib.sha256(artifact).hexdigest() @@ -135,20 +141,34 @@ def run_recovery_demo() -> None: print("OFFLINE MOCK ONLY: this is not restart recovery and no real money moved") +def _add_allow_demo_key(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="permit", description="Run credential-free, headless Permit mock demonstrations.", ) commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser( + demo = commands.add_parser( "demo", help="show mock capture, cap blocking, e-stop, and ledger verification", ) - commands.add_parser( + _add_allow_demo_key(demo) + recovery = commands.add_parser( "demo-recovery", help="show mock UNKNOWN-to-CAPTURED reconciliation without duplicate capture", ) + _add_allow_demo_key(recovery) return parser @@ -156,10 +176,13 @@ def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) try: if args.command == "demo": - run_demo() + run_demo(allow_demo_key=args.allow_demo_key) elif args.command == "demo-recovery": - run_recovery_demo() + run_recovery_demo(allow_demo_key=args.allow_demo_key) except DemoInvariantError as exc: print(f"DEMO FAILED: {exc}") return 1 + except ValueError as exc: + print(f"acceptance key error: {exc}") + return 1 return 0 diff --git a/permit/ledger.py b/permit/ledger.py index ffc67a4..6da3d9d 100644 --- a/permit/ledger.py +++ b/permit/ledger.py @@ -10,7 +10,8 @@ settlement verifier refuses to proceed on a broken chain. No external format dependency (Interlock cut 2026-10-02, agent review). -Demo keys are hardcoded and labeled as such — no key-management scope. +Acceptance keys are not managed here: settlement fails closed without a +configured key (issues #35/#53); library code ships no demo-key fallback. """ from __future__ import annotations diff --git a/scenarios/sla_escrow.py b/scenarios/sla_escrow.py index b727723..3636f57 100644 --- a/scenarios/sla_escrow.py +++ b/scenarios/sla_escrow.py @@ -50,11 +50,15 @@ def money(cents: int) -> str: return f"${cents / 100:.2f}" -def run_sla_escrow(paypal, ledger: Ledger | None = None) -> dict: +def run_sla_escrow( + paypal, ledger: Ledger | None = None, *, allow_demo_key: bool = False +) -> dict: """Run the SLA escrow scenario. Returns a transcript dict of beats.""" ledger = ledger if ledger is not None else Ledger() permits = PermitStore(ledger=ledger) - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=True) + verifier = ReleaseVerifier( + paypal, permits, ledger=ledger, allow_demo_key=allow_demo_key + ) pipeline = SpendPipeline(permits, paypal, verifier, ledger=ledger) beats: list[dict] = [] now = datetime.now(timezone.utc) diff --git a/server.py b/server.py index 828aed9..f177731 100644 --- a/server.py +++ b/server.py @@ -508,6 +508,17 @@ def main(): " default is in-memory (no persistence)") ap.add_argument("--sandbox", action="store_true", help="use the real sandbox rail (needs env creds + interactive payer approval)") + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (mock/demo only; off by default). With --db, the key is " + "persisted next to the ledger as .acceptance_key mode 0600 " + "so restarts do not rotate it (#53)." + ), + ) args = ap.parse_args() if args.sandbox: @@ -527,7 +538,28 @@ def main(): paypal = MockPayPalClient() log.info("mock rail (no network, no credentials)") - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=MOCK_MODE) + # Demo key is an explicit opt-in (#53), not implied by mock mode. + # With --db, persist the demo key so durable mock mode does not + # rotate the signing key across restarts. + demo_key_path = ( + f"{args.db}.acceptance_key" + if args.allow_demo_key and args.db + else None + ) + + def _build_verifier(active_permits, active_ledger): + try: + return ReleaseVerifier( + paypal, + active_permits, + ledger=active_ledger, + allow_demo_key=args.allow_demo_key, + demo_key_path=demo_key_path, + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc + + verifier = _build_verifier(permits, ledger) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) if args.db: @@ -539,10 +571,12 @@ def main(): folded = fold_receipts(db_ledger.receipts()) folded.ledger = db_ledger ledger, permits = db_ledger, folded - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=MOCK_MODE) + verifier = _build_verifier(permits, ledger) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) log.info(f"durable ledger at {args.db}" f" ({len(ledger)} receipts restored)") + if demo_key_path: + log.info(f"demo acceptance key persisted at {demo_key_path} (mode 0600)") # Reap TTL-expired payer-approval operations (daemon; 60s cadence). # /resume also reaps on hit, so an expired op never captures. diff --git a/settlement/verifier.py b/settlement/verifier.py index f637842..eb687ae 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -101,14 +101,20 @@ class ReconcileResult: receipt: Receipt | None = None -# Acceptance key (issue #35): there is NO fallback key in library code. -# A known fallback key lets anyone forge Type A acceptances in a default -# deployment, so construction fails closed: the key must come from the -# caller or from PERMIT_ACCEPTANCE_KEY, and must be at least 32 bytes. -# Demo code opts in explicitly with allow_demo_key=True, which generates -# a fresh random key per verifier instance (per run), never a constant. +# Acceptance key (issues #35, #53): there is NO fallback key in library +# code. A known fallback key lets anyone forge Type A acceptances in a +# default deployment, so construction fails closed: the key must come +# from the caller or from PERMIT_ACCEPTANCE_KEY, be at least 32 bytes, +# and must not be whitespace-only. Demo entry points opt in explicitly +# with --allow-demo-key / allow_demo_key=True (off by default). When that +# flag is set and no key is configured, a fresh random key is generated; +# if a durable demo_key_path is also given (server --db), the key is +# persisted mode 0600 so restarts do not rotate it. A key that IS set +# (env or argument) but invalid always raises — the demo flag never +# papered over a bad configured key. import os as _os import secrets as _secrets +from pathlib import Path as _Path MIN_ACCEPTANCE_KEY_BYTES = 32 @@ -119,28 +125,83 @@ def _coerce_key(key: bytes | str | None) -> bytes | None: return key.encode() if isinstance(key, str) else bytes(key) +def _require_valid_key(resolved: bytes, *, what: str = "acceptance key") -> bytes: + """Reject empty, whitespace-only, and short keys.""" + if resolved.strip() == b"": + if len(resolved) == 0: + raise ValueError(f"{what} is empty") + raise ValueError(f"{what} is whitespace-only") + if len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: + raise ValueError( + f"{what} under {MIN_ACCEPTANCE_KEY_BYTES} bytes " + f"(got {len(resolved)})" + ) + return resolved + + +def load_or_create_demo_key(path: str | _os.PathLike[str]) -> bytes: + """Load a demo acceptance key from ``path``, or create one at mode 0600. + + Used by durable mock mode (``server.py --db`` with ``--allow-demo-key``) + so the key does not silently rotate across restarts (#53). + """ + key_path = _Path(path) + if key_path.exists(): + return _require_valid_key( + key_path.read_bytes(), what=f"demo acceptance key at {key_path}" + ) + key = _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) + key_path.parent.mkdir(parents=True, exist_ok=True) + fd = _os.open( + key_path, + _os.O_WRONLY | _os.O_CREAT | _os.O_EXCL, + 0o600, + ) + try: + _os.write(fd, key) + finally: + _os.close(fd) + _os.chmod(key_path, 0o600) + return key + + def resolve_acceptance_key( - key: bytes | str | None = None, *, allow_demo_key: bool = False + key: bytes | str | None = None, + *, + allow_demo_key: bool = False, + demo_key_path: str | _os.PathLike[str] | None = None, ) -> bytes: """Resolve and validate the acceptance key, failing closed. Order: explicit ``key`` argument, then PERMIT_ACCEPTANCE_KEY. - Missing, empty, or short (under 32 byte) keys raise ValueError, - unless ``allow_demo_key`` is set, in which case a fresh per-call - random key is generated (demo use only, off by default). + A key that is set (argument or env) but empty, whitespace-only, or + short (under 32 bytes) always raises ValueError — even when + ``allow_demo_key`` is set (#53). + + When no key is configured at all and ``allow_demo_key`` is set: + if ``demo_key_path`` is given, load or create a 0600 key file there; + otherwise generate a fresh per-call random key (ephemeral demos). """ - resolved = _coerce_key(key) - if resolved is None: + if key is not None: + resolved = _coerce_key(key) + if resolved is None: + raise ValueError("acceptance key is empty") + return _require_valid_key(resolved) + + if "PERMIT_ACCEPTANCE_KEY" in _os.environ: resolved = _coerce_key(_os.environ.get("PERMIT_ACCEPTANCE_KEY")) - if resolved is None or len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: - if allow_demo_key: - return _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) - raise ValueError( - "acceptance key missing, empty, or under " - f"{MIN_ACCEPTANCE_KEY_BYTES} bytes: pass acceptance_key or set " - "PERMIT_ACCEPTANCE_KEY (fail closed, issue #35)" - ) - return resolved + return _require_valid_key(resolved if resolved is not None else b"") + + if allow_demo_key: + if demo_key_path is not None: + return load_or_create_demo_key(demo_key_path) + return _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) + + raise ValueError( + "acceptance key missing, empty, whitespace-only, or under " + f"{MIN_ACCEPTANCE_KEY_BYTES} bytes: pass acceptance_key or set " + "PERMIT_ACCEPTANCE_KEY (fail closed, issues #35/#53)" + ) def sign_acceptance( @@ -149,10 +210,12 @@ def sign_acceptance( """What the counterparty (acceptance key holder) does off-camera. The key is required: there is no module-level key to fall back to. + Empty, whitespace-only, and short keys are rejected (#53). """ resolved = _coerce_key(key) - if resolved is None or len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: + if resolved is None: raise ValueError("sign_acceptance requires a key of at least 32 bytes") + resolved = _require_valid_key(resolved, what="sign_acceptance key") msg = f"{escrow_id}|{artifact_hash}|{amount_cents}".encode() return hmac.new(resolved, msg, hashlib.sha256).hexdigest() @@ -166,12 +229,15 @@ def __init__( *, acceptance_key: bytes | str | None = None, allow_demo_key: bool = False, + demo_key_path: str | _os.PathLike[str] | None = None, ): - # Fail closed at construction (issue #35): no usable acceptance - # key, no verifier. Demo callers pass allow_demo_key=True and get - # a fresh random key for this instance only. + # Fail closed at construction (issues #35/#53): no usable + # acceptance key, no verifier. Demo callers pass + # allow_demo_key=True only when no key is configured. self._acceptance_key = resolve_acceptance_key( - acceptance_key, allow_demo_key=allow_demo_key + acceptance_key, + allow_demo_key=allow_demo_key, + demo_key_path=demo_key_path, ) self.paypal = paypal self.permits = permits diff --git a/tests/test_acceptance_key.py b/tests/test_acceptance_key.py index 9846bab..a38920c 100644 --- a/tests/test_acceptance_key.py +++ b/tests/test_acceptance_key.py @@ -1,17 +1,16 @@ -"""Issue #35: the release verifier must fail closed on its acceptance key. +"""Issue #35/#53: the release verifier must fail closed on its acceptance key. -Before this fix, settlement/verifier.py fell back to a hardcoded +Before #35, settlement/verifier.py fell back to a hardcoded DEMO_ACCEPTANCE_KEY when PERMIT_ACCEPTANCE_KEY was unset, so anyone who read the repo could forge Type A acceptances in a default deployment. -Pass bar (from the issue): -- No fallback key in library code; the constant is gone. -- Missing, empty, or short (under 32 byte) key raises at construction. -- Demo use opts in only through an explicit flag, off by default, with - the key generated per run. +#53 (REA check of #44) adds: reject whitespace-only keys; never paper over +an invalid configured key with the demo flag; persist demo keys in --db +mode. """ import hashlib +import stat from datetime import datetime, timedelta, timezone import pytest @@ -25,6 +24,7 @@ Evidence, PredicateType, ReleaseVerifier, + resolve_acceptance_key, sign_acceptance, ) @@ -67,6 +67,35 @@ def test_short_key_raises(monkeypatch): _rig() +def test_whitespace_only_key_raises(monkeypatch): + """#53 bar 1: verifier rejects whitespace-only keys, including >= 32 bytes.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + for bad in (b" " * 32, b"\t\n" * 16, " " * 40, b" \t " * 20): + with pytest.raises(ValueError, match="whitespace-only"): + _rig(acceptance_key=bad) + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", " " * 32) + with pytest.raises(ValueError, match="whitespace-only"): + _rig() + with pytest.raises(ValueError, match="whitespace-only"): + sign_acceptance("esc", "hash", 100, b" " * 32) + + +def test_invalid_configured_key_raises_even_with_demo_flag(monkeypatch): + """#53 bar 3: a set-but-invalid env/arg key raises even with allow_demo_key.""" + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", "x" * 31) + with pytest.raises(ValueError): + _rig(allow_demo_key=True) + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", " " * 32) + with pytest.raises(ValueError, match="whitespace-only"): + _rig(allow_demo_key=True) + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", "") + with pytest.raises(ValueError): + _rig(allow_demo_key=True) + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + with pytest.raises(ValueError): + _rig(acceptance_key=b"x" * 31, allow_demo_key=True) + + def test_env_key_and_explicit_key_construct(monkeypatch): monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", "e" * 32) _rig() @@ -84,6 +113,22 @@ def test_demo_opt_in_generates_per_instance_key(monkeypatch): assert sig1 != sig2 # per-run keys: a signature for one run fits no other +def test_demo_key_path_persists_across_resolve(monkeypatch, tmp_path): + """#53 bar 4: durable demo key is stable across resolve calls (0600 file).""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "ledger.db.acceptance_key" + # demo_key_path is the #53 API; must be accepted (fails on 9809cc7). + k1 = resolve_acceptance_key(allow_demo_key=True, demo_key_path=path) + assert path.exists() + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + k2 = resolve_acceptance_key(allow_demo_key=True, demo_key_path=path) + assert k1 == k2 + from settlement.verifier import load_or_create_demo_key + + k3 = load_or_create_demo_key(path) + assert k3 == k1 + + def test_sign_acceptance_requires_a_real_key(): with pytest.raises(ValueError): sign_acceptance("esc", "hash", 100, b"short") @@ -123,3 +168,12 @@ def test_type_a_release_uses_configured_key(monkeypatch): ) assert not refused.released assert len(paypal.capture_calls) == 0 + + +def test_ledger_module_docstring_no_longer_claims_hardcoded_demo_keys(): + """#53 bar 5: ledger.py:13 must not claim demo keys are hardcoded.""" + import permit.ledger as ledger_mod + + doc = ledger_mod.__doc__ or "" + assert "Demo keys are hardcoded" not in doc + assert "fail" in doc.lower() or "configured" in doc.lower() diff --git a/tests/test_cli.py b/tests/test_cli.py index a7f37c8..2577ff5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,7 +80,7 @@ def test_help_lists_only_offline_demo_commands(tmp_path): def test_invariant_failure_returns_nonzero(monkeypatch, capsys): - def fail(): + def fail(**_kwargs): raise cli.DemoInvariantError("boom") monkeypatch.setattr(cli, "run_demo", fail) diff --git a/tests/test_entry_point_keys.py b/tests/test_entry_point_keys.py new file mode 100644 index 0000000..0219104 --- /dev/null +++ b/tests/test_entry_point_keys.py @@ -0,0 +1,231 @@ +"""#53: every real entry point fails closed without a valid acceptance key. + +Each entry point is run as a subprocess with (a) no key, (b) a 31-byte key, +and (c) a whitespace-only key. All three must exit non-zero when the demo +flag is absent. These cases must fail on main@9809cc7 (demo key was on by +default / whitespace accepted) and pass on the #53 fix. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SIX_BEAT_FIXTURE = ROOT / "demos" / "fixtures" / "demo_transcript.jsonl" + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _clean_env(**extra: str) -> dict[str, str]: + env = { + k: v + for k, v in os.environ.items() + if k not in ( + "PERMIT_ACCEPTANCE_KEY", + "PERMIT_AGENT_CLI", + "PERMIT_GROK_CLI", + ) + } + env.update(extra) + return env + + +def _run(args: list[str], *, env: dict[str, str], timeout: float = 25.0): + """Run an entry point. For server.py, treat a successful listen as exit 0 + + (fail-open) and a quick non-zero exit as the expected fail-closed path. + This avoids hanging the suite on 9809cc7 where mock mode swapped in a + demo key and served forever. + """ + import time + + is_server = any(a == "server.py" or a.endswith("/server.py") for a in args) + if not is_server: + return subprocess.run( + [sys.executable, *args], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + + # Extract --port value for a listen probe. + port = None + for i, a in enumerate(args): + if a == "--port" and i + 1 < len(args): + port = int(args[i + 1]) + break + proc = subprocess.Popen( + [sys.executable, *args], + cwd=ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + deadline = time.time() + min(timeout, 8.0) + listened = False + try: + while time.time() < deadline: + rc = proc.poll() + if rc is not None: + out = proc.stdout.read() if proc.stdout else "" + return subprocess.CompletedProcess( + proc.args, rc, out or "", "" + ) + if port is not None: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + listened = True + break + except OSError: + pass + time.sleep(0.05) + out = "" + proc.terminate() + try: + out, _ = proc.communicate(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + out, _ = proc.communicate(timeout=3) + # Listening means the process accepted a bad/missing key and started. + return subprocess.CompletedProcess( + proc.args, 0 if listened else (proc.returncode or 1), out or "", "" + ) + except Exception: + proc.kill() + raise + + +# (id, argv builder) — builders may need a free port / tmp path. +def _entry_points(tmp_path: Path): + port = _free_port() + db_path = tmp_path / "ep.db" + return [ + ("demo.py", ["demo.py"]), + ("demos/demo_admission.py", ["demos/demo_admission.py"]), + ("demos/demo_approval.py", ["demos/demo_approval.py", "--fast", "--port", str(port)]), + ( + "demos/demo_delegation.py", + ["demos/demo_delegation.py", "--fast", "--port", str(_free_port())], + ), + ( + "demos/demo_six_beat.py", + [ + "demos/demo_six_beat.py", + "--fast", + "--replay", + str(SIX_BEAT_FIXTURE), + "--port", + str(_free_port()), + ], + ), + ("demos/demo_sla_escrow.py", ["demos/demo_sla_escrow.py", "--fast"]), + ("python -m permit", ["-m", "permit", "demo"]), + ("trace.py", ["trace.py"]), + ("server.py", ["server.py", "--port", str(_free_port())]), + ( + "server.py --db", + ["server.py", "--port", str(_free_port()), "--db", str(db_path)], + ), + ] + + +KEY_CASES = [ + ("no_key", {}), + ("short_31", {"PERMIT_ACCEPTANCE_KEY": "x" * 31}), + ("whitespace_only", {"PERMIT_ACCEPTANCE_KEY": " " * 32}), +] + + +@pytest.mark.parametrize("case_name,key_env", KEY_CASES, ids=[c[0] for c in KEY_CASES]) +def test_each_entry_point_rejects_bad_or_missing_key(tmp_path, case_name, key_env): + env = _clean_env(**key_env) + failures: list[str] = [] + for name, argv in _entry_points(tmp_path): + proc = _run(argv, env=env, timeout=30.0) + if proc.returncode == 0: + failures.append( + f"{name} ({case_name}) exited 0; stdout={proc.stdout!r} " + f"stderr={proc.stderr!r}" + ) + assert not failures, "\n".join(failures) + + +def test_server_db_demo_key_persists_across_restart(tmp_path): + """#53 bar 4: --db --allow-demo-key writes a 0600 key that survives restart.""" + import time + + db = tmp_path / "durable.db" + key_file = Path(f"{db}.acceptance_key") + env = _clean_env() + + def _boot(port: int) -> subprocess.Popen: + return subprocess.Popen( + [ + sys.executable, + "server.py", + "--port", + str(port), + "--db", + str(db), + "--allow-demo-key", + ], + cwd=ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + def _wait_listen(srv: subprocess.Popen, port: int, timeout: float = 8.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if srv.poll() is not None: + out, _ = srv.communicate(timeout=2) + raise AssertionError( + f"server exited {srv.returncode} before listen: {out}" + ) + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return + except OSError: + time.sleep(0.05) + raise AssertionError("server did not listen in time") + + port = _free_port() + srv = _boot(port) + try: + _wait_listen(srv, port) + assert key_file.exists() + assert (key_file.stat().st_mode & 0o777) == 0o600 + first = key_file.read_bytes() + finally: + srv.terminate() + try: + srv.communicate(timeout=5) + except subprocess.TimeoutExpired: + srv.kill() + + port2 = _free_port() + srv2 = _boot(port2) + try: + _wait_listen(srv2, port2) + assert key_file.read_bytes() == first + finally: + srv2.terminate() + try: + srv2.communicate(timeout=5) + except subprocess.TimeoutExpired: + srv2.kill() diff --git a/trace.py b/trace.py index 5a47373..6f0cb8b 100644 --- a/trace.py +++ b/trace.py @@ -18,6 +18,7 @@ from __future__ import annotations +import argparse import base64 import hashlib import json @@ -42,10 +43,6 @@ def _free_port(): return s.getsockname()[1] -PORT = _free_port() -BASE = f"http://127.0.0.1:{PORT}" - - TRACE_TOKEN = "trace-token-not-a-secret" @@ -64,9 +61,33 @@ def show(tag, obj): def main(): + ap = argparse.ArgumentParser(description="Permit evidence-trace runner") + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53). Forwarded to server.py." + ), + ) + args = ap.parse_args() + + port = _free_port() + base = f"http://127.0.0.1:{port}" + + # Rebind module-level helpers used by req()/show path to this run's port. + global PORT, BASE + PORT, BASE = port, base + env = dict(os.environ, PERMIT_API_TOKEN=TRACE_TOKEN) + server_cmd = [ + sys.executable, str(HERE / "server.py"), "--port", str(port), + ] + if args.allow_demo_key: + server_cmd.append("--allow-demo-key") srv = subprocess.Popen( - [sys.executable, str(HERE / "server.py"), "--port", str(PORT)], + server_cmd, cwd=HERE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) @@ -167,7 +188,12 @@ def get_authorization(self, auth_id): lg = Ledger() ps = PermitStore(ledger=lg) spy = StrictSpy() - vf = ReleaseVerifier(spy, ps, ledger=lg, allow_demo_key=True) + try: + vf = ReleaseVerifier( + spy, ps, ledger=lg, allow_demo_key=args.allow_demo_key + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from exc fl = SpendPipeline(ps, spy, vf, ledger=lg) p2, _ = ps.grant("spy_agent", 1000, [merchant], datetime.now(timezone.utc) + timedelta(hours=1)) From c063f8dd543fbda2b3f9138856df271a7f97a535 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 15:09:15 +0000 Subject: [PATCH 02/22] docs: refresh evidence counts for #53 key-handling suite Co-authored-by: Marcus Richards --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3037ca2..7931ae5 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,8 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **389 tests pass, 1 skipped (390 collected):** `python -m pytest -q` on `main` at `21d7d7c`, 2026-10-15, both locally and in the green `ci` run for that commit. CI runs the same suite plus `python demo.py` on every push. -- **Mock end-to-end demo:** `python demo.py` ends with `ledger chain: VERIFIED (16 receipts)` (run 2026-10-15 at `21d7d7c`). +- **407 tests pass, 1 skipped (408 collected):** `python -m pytest -q` and `python -O -m pytest -q` on `launch/53-key-handling` (issue #53). CI runs the same suite plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push. +- **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path. - The Oct 2 sandbox spike made real REST calls (OAuth, order, authorize, capture, void) against PayPal sandbox and verified merchant identity. Its report left the tree in `3f88feb`. Read it with `git show 3f88feb^:spike-report.md`. From d89a95015723c632b5bc6834685f1b9346a02ce6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 15:32:50 +0000 Subject: [PATCH 03/22] Harden durable demo key files after REA seq 1690 (#53) Open key files with O_NOFOLLOW; require a regular euid-owned 0600 file; read exactly 32 bytes with EOF; create via O_CREAT|O_EXCL|O_NOFOLLOW and fsync file+dir. Refuse to regenerate when the ledger is nonempty and the key is missing. Log mode only after verification. Run forge on pull_request with --allow-demo-key; make the durability harness demo-key flag off by default. Co-authored-by: Marcus Richards --- .github/workflows/forge.yml | 9 +- README.md | 4 +- harness/durability_harness.py | 65 ++++++++-- server.py | 48 +++---- settlement/verifier.py | 160 ++++++++++++++++++++--- tests/test_acceptance_key.py | 3 +- tests/test_demo_key_file_hardening.py | 177 ++++++++++++++++++++++++++ 7 files changed, 409 insertions(+), 57 deletions(-) create mode 100644 tests/test_demo_key_file_hardening.py diff --git a/.github/workflows/forge.yml b/.github/workflows/forge.yml index bcbe310..36f209f 100644 --- a/.github/workflows/forge.yml +++ b/.github/workflows/forge.yml @@ -10,6 +10,9 @@ on: - permit-cleanup-20261004 - "permit-forge-*" - main + pull_request: + branches: + - main schedule: # 06:00 UTC nightly (~00:00 MDT). Same job as push; default branch is the schedule ref. - cron: "0 6 * * *" @@ -118,8 +121,10 @@ jobs: set -euo pipefail root="$PWD" mkdir -p "$root/forge-demos" - (cd /tmp && /tmp/forge-venv/bin/permit demo) 2>&1 | tee "$root/forge-demos/demo.out" - (cd /tmp && /tmp/forge-venv/bin/permit demo-recovery) 2>&1 | tee "$root/forge-demos/demo-recovery.out" + # Demo key is off by default (#53); forge opts in explicitly for the + # mock-rail smoke. A set-but-invalid env key would still fail closed. + (cd /tmp && /tmp/forge-venv/bin/permit demo --allow-demo-key) 2>&1 | tee "$root/forge-demos/demo.out" + (cd /tmp && /tmp/forge-venv/bin/permit demo-recovery --allow-demo-key) 2>&1 | tee "$root/forge-demos/demo-recovery.out" c13=$(grep -c "LEDGER VERIFIED: 13" "$root/forge-demos/demo.out" || true) c6=$(grep -c "LEDGER VERIFIED: 6" "$root/forge-demos/demo-recovery.out" || true) echo "demo LEDGER VERIFIED: 13 count=${c13}" diff --git a/README.md b/README.md index 7931ae5..cf12d40 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Permit fails closed. When state is uncertain, money does not move. | Tampered delivery evidence | Release refused, hold voided | **What it does not defend (production boundaries):** -- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key` with mode `0600` so restarts do not rotate it (#53). +- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). - HTTP POSTs require a single shared bearer token (`PERMIT_API_TOKEN`, #23) with no role split between principal and agent (F5). GETs, including `/api/ledger`, are unauthenticated; the server binds to 127.0.0.1. - Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, under 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53). - `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of at least 32 bytes. There is no module-level fallback key (#35/#53 API change). @@ -106,7 +106,7 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **407 tests pass, 1 skipped (408 collected):** `python -m pytest -q` and `python -O -m pytest -q` on `launch/53-key-handling` (issue #53). CI runs the same suite plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push. +- **417 tests pass, 1 skipped (418 collected):** `python -m pytest -q` and `python -O -m pytest -q` on `launch/53-key-handling` (issue #53 / REA seq 1690). CI runs the same suite plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. - **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path. diff --git a/harness/durability_harness.py b/harness/durability_harness.py index 17aab81..73660b9 100644 --- a/harness/durability_harness.py +++ b/harness/durability_harness.py @@ -35,11 +35,17 @@ from settlement.verifier import Evidence, PredicateType -def _build(durable_dir: Path): +def _build(durable_dir: Path, *, allow_demo_key: bool = False): ledger = DurableLedger(durable_dir=durable_dir) permits = DurablePermitStore(ledger=ledger, durable_dir=durable_dir) paypal = DurableMockPayPalClient(durable_dir=durable_dir) - verifier = DurableReleaseVerifier(paypal, permits, ledger=ledger, durable_dir=durable_dir, allow_demo_key=True) + verifier = DurableReleaseVerifier( + paypal, + permits, + ledger=ledger, + durable_dir=durable_dir, + allow_demo_key=allow_demo_key, + ) flow = DurableSpendPipeline(permits, paypal, verifier, ledger=ledger, durable_dir=durable_dir) return ledger, permits, paypal, verifier, flow @@ -69,12 +75,20 @@ def _artifact(): KILL_POINTS = ("K1", "K2", "K3", "K4") -def _run_kill_point(kill_point: str, durable_dir: Path, verbose: bool = False) -> dict: +def _run_kill_point( + kill_point: str, + durable_dir: Path, + verbose: bool = False, + *, + allow_demo_key: bool = False, +) -> dict: """Run one kill-point scenario. Returns dict with PASS/FAIL per expectation.""" import multiprocessing artifact, digest = _artifact() - ledger, permits, paypal, verifier, flow = _build(durable_dir) + ledger, permits, paypal, verifier, flow = _build( + durable_dir, allow_demo_key=allow_demo_key + ) permit = _grant(permits) permit_id = permit.permit_id # Release writer lock before fork/reload so the child (and the post-kill @@ -92,7 +106,7 @@ def _run_kill_point(kill_point: str, durable_dir: Path, verbose: bool = False) - # For K4, we need to drive to UNKNOWN via timeout then kill before reconcile # For all, child does spend with hooks that write sentinel at kill point - def child_main(q, kill_point_inner, durable_dir_str): + def child_main(q, kill_point_inner, durable_dir_str, allow_demo_key_inner): # Re-build inside child (same dir, but new objects) import pathlib d = pathlib.Path(durable_dir_str) @@ -103,7 +117,13 @@ def child_main(q, kill_point_inner, durable_dir_str): ledger_c = DurableLedger(durable_dir=d) permits_c = DurablePermitStore(ledger=ledger_c, durable_dir=d) paypal_c = DurableMockPayPalClient(durable_dir=d) - verifier_c = DurableReleaseVerifier(paypal_c, permits_c, ledger=ledger_c, durable_dir=d, allow_demo_key=True) + verifier_c = DurableReleaseVerifier( + paypal_c, + permits_c, + ledger=ledger_c, + durable_dir=d, + allow_demo_key=allow_demo_key_inner, + ) flow_c = DurableSpendPipeline(permits_c, paypal_c, verifier_c, ledger=ledger_c, durable_dir=d) permit_c = permits_c.get(permit_id) if permit_c is None: @@ -201,7 +221,10 @@ def patched_register(escrow): # Use a queue to get child status if it doesn't die q = ctx.Queue() - p = ctx.Process(target=child_main, args=(q, kill_point, str(durable_dir))) + p = ctx.Process( + target=child_main, + args=(q, kill_point, str(durable_dir), allow_demo_key), + ) p.start() # Wait for sentinel @@ -255,7 +278,9 @@ def patched_register(escrow): time.sleep(0.2) # let fs finish # Fresh process reload: new objects from same dir (lock free after kill). - ledger2, permits2, paypal2, verifier2, flow2 = _build(durable_dir) + ledger2, permits2, paypal2, verifier2, flow2 = _build( + durable_dir, allow_demo_key=allow_demo_key + ) permit2 = permits2.get(permit_id) result: dict = {"kill_point": kill_point, "pass": True, "details": []} @@ -348,7 +373,13 @@ def check(name: str, cond: bool, detail: str = ""): check("no_duplicate_after_reconcile", len(paypal3.capture_calls) == before_calls, f"calls before={before_calls} after={len(paypal3.capture_calls)}") check("captures_still_one", len(paypal3.captures) == before_captures, f"captures={len(paypal3.captures)}") # Escrow should now be CAPTURED - verifier3 = DurableReleaseVerifier(paypal3, permits2, ledger=ledger2, durable_dir=durable_dir, allow_demo_key=True) + verifier3 = DurableReleaseVerifier( + paypal3, + permits2, + ledger=ledger2, + durable_dir=durable_dir, + allow_demo_key=allow_demo_key, + ) escrow3 = verifier3.get_escrow(escrow.escrow_id) if escrow3: check("escrow_captured_after_reconcile", escrow3.state == "CAPTURED", f"state={escrow3.state}") @@ -375,6 +406,15 @@ def main(argv=None): ap.add_argument("--all", action="store_true", help="run all kill points") ap.add_argument("--work-dir", help="durable dir (default: mktemp)") ap.add_argument("--verbose", action="store_true") + ap.add_argument( + "--allow-demo-key", + action="store_true", + default=False, + help=( + "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " + "is unset (demo only; off by default, #53)" + ), + ) args = ap.parse_args(argv) if not args.kill_point and not args.all: @@ -393,7 +433,12 @@ def main(argv=None): # Each kill point gets its own subdir to isolate sub = work_dir / kp sub.mkdir(parents=True, exist_ok=True) - res = _run_kill_point(kp, sub, verbose=args.verbose or args.all) + res = _run_kill_point( + kp, + sub, + verbose=args.verbose or args.all, + allow_demo_key=args.allow_demo_key, + ) if not args.verbose and not args.all: # Single mode: print details print(f"[{kp}] {'PASS' if res['pass'] else 'FAIL'}") diff --git a/server.py b/server.py index f177731..3085897 100644 --- a/server.py +++ b/server.py @@ -57,6 +57,7 @@ Evidence, PredicateType, ReleaseVerifier, + load_or_create_demo_key, ) # ---------------------------------------------------------------- state @@ -539,13 +540,21 @@ def main(): log.info("mock rail (no network, no credentials)") # Demo key is an explicit opt-in (#53), not implied by mock mode. - # With --db, persist the demo key so durable mock mode does not - # rotate the signing key across restarts. - demo_key_path = ( - f"{args.db}.acceptance_key" - if args.allow_demo_key and args.db - else None - ) + # Open the durable ledger BEFORE resolving a demo key so a nonempty + # db with a missing key file refuses to start instead of regenerating. + demo_key_path = None + demo_key_db_nonempty = False + if args.db: + from permit.sqlite_ledger import SqliteLedger, fold_receipts + db_ledger = SqliteLedger(args.db) + folded = fold_receipts(db_ledger.receipts()) + folded.ledger = db_ledger + ledger, permits = db_ledger, folded + demo_key_db_nonempty = len(ledger) > 0 + log.info(f"durable ledger at {args.db}" + f" ({len(ledger)} receipts restored)") + if args.allow_demo_key: + demo_key_path = f"{args.db}.acceptance_key" def _build_verifier(active_permits, active_ledger): try: @@ -555,6 +564,7 @@ def _build_verifier(active_permits, active_ledger): ledger=active_ledger, allow_demo_key=args.allow_demo_key, demo_key_path=demo_key_path, + demo_key_db_nonempty=demo_key_db_nonempty, ) except ValueError as exc: raise SystemExit(f"acceptance key error: {exc}") from exc @@ -562,21 +572,15 @@ def _build_verifier(active_permits, active_ledger): verifier = _build_verifier(permits, ledger) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) - if args.db: - # Durable mode: open (verifying) the SQLite ledger, then fold the - # restored chain into the permit store. SqliteLedger.__init__ - # raises instead of serving a corrupt chain — fail closed. - from permit.sqlite_ledger import SqliteLedger, fold_receipts - db_ledger = SqliteLedger(args.db) - folded = fold_receipts(db_ledger.receipts()) - folded.ledger = db_ledger - ledger, permits = db_ledger, folded - verifier = _build_verifier(permits, ledger) - flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) - log.info(f"durable ledger at {args.db}" - f" ({len(ledger)} receipts restored)") - if demo_key_path: - log.info(f"demo acceptance key persisted at {demo_key_path} (mode 0600)") + if demo_key_path: + # Re-open via the same fail-closed helper so the log reports the + # verified mode only after O_NOFOLLOW / owner / 0600 / exact-32 checks. + _key, mode = load_or_create_demo_key( + demo_key_path, db_nonempty=demo_key_db_nonempty + ) + log.info( + f"demo acceptance key persisted at {demo_key_path} (mode {mode:04o})" + ) # Reap TTL-expired payer-approval operations (daemon; 60s cadence). # /resume also reaps on hit, so an expired op never captures. diff --git a/settlement/verifier.py b/settlement/verifier.py index eb687ae..183be71 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -109,11 +109,16 @@ class ReconcileResult: # with --allow-demo-key / allow_demo_key=True (off by default). When that # flag is set and no key is configured, a fresh random key is generated; # if a durable demo_key_path is also given (server --db), the key is -# persisted mode 0600 so restarts do not rotate it. A key that IS set -# (env or argument) but invalid always raises — the demo flag never -# papered over a bad configured key. +# persisted as a regular file owned by the server euid with mode exactly +# 0600 (O_NOFOLLOW; create uses O_CREAT|O_EXCL|O_NOFOLLOW + fsync). A key +# that IS set (env or argument) but invalid always raises — the demo flag +# never papered over a bad configured key. If the durable ledger already +# has entries and the key file is missing, startup refuses rather than +# silently regenerating. +import errno as _errno import os as _os import secrets as _secrets +import stat as _stat from pathlib import Path as _Path MIN_ACCEPTANCE_KEY_BYTES = 32 @@ -139,30 +144,135 @@ def _require_valid_key(resolved: bytes, *, what: str = "acceptance key") -> byte return resolved -def load_or_create_demo_key(path: str | _os.PathLike[str]) -> bytes: - """Load a demo acceptance key from ``path``, or create one at mode 0600. +def _open_demo_key_readonly(key_path: _Path) -> int: + """Open ``key_path`` with O_NOFOLLOW; raise ValueError on symlink/missing.""" + flags = _os.O_RDONLY | getattr(_os, "O_NOFOLLOW", 0) + try: + return _os.open(key_path, flags) + except FileNotFoundError: + raise + except OSError as exc: + if exc.errno in ( + getattr(_errno, "ELOOP", -1), + getattr(_errno, "EMLINK", -1), + ): + raise ValueError( + f"demo acceptance key at {key_path} must not be a symlink" + ) from exc + # Some platforms surface symlink-via-NOFOLLOW as EPERM. + if exc.errno == _errno.EPERM: + raise ValueError( + f"demo acceptance key at {key_path} must not be a symlink" + ) from exc + raise + + +def _require_demo_key_fstat(fd: int, key_path: _Path) -> int: + """Require a regular file owned by euid with mode exactly 0600. + + Returns the verified mode (always 0o600 on success). + """ + st = _os.fstat(fd) + if not _stat.S_ISREG(st.st_mode): + raise ValueError( + f"demo acceptance key at {key_path} must be a regular file" + ) + mode = _stat.S_IMODE(st.st_mode) + if mode != 0o600: + raise ValueError( + f"demo acceptance key at {key_path} must have mode 0600 " + f"(got {mode:04o})" + ) + euid = _os.geteuid() + if st.st_uid != euid: + raise ValueError( + f"demo acceptance key at {key_path} must be owned by euid " + f"{euid} (got uid {st.st_uid})" + ) + return mode - Used by durable mock mode (``server.py --db`` with ``--allow-demo-key``) - so the key does not silently rotate across restarts (#53). + +def _read_exact_demo_key(fd: int, key_path: _Path) -> bytes: + """Read exactly MIN_ACCEPTANCE_KEY_BYTES and require EOF (no trailing).""" + data = _os.read(fd, MIN_ACCEPTANCE_KEY_BYTES + 1) + if len(data) < MIN_ACCEPTANCE_KEY_BYTES: + raise ValueError( + f"demo acceptance key at {key_path} is truncated " + f"({len(data)} bytes, need {MIN_ACCEPTANCE_KEY_BYTES})" + ) + if len(data) > MIN_ACCEPTANCE_KEY_BYTES: + raise ValueError( + f"demo acceptance key at {key_path} has trailing bytes after " + f"{MIN_ACCEPTANCE_KEY_BYTES}-byte key" + ) + return _require_valid_key(data, what=f"demo acceptance key at {key_path}") + + +def _fsync_dir(dir_path: _Path) -> None: + dir_fd = _os.open(dir_path, _os.O_RDONLY | getattr(_os, "O_DIRECTORY", 0)) + try: + _os.fsync(dir_fd) + finally: + _os.close(dir_fd) + + +def load_or_create_demo_key( + path: str | _os.PathLike[str], + *, + db_nonempty: bool = False, +) -> tuple[bytes, int]: + """Load or create a durable demo acceptance key file. + + Security posture (REA #54 / seq 1690): + - Open existing files with ``O_NOFOLLOW``; refuse symlinks. + - Require a regular file owned by the process euid with mode exactly 0600. + - Read exactly 32 bytes and require EOF (no trailing newline/bytes). + - Create with ``O_CREAT|O_EXCL|O_NOFOLLOW`` at 0600, then ``fsync`` the + file and its directory. + - If ``db_nonempty`` and the key file is missing, refuse to regenerate. + + Returns ``(key_bytes, verified_mode)`` so callers can log the mode only + after it has been checked. """ key_path = _Path(path) - if key_path.exists(): - return _require_valid_key( - key_path.read_bytes(), what=f"demo acceptance key at {key_path}" - ) + try: + fd = _open_demo_key_readonly(key_path) + except FileNotFoundError: + if db_nonempty: + raise ValueError( + f"demo acceptance key missing at {key_path} but the durable " + "ledger has entries; refusing to regenerate (issue #53)" + ) + return _create_demo_key_file(key_path) + + try: + mode = _require_demo_key_fstat(fd, key_path) + key = _read_exact_demo_key(fd, key_path) + return key, mode + finally: + _os.close(fd) + + +def _create_demo_key_file(key_path: _Path) -> tuple[bytes, int]: key = _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) key_path.parent.mkdir(parents=True, exist_ok=True) - fd = _os.open( - key_path, - _os.O_WRONLY | _os.O_CREAT | _os.O_EXCL, - 0o600, + flags = ( + _os.O_WRONLY + | _os.O_CREAT + | _os.O_EXCL + | getattr(_os, "O_NOFOLLOW", 0) ) + fd = _os.open(key_path, flags, 0o600) try: _os.write(fd, key) + # umask may have cleared bits from the create mode; force 0600. + _os.fchmod(fd, 0o600) + _os.fsync(fd) + mode = _require_demo_key_fstat(fd, key_path) finally: _os.close(fd) - _os.chmod(key_path, 0o600) - return key + _fsync_dir(key_path.parent) + return key, mode def resolve_acceptance_key( @@ -170,6 +280,7 @@ def resolve_acceptance_key( *, allow_demo_key: bool = False, demo_key_path: str | _os.PathLike[str] | None = None, + demo_key_db_nonempty: bool = False, ) -> bytes: """Resolve and validate the acceptance key, failing closed. @@ -179,8 +290,9 @@ def resolve_acceptance_key( ``allow_demo_key`` is set (#53). When no key is configured at all and ``allow_demo_key`` is set: - if ``demo_key_path`` is given, load or create a 0600 key file there; - otherwise generate a fresh per-call random key (ephemeral demos). + if ``demo_key_path`` is given, load or create a 0600 key file there + (see ``load_or_create_demo_key``); otherwise generate a fresh per-call + random key (ephemeral demos). """ if key is not None: resolved = _coerce_key(key) @@ -194,7 +306,10 @@ def resolve_acceptance_key( if allow_demo_key: if demo_key_path is not None: - return load_or_create_demo_key(demo_key_path) + key_bytes, _mode = load_or_create_demo_key( + demo_key_path, db_nonempty=demo_key_db_nonempty + ) + return key_bytes return _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) raise ValueError( @@ -230,6 +345,7 @@ def __init__( acceptance_key: bytes | str | None = None, allow_demo_key: bool = False, demo_key_path: str | _os.PathLike[str] | None = None, + demo_key_db_nonempty: bool = False, ): # Fail closed at construction (issues #35/#53): no usable # acceptance key, no verifier. Demo callers pass @@ -238,6 +354,10 @@ def __init__( acceptance_key, allow_demo_key=allow_demo_key, demo_key_path=demo_key_path, + demo_key_db_nonempty=demo_key_db_nonempty, + ) + self._demo_key_path = ( + str(demo_key_path) if demo_key_path is not None else None ) self.paypal = paypal self.permits = permits diff --git a/tests/test_acceptance_key.py b/tests/test_acceptance_key.py index a38920c..513209e 100644 --- a/tests/test_acceptance_key.py +++ b/tests/test_acceptance_key.py @@ -125,8 +125,9 @@ def test_demo_key_path_persists_across_resolve(monkeypatch, tmp_path): assert k1 == k2 from settlement.verifier import load_or_create_demo_key - k3 = load_or_create_demo_key(path) + k3, mode = load_or_create_demo_key(path) assert k3 == k1 + assert mode == 0o600 def test_sign_acceptance_requires_a_real_key(): diff --git a/tests/test_demo_key_file_hardening.py b/tests/test_demo_key_file_hardening.py new file mode 100644 index 0000000..5385ce2 --- /dev/null +++ b/tests/test_demo_key_file_hardening.py @@ -0,0 +1,177 @@ +"""REA seq 1690 regressions on durable demo key files (PR #54 follow-up). + +Each case must fail on c063f8d (soft open/chmod, silent regenerate, forged +mode log, harness/forge demo-key defaults) and pass on the hardened head. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import sys +from pathlib import Path +from unittest import mock + +import pytest + +from settlement.verifier import load_or_create_demo_key, resolve_acceptance_key + +ROOT = Path(__file__).resolve().parents[1] +MIN = 32 +FORGE_YML = ROOT / ".github" / "workflows" / "forge.yml" + + +def _write_key(path: Path, data: bytes, mode: int = 0o600) -> None: + path.write_bytes(data) + os.chmod(path, mode) + + +def test_symlinked_key_file_refused(monkeypatch, tmp_path): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + real = tmp_path / "real.key" + link = tmp_path / "ledger.db.acceptance_key" + _write_key(real, os.urandom(MIN), 0o600) + link.symlink_to(real) + with pytest.raises(ValueError, match="symlink"): + load_or_create_demo_key(link) + + +def test_wrong_owner_refused(monkeypatch, tmp_path): + """Simulate a foreign uid via fstat when chown is unavailable.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "ledger.db.acceptance_key" + _write_key(path, os.urandom(MIN), 0o600) + real_fstat = os.fstat + + def foreign_fstat(fd): + st = real_fstat(fd) + return os.stat_result( + ( + st.st_mode, + st.st_ino, + st.st_dev, + st.st_nlink, + st.st_uid + 1, # not euid + st.st_gid, + st.st_size, + st.st_atime, + st.st_mtime, + st.st_ctime, + ) + ) + + with mock.patch("settlement.verifier._os.fstat", side_effect=foreign_fstat): + with pytest.raises(ValueError, match="owned by euid"): + load_or_create_demo_key(path) + + +@pytest.mark.parametrize("bad_mode", [0o644, 0o666], ids=["0644", "0666"]) +def test_wrong_mode_refused(monkeypatch, tmp_path, bad_mode): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "ledger.db.acceptance_key" + _write_key(path, os.urandom(MIN), bad_mode) + with pytest.raises(ValueError, match="mode 0600"): + load_or_create_demo_key(path) + + +def test_trailing_newline_refused(monkeypatch, tmp_path): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "ledger.db.acceptance_key" + _write_key(path, os.urandom(MIN) + b"\n", 0o600) + with pytest.raises(ValueError, match="trailing"): + load_or_create_demo_key(path) + + +def test_short_key_file_refused(monkeypatch, tmp_path): + """Exactly-32 read rejects a short file with an explicit truncated error.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "short.acceptance_key" + _write_key(path, os.urandom(MIN - 1), 0o600) + with pytest.raises(ValueError, match=r"is truncated \("): + load_or_create_demo_key(path) + + +def test_nonempty_db_missing_key_refused(monkeypatch, tmp_path): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "ledger.db.acceptance_key" + assert not path.exists() + with pytest.raises(ValueError, match="refusing to regenerate"): + load_or_create_demo_key(path, db_nonempty=True) + with pytest.raises(ValueError, match="refusing to regenerate"): + resolve_acceptance_key( + allow_demo_key=True, + demo_key_path=path, + demo_key_db_nonempty=True, + ) + + +def test_create_then_load_is_exact_0600_and_stable(monkeypatch, tmp_path): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "ledger.db.acceptance_key" + k1, mode1 = load_or_create_demo_key(path, db_nonempty=False) + assert mode1 == 0o600 + assert len(k1) == MIN + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + k2, mode2 = load_or_create_demo_key(path, db_nonempty=True) + assert k2 == k1 and mode2 == 0o600 + + +def test_forge_demo_step_passes_allow_demo_key(): + """forge.yml must opt the installed CLI demos into --allow-demo-key.""" + text = FORGE_YML.read_text() + assert "pull_request:" in text, "forge must run on pull_request so PRs prove the step" + assert "permit demo --allow-demo-key" in text + assert "permit demo-recovery --allow-demo-key" in text + # Bare installed-CLI smoke without the flag must not remain. + assert "permit demo)" not in text.replace("permit demo --allow-demo-key", "PERMIT_DEMO_OK") + assert "permit demo-recovery)" not in text.replace( + "permit demo-recovery --allow-demo-key", "PERMIT_RECOVERY_OK" + ) + + +def test_harness_default_disallows_demo_key(monkeypatch): + """harness/durability_harness.py --allow-demo-key is off by default.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + # Import-time default on _build / argparse. + from harness import durability_harness as dh + + import inspect + + sig = inspect.signature(dh._build) + assert sig.parameters["allow_demo_key"].default is False + sig_run = inspect.signature(dh._run_kill_point) + assert sig_run.parameters["allow_demo_key"].default is False + + # CLI default: parsing with only --all must leave the flag False. + # (main requires --all or --kill-point; parse via the private parser path.) + proc = subprocess.run( + [ + sys.executable, + "-c", + ( + "from harness.durability_harness import main\n" + "import argparse\n" + # Re-build the same parser defaults the module exposes. + "from harness import durability_harness as m\n" + "import inspect\n" + "src = inspect.getsource(m.main)\n" + "assert 'allow-demo-key' in src\n" + "assert 'default=False' in src\n" + "print('ok')\n" + ), + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=20, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "ok" in proc.stdout + + # Without env key and without the flag, _build must raise. + import tempfile + + with tempfile.TemporaryDirectory(prefix="harness-demo-key-") as td: + with pytest.raises(ValueError): + dh._build(Path(td), allow_demo_key=False) From 70b0fc683568c64dbfa9345ee30eef2079f9f05c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 15:33:05 +0000 Subject: [PATCH 04/22] docs: clarify --allow-demo-key durable file posture in server help Co-authored-by: Marcus Richards --- server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 3085897..3e7f199 100644 --- a/server.py +++ b/server.py @@ -516,8 +516,9 @@ def main(): help=( "Opt in to a generated acceptance key when PERMIT_ACCEPTANCE_KEY " "is unset (mock/demo only; off by default). With --db, the key is " - "persisted next to the ledger as .acceptance_key mode 0600 " - "so restarts do not rotate it (#53)." + "persisted as .acceptance_key (regular file, euid-owned, mode " + "0600, O_NOFOLLOW, exact 32 bytes, fsynced). If the ledger already " + "has entries and the key file is missing, refuse to start (#53)." ), ) args = ap.parse_args() From 2a1bbfe988e76022c11b7c8ed7975ddaa810fd7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 16:39:28 +0000 Subject: [PATCH 05/22] Map key-file OSError cleanly; O_NONBLOCK; load durable key once (REA N-b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map every open/read OSError (including EACCES) to ValueError so startup exits with 'acceptance key error' and no traceback. Open with O_NONBLOCK so a FIFO at the key path fails fast after the regular-file check. Load the durable demo key once in server.py and pass the bytes into ReleaseVerifier — drop the second load that only existed to log the mode. Co-authored-by: Marcus Richards --- server.py | 34 +++-- settlement/verifier.py | 101 +++++++++++---- tests/test_demo_key_rea_n_b.py | 221 +++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 tests/test_demo_key_rea_n_b.py diff --git a/server.py b/server.py index 3e7f199..b0f94f7 100644 --- a/server.py +++ b/server.py @@ -543,7 +543,12 @@ def main(): # Demo key is an explicit opt-in (#53), not implied by mock mode. # Open the durable ledger BEFORE resolving a demo key so a nonempty # db with a missing key file refuses to start instead of regenerating. + # Load the durable demo key at most once here, then pass the bytes into + # ReleaseVerifier — a second load could recreate a deleted file while + # the process kept signing with the first key (REA seq 1801). demo_key_path = None + demo_key_bytes = None + demo_key_mode = None demo_key_db_nonempty = False if args.db: from permit.sqlite_ledger import SqliteLedger, fold_receipts @@ -557,30 +562,39 @@ def main(): if args.allow_demo_key: demo_key_path = f"{args.db}.acceptance_key" + if demo_key_path is not None: + try: + demo_key_bytes, demo_key_mode = load_or_create_demo_key( + demo_key_path, db_nonempty=demo_key_db_nonempty + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from None + def _build_verifier(active_permits, active_ledger): try: + if demo_key_bytes is not None: + return ReleaseVerifier( + paypal, + active_permits, + ledger=active_ledger, + acceptance_key=demo_key_bytes, + ) return ReleaseVerifier( paypal, active_permits, ledger=active_ledger, allow_demo_key=args.allow_demo_key, - demo_key_path=demo_key_path, - demo_key_db_nonempty=demo_key_db_nonempty, ) except ValueError as exc: - raise SystemExit(f"acceptance key error: {exc}") from exc + raise SystemExit(f"acceptance key error: {exc}") from None verifier = _build_verifier(permits, ledger) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) - if demo_key_path: - # Re-open via the same fail-closed helper so the log reports the - # verified mode only after O_NOFOLLOW / owner / 0600 / exact-32 checks. - _key, mode = load_or_create_demo_key( - demo_key_path, db_nonempty=demo_key_db_nonempty - ) + if demo_key_path is not None and demo_key_mode is not None: log.info( - f"demo acceptance key persisted at {demo_key_path} (mode {mode:04o})" + f"demo acceptance key persisted at {demo_key_path} " + f"(mode {demo_key_mode:04o})" ) # Reap TTL-expired payer-approval operations (daemon; 60s cadence). diff --git a/settlement/verifier.py b/settlement/verifier.py index 183be71..5d36251 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -144,27 +144,53 @@ def _require_valid_key(resolved: bytes, *, what: str = "acceptance key") -> byte return resolved +def _key_oserror(key_path: _Path, exc: OSError) -> ValueError: + """Map any open/read OSError to a clean ValueError (no raw traceback).""" + err = _errno.errorcode.get(exc.errno, str(exc.errno)) + if exc.errno in ( + getattr(_errno, "ELOOP", -1), + getattr(_errno, "EMLINK", -1), + ): + return ValueError( + f"demo acceptance key at {key_path} must not be a symlink" + ) + if exc.errno == _errno.EPERM and "symlink" in (exc.strerror or "").lower(): + return ValueError( + f"demo acceptance key at {key_path} must not be a symlink" + ) + return ValueError( + f"demo acceptance key at {key_path} cannot be opened " + f"({err}: {exc.strerror})" + ) + + +def _clear_nonblock(fd: int) -> None: + """Clear O_NONBLOCK so the subsequent read blocks normally on a regular file.""" + try: + import fcntl as _fcntl + except ImportError: # pragma: no cover - non-POSIX + return + flags = _fcntl.fcntl(fd, _fcntl.F_GETFL) + _fcntl.fcntl(fd, _fcntl.F_SETFL, flags & ~getattr(_os, "O_NONBLOCK", 0)) + + def _open_demo_key_readonly(key_path: _Path) -> int: - """Open ``key_path`` with O_NOFOLLOW; raise ValueError on symlink/missing.""" - flags = _os.O_RDONLY | getattr(_os, "O_NOFOLLOW", 0) + """Open ``key_path`` with O_NOFOLLOW|O_NONBLOCK; map OSError → ValueError. + + O_NONBLOCK keeps a FIFO (or other blocking special file) from hanging + startup forever; the regular-file check then rejects non-files. + """ + flags = ( + _os.O_RDONLY + | getattr(_os, "O_NOFOLLOW", 0) + | getattr(_os, "O_NONBLOCK", 0) + ) try: return _os.open(key_path, flags) except FileNotFoundError: raise except OSError as exc: - if exc.errno in ( - getattr(_errno, "ELOOP", -1), - getattr(_errno, "EMLINK", -1), - ): - raise ValueError( - f"demo acceptance key at {key_path} must not be a symlink" - ) from exc - # Some platforms surface symlink-via-NOFOLLOW as EPERM. - if exc.errno == _errno.EPERM: - raise ValueError( - f"demo acceptance key at {key_path} must not be a symlink" - ) from exc - raise + raise _key_oserror(key_path, exc) from None def _require_demo_key_fstat(fd: int, key_path: _Path) -> int: @@ -172,7 +198,10 @@ def _require_demo_key_fstat(fd: int, key_path: _Path) -> int: Returns the verified mode (always 0o600 on success). """ - st = _os.fstat(fd) + try: + st = _os.fstat(fd) + except OSError as exc: + raise _key_oserror(key_path, exc) from None if not _stat.S_ISREG(st.st_mode): raise ValueError( f"demo acceptance key at {key_path} must be a regular file" @@ -194,7 +223,10 @@ def _require_demo_key_fstat(fd: int, key_path: _Path) -> int: def _read_exact_demo_key(fd: int, key_path: _Path) -> bytes: """Read exactly MIN_ACCEPTANCE_KEY_BYTES and require EOF (no trailing).""" - data = _os.read(fd, MIN_ACCEPTANCE_KEY_BYTES + 1) + try: + data = _os.read(fd, MIN_ACCEPTANCE_KEY_BYTES + 1) + except OSError as exc: + raise _key_oserror(key_path, exc) from None if len(data) < MIN_ACCEPTANCE_KEY_BYTES: raise ValueError( f"demo acceptance key at {key_path} is truncated " @@ -209,7 +241,12 @@ def _read_exact_demo_key(fd: int, key_path: _Path) -> bytes: def _fsync_dir(dir_path: _Path) -> None: - dir_fd = _os.open(dir_path, _os.O_RDONLY | getattr(_os, "O_DIRECTORY", 0)) + try: + dir_fd = _os.open( + dir_path, _os.O_RDONLY | getattr(_os, "O_DIRECTORY", 0) + ) + except OSError as exc: + raise _key_oserror(dir_path, exc) from None try: _os.fsync(dir_fd) finally: @@ -223,16 +260,18 @@ def load_or_create_demo_key( ) -> tuple[bytes, int]: """Load or create a durable demo acceptance key file. - Security posture (REA #54 / seq 1690): - - Open existing files with ``O_NOFOLLOW``; refuse symlinks. + Security posture (REA #54 / seq 1690 + seq 1801): + - Open existing files with ``O_NOFOLLOW|O_NONBLOCK``; refuse symlinks and + non-regular files (FIFOs must not hang startup). + - Map every open/read ``OSError`` (including EACCES) to ``ValueError``. - Require a regular file owned by the process euid with mode exactly 0600. - - Read exactly 32 bytes and require EOF (no trailing newline/bytes). + - Clear O_NONBLOCK, then read exactly 32 bytes and require EOF. - Create with ``O_CREAT|O_EXCL|O_NOFOLLOW`` at 0600, then ``fsync`` the file and its directory. - If ``db_nonempty`` and the key file is missing, refuse to regenerate. Returns ``(key_bytes, verified_mode)`` so callers can log the mode only - after it has been checked. + after it has been checked — and without opening the file a second time. """ key_path = _Path(path) try: @@ -246,7 +285,9 @@ def load_or_create_demo_key( return _create_demo_key_file(key_path) try: + # Reject FIFO/device/etc. before clearing NONBLOCK / reading. mode = _require_demo_key_fstat(fd, key_path) + _clear_nonblock(fd) key = _read_exact_demo_key(fd, key_path) return key, mode finally: @@ -262,12 +303,18 @@ def _create_demo_key_file(key_path: _Path) -> tuple[bytes, int]: | _os.O_EXCL | getattr(_os, "O_NOFOLLOW", 0) ) - fd = _os.open(key_path, flags, 0o600) try: - _os.write(fd, key) - # umask may have cleared bits from the create mode; force 0600. - _os.fchmod(fd, 0o600) - _os.fsync(fd) + fd = _os.open(key_path, flags, 0o600) + except OSError as exc: + raise _key_oserror(key_path, exc) from None + try: + try: + _os.write(fd, key) + # umask may have cleared bits from the create mode; force 0600. + _os.fchmod(fd, 0o600) + _os.fsync(fd) + except OSError as exc: + raise _key_oserror(key_path, exc) from None mode = _require_demo_key_fstat(fd, key_path) finally: _os.close(fd) diff --git a/tests/test_demo_key_rea_n_b.py b/tests/test_demo_key_rea_n_b.py new file mode 100644 index 0000000..3807e1c --- /dev/null +++ b/tests/test_demo_key_rea_n_b.py @@ -0,0 +1,221 @@ +"""REA seq 1801 (N-b) regressions on PR #54 @ 70b0fc6. + +- chmod-000 / foreign-owner opens must become clean ValueError (not + PermissionError traceback). +- A FIFO at the key path must fail fast (O_NONBLOCK), not hang. +- server.py must open the durable key file exactly once at startup. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +import server as server_mod +from settlement.verifier import load_or_create_demo_key + +ROOT = Path(__file__).resolve().parents[1] +MIN = 32 + + +def _write_key(path: Path, data: bytes, mode: int = 0o600) -> None: + path.write_bytes(data) + os.chmod(path, mode) + + +def test_chmod_000_key_file_is_clean_valueerror(monkeypatch, tmp_path): + """Real unreadable file: EACCES must map to ValueError, not PermissionError.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + if os.geteuid() == 0: + pytest.skip("root bypasses mode bits; chmod-000 needs a non-root euid") + path = tmp_path / "locked.acceptance_key" + _write_key(path, os.urandom(MIN), 0o000) + try: + with pytest.raises(ValueError, match="cannot be opened"): + load_or_create_demo_key(path) + finally: + os.chmod(path, 0o600) + + +def test_chmod_000_server_exits_clean_no_traceback(monkeypatch, tmp_path): + if os.geteuid() == 0: + pytest.skip("root bypasses mode bits; chmod-000 needs a non-root euid") + db = tmp_path / "ledger.db" + key = Path(f"{db}.acceptance_key") + _write_key(key, os.urandom(MIN), 0o000) + # Touch an empty sqlite db path by letting server create it — but the key + # already exists (unreadable), so load fails before serve. + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + # Pre-create a valid empty durable db so --db opens, then hits the key. + from permit.sqlite_ledger import SqliteLedger + + SqliteLedger(str(db)).close() + try: + proc = subprocess.run( + [ + sys.executable, + "server.py", + "--port", + "0", + "--db", + str(db), + "--allow-demo-key", + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=8, + ) + finally: + os.chmod(key, 0o600) + out = (proc.stdout or "") + (proc.stderr or "") + assert proc.returncode != 0 + assert "acceptance key error:" in out + assert "Traceback" not in out + assert "PermissionError" not in out + + +@pytest.mark.skipif(os.geteuid() != 0, reason="chown/seteuid requires root") +def test_foreign_owner_real_chown_clean_valueerror(tmp_path, monkeypatch): + """Real foreign-owned 0600 file: drop euid so open hits EACCES, map cleanly.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "foreign.acceptance_key" + _write_key(path, os.urandom(MIN), 0o600) + # Keep ownership on root; switch euid to nobody so open fails with EACCES. + nobody = 65534 + os.chown(path, 0, 0) + os.seteuid(nobody) + try: + with pytest.raises(ValueError, match="cannot be opened"): + load_or_create_demo_key(path) + finally: + os.seteuid(0) + + +def test_fifo_at_key_path_fails_fast(monkeypatch, tmp_path): + """A FIFO at the key path must fail fast (O_NONBLOCK), not hang open().""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "fifo.acceptance_key" + os.mkfifo(path, 0o600) + # Run in a child process so a blocking open on 70b0fc6 is killed by timeout + # rather than wedging the pytest worker thread. + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + t0 = time.monotonic() + try: + proc = subprocess.run( + [ + sys.executable, + "-c", + ( + "from settlement.verifier import load_or_create_demo_key\n" + f"load_or_create_demo_key({str(path)!r})\n" + ), + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=3, + ) + except subprocess.TimeoutExpired as exc: + pytest.fail(f"load_or_create_demo_key hung on FIFO: {exc}") + assert time.monotonic() - t0 < 3.0 + out = (proc.stdout or "") + (proc.stderr or "") + assert proc.returncode != 0, out + assert "regular file" in out + + +def test_fifo_server_exits_fast_nonzero(monkeypatch, tmp_path): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + db = tmp_path / "fifo.db" + key = Path(f"{db}.acceptance_key") + from permit.sqlite_ledger import SqliteLedger + + SqliteLedger(str(db)).close() + os.mkfifo(key, 0o600) + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + t0 = time.monotonic() + try: + proc = subprocess.run( + [ + sys.executable, + "server.py", + "--port", + "0", + "--db", + str(db), + "--allow-demo-key", + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=3, + ) + except subprocess.TimeoutExpired as exc: + pytest.fail(f"server hung on FIFO key path: {exc}") + assert time.monotonic() - t0 < 3.0 + out = (proc.stdout or "") + (proc.stderr or "") + assert proc.returncode != 0 + assert "acceptance key error:" in out + assert "Traceback" not in out + + +def test_server_opens_demo_key_file_exactly_once(monkeypatch, tmp_path): + """The durable key must be loaded once; a second load can diverge (seq 1801).""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + db = tmp_path / "once.db" + key_path = Path(f"{db}.acceptance_key") + from permit.sqlite_ledger import SqliteLedger + import settlement.verifier as verifier_mod + from permit.ledger import Ledger + from permit.permit import PermitStore + + SqliteLedger(str(db)).close() + + opens: list[str] = [] + real_load = verifier_mod.load_or_create_demo_key + + def counting_load(path, *, db_nonempty=False): + opens.append(str(path)) + return real_load(path, db_nonempty=db_nonempty) + + # Patch both the server import and the verifier module name resolve uses. + monkeypatch.setattr(verifier_mod, "load_or_create_demo_key", counting_load) + monkeypatch.setattr(server_mod, "load_or_create_demo_key", counting_load) + + class _FakeServer: + def __init__(self, *a, **k): + pass + + def serve_forever(self): + raise KeyboardInterrupt + + monkeypatch.setattr(server_mod, "ThreadingHTTPServer", _FakeServer) + monkeypatch.setattr( + sys, + "argv", + [ + "server.py", + "--port", + "8765", + "--db", + str(db), + "--allow-demo-key", + ], + ) + # Reset module globals that main() mutates. + server_mod.ledger = Ledger() + server_mod.permits = PermitStore(ledger=server_mod.ledger) + try: + server_mod.main() + except SystemExit as exc: + pytest.fail(f"server exited early: {exc}") + assert opens == [str(key_path)], f"expected one load, got {opens!r}" + assert key_path.exists() From e36c22baa763d1cdfa10ded23ff05280c28a3f44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 16:42:49 +0000 Subject: [PATCH 06/22] Bind durable acceptance-key commitment via HMAC (REA N-b, droppable) On first --db use, store HMAC(key, 'permit-acceptance-key-v1') in the SQLite meta table; refuse to start on a commitment mismatch so a same-length swapped key cannot silently take over. Separate commit so the binding can be dropped independently of the OSError/FIFO/once-open fixes. Co-authored-by: Marcus Richards --- permit/sqlite_ledger.py | 28 ++++++ server.py | 28 ++++++ settlement/verifier.py | 6 ++ tests/test_acceptance_key_commitment.py | 112 ++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 tests/test_acceptance_key_commitment.py diff --git a/permit/sqlite_ledger.py b/permit/sqlite_ledger.py index 2f8c0d2..45fd8bb 100644 --- a/permit/sqlite_ledger.py +++ b/permit/sqlite_ledger.py @@ -64,14 +64,25 @@ WHERE json_extract(payload, '$.idempotency_key') IS NOT NULL """ +_DDL_META = """ +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +) +""" + _DDL = ( _DDL_TABLE, _DDL_NO_UPDATE, _DDL_NO_DELETE, _DDL_LINK, _DDL_IDEMPOTENCY, + _DDL_META, ) +# HMAC(acceptance_key, b"permit-acceptance-key-v1") hex, bound on first use. +ACCEPTANCE_KEY_COMMITMENT_META = "acceptance_key_commitment_v1" + class SqliteLedger: """Durable, thread-safe, append-only receipt log with chain verification.""" @@ -265,6 +276,23 @@ def verify_chain(self) -> tuple[bool, str]: receipts = list(self._receipts) return self._verify_receipts(receipts) + def get_meta(self, key: str) -> str | None: + """Return a meta value, or None if unset.""" + with self._lock: + row = self._conn.execute( + "SELECT value FROM meta WHERE key = ?", (key,) + ).fetchone() + return None if row is None else str(row[0]) + + def set_meta(self, key: str, value: str) -> None: + """Insert or replace a meta value (durable, same connection).""" + with self._lock: + self._conn.execute( + "INSERT INTO meta(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + def close(self) -> None: """Close the database connection. The instance must not be used after.""" with self._lock: diff --git a/server.py b/server.py index b0f94f7..ec3a9fb 100644 --- a/server.py +++ b/server.py @@ -57,6 +57,7 @@ Evidence, PredicateType, ReleaseVerifier, + acceptance_key_commitment, load_or_create_demo_key, ) @@ -498,6 +499,25 @@ def do_POST(self): return self._error(404, "not_found") +def _ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: + """Store HMAC(key, 'permit-acceptance-key-v1') on first use; refuse mismatch. + + Raises ValueError on mismatch so callers can exit as 'acceptance key error'. + """ + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + + digest = acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return + if not hmac.compare_digest(stored, digest): + raise ValueError( + "acceptance key commitment mismatch with durable ledger " + "(HMAC key material changed since first use)" + ) + + # ---------------------------------------------------------------- main def main(): @@ -591,6 +611,14 @@ def _build_verifier(active_permits, active_ledger): verifier = _build_verifier(permits, ledger) flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) + if args.db: + # Bind the acceptance key to the durable ledger on first use; refuse + # a same-length swapped key on later starts (REA seq 1801 / N-b). + try: + _ensure_acceptance_key_commitment(ledger, verifier._acceptance_key) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from None + if demo_key_path is not None and demo_key_mode is not None: log.info( f"demo acceptance key persisted at {demo_key_path} " diff --git a/settlement/verifier.py b/settlement/verifier.py index 5d36251..7d9f412 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -122,6 +122,12 @@ class ReconcileResult: from pathlib import Path as _Path MIN_ACCEPTANCE_KEY_BYTES = 32 +ACCEPTANCE_KEY_COMMITMENT_MSG = b"permit-acceptance-key-v1" + + +def acceptance_key_commitment(key: bytes) -> str: + """HMAC(key, 'permit-acceptance-key-v1') as hex — durable binding (REA N-b).""" + return hmac.new(key, ACCEPTANCE_KEY_COMMITMENT_MSG, hashlib.sha256).hexdigest() def _coerce_key(key: bytes | str | None) -> bytes | None: diff --git a/tests/test_acceptance_key_commitment.py b/tests/test_acceptance_key_commitment.py new file mode 100644 index 0000000..49f3703 --- /dev/null +++ b/tests/test_acceptance_key_commitment.py @@ -0,0 +1,112 @@ +"""REA seq 1801 N-b: durable HMAC commitment for the acceptance key. + +Stored in the SQLite meta table on first use; a same-length corrupted key +must refuse to start. Lives in its own commit so it can be dropped. + +Imports of new symbols are deferred inside tests so this module still +collects on 70b0fc6, where the cases fail by assertion (or AttributeError). +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MIN = 32 + + +def _env_no_key() -> dict[str, str]: + return {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + + +def test_commitment_stored_on_first_use(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + import server as server_mod + from settlement.verifier import acceptance_key_commitment + + db = tmp_path / "commit.db" + lg = SqliteLedger(str(db)) + try: + key = os.urandom(MIN) + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) is None + server_mod._ensure_acceptance_key_commitment(lg, key) + stored = lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + assert stored == acceptance_key_commitment(key) + server_mod._ensure_acceptance_key_commitment(lg, key) + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == stored + finally: + lg.close() + + +def test_commitment_mismatch_raises_valueerror(tmp_path): + from permit.sqlite_ledger import SqliteLedger + import server as server_mod + + db = tmp_path / "mismatch.db" + lg = SqliteLedger(str(db)) + try: + key_a = os.urandom(MIN) + key_b = os.urandom(MIN) + assert key_a != key_b + server_mod._ensure_acceptance_key_commitment(lg, key_a) + with pytest.raises(ValueError, match="commitment mismatch"): + server_mod._ensure_acceptance_key_commitment(lg, key_b) + finally: + lg.close() + + +def test_same_length_corrupted_key_refuses_startup(tmp_path, monkeypatch): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import acceptance_key_commitment + + db = tmp_path / "corrupt.db" + key_path = Path(f"{db}.acceptance_key") + SqliteLedger(str(db)).close() + + good = os.urandom(MIN) + key_path.write_bytes(good) + os.chmod(key_path, 0o600) + lg = SqliteLedger(str(db)) + # On 70b0fc6 set_meta / ACCEPTANCE_KEY_COMMITMENT_META are absent — + # AttributeError fails the test. On head, seed the commitment then corrupt. + lg.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, acceptance_key_commitment(good)) + lg.close() + + bad = os.urandom(MIN) + assert bad != good and len(bad) == MIN + key_path.write_bytes(bad) + os.chmod(key_path, 0o600) + + proc = subprocess.run( + [ + sys.executable, + "server.py", + "--port", + "0", + "--db", + str(db), + "--allow-demo-key", + ], + cwd=ROOT, + env=_env_no_key(), + capture_output=True, + text=True, + timeout=8, + ) + out = (proc.stdout or "") + (proc.stderr or "") + assert proc.returncode != 0 + assert "acceptance key error:" in out + assert "commitment mismatch" in out + assert "Traceback" not in out From 730d11bec845af2670eb33d6959c2221a4aa7ef9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 16:55:20 +0000 Subject: [PATCH 07/22] test: never-skip chmod-000 and sudo chown foreign-owner key checks Replace skip-unless-root with hard fails: chmod-000 asserts non-root euid, foreign-owner requires passwordless sudo -n chown nobody and cleans up with sudo. Both fail on 70b0fc6 and pass on tip. Co-authored-by: Marcus Richards --- README.md | 2 +- tests/test_demo_key_rea_n_b.py | 105 ++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index cf12d40..9afcce3 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **417 tests pass, 1 skipped (418 collected):** `python -m pytest -q` and `python -O -m pytest -q` on `launch/53-key-handling` (issue #53 / REA seq 1690). CI runs the same suite plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. +- **426 tests pass, 1 skipped (427 collected):** `python -m pytest -q` and `python -O -m pytest -q` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801). CI runs the same suite plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. - **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path. diff --git a/tests/test_demo_key_rea_n_b.py b/tests/test_demo_key_rea_n_b.py index 3807e1c..e33147b 100644 --- a/tests/test_demo_key_rea_n_b.py +++ b/tests/test_demo_key_rea_n_b.py @@ -29,12 +29,15 @@ def _write_key(path: Path, data: bytes, mode: int = 0o600) -> None: def test_chmod_000_key_file_is_clean_valueerror(monkeypatch, tmp_path): - """Real unreadable file: EACCES must map to ValueError, not PermissionError.""" + """Own-user mode-000 file: EACCES → ValueError (never skip; fail if root).""" monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) - if os.geteuid() == 0: - pytest.skip("root bypasses mode bits; chmod-000 needs a non-root euid") + assert os.geteuid() != 0, ( + "chmod-000 key test must run as non-root (root bypasses mode bits); " + "do not skip — fix the environment" + ) path = tmp_path / "locked.acceptance_key" _write_key(path, os.urandom(MIN), 0o000) + assert path.stat().st_uid == os.geteuid() try: with pytest.raises(ValueError, match="cannot be opened"): load_or_create_demo_key(path) @@ -43,15 +46,16 @@ def test_chmod_000_key_file_is_clean_valueerror(monkeypatch, tmp_path): def test_chmod_000_server_exits_clean_no_traceback(monkeypatch, tmp_path): - if os.geteuid() == 0: - pytest.skip("root bypasses mode bits; chmod-000 needs a non-root euid") + """Own-user mode-000 key: server exits 1 with clean acceptance key error.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + assert os.geteuid() != 0, ( + "chmod-000 server test must run as non-root; do not skip — fix the environment" + ) db = tmp_path / "ledger.db" key = Path(f"{db}.acceptance_key") _write_key(key, os.urandom(MIN), 0o000) - # Touch an empty sqlite db path by letting server create it — but the key - # already exists (unreadable), so load fails before serve. + assert key.stat().st_uid == os.geteuid() env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} - # Pre-create a valid empty durable db so --db opens, then hits the key. from permit.sqlite_ledger import SqliteLedger SqliteLedger(str(db)).close() @@ -75,27 +79,96 @@ def test_chmod_000_server_exits_clean_no_traceback(monkeypatch, tmp_path): finally: os.chmod(key, 0o600) out = (proc.stdout or "") + (proc.stderr or "") - assert proc.returncode != 0 + assert proc.returncode == 1, out assert "acceptance key error:" in out assert "Traceback" not in out assert "PermissionError" not in out -@pytest.mark.skipif(os.geteuid() != 0, reason="chown/seteuid requires root") +def _sudo_n(*args: str) -> subprocess.CompletedProcess[str]: + """Run ``sudo -n …``. Fail (never skip) if passwordless sudo is unavailable.""" + return subprocess.run( + ["sudo", "-n", *args], + capture_output=True, + text=True, + timeout=30, + ) + + def test_foreign_owner_real_chown_clean_valueerror(tmp_path, monkeypatch): - """Real foreign-owned 0600 file: drop euid so open hits EACCES, map cleanly.""" + """Real foreign-owned 0600 key via ``sudo -n chown nobody`` (never skip). + + Creates a 0600 key, then ``sudo -n chown nobody``. GitHub Ubuntu runners + have passwordless sudo; if ``sudo -n`` fails, this test FAILS (does not + skip). Non-root open then hits EACCES; that must map to a clean + ValueError / ``acceptance key error`` (exit 1, no traceback). Cleanup + uses sudo too. + """ monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + assert os.geteuid() != 0, ( + "foreign-owner test must run as non-root so open hits EACCES; " + "do not skip — fix the environment" + ) path = tmp_path / "foreign.acceptance_key" _write_key(path, os.urandom(MIN), 0o600) - # Keep ownership on root; switch euid to nobody so open fails with EACCES. - nobody = 65534 - os.chown(path, 0, 0) - os.seteuid(nobody) + + chown = _sudo_n("chown", "nobody", str(path)) + assert chown.returncode == 0, ( + "sudo -n chown nobody failed — passwordless sudo is required " + f"(never skip). stderr={chown.stderr!r} stdout={chown.stdout!r}" + ) try: + st = os.stat(path) + assert st.st_uid != os.geteuid(), "chown nobody did not change ownership" + assert (st.st_mode & 0o777) == 0o600 + with pytest.raises(ValueError, match="cannot be opened"): load_or_create_demo_key(path) + + # Also exercise the server entry point: clean exit 1, no traceback. + db = tmp_path / "foreign.db" + from permit.sqlite_ledger import SqliteLedger + + SqliteLedger(str(db)).close() + server_key = Path(f"{db}.acceptance_key") + _write_key(server_key, os.urandom(MIN), 0o600) + chown2 = _sudo_n("chown", "nobody", str(server_key)) + assert chown2.returncode == 0, chown2.stderr + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + try: + proc = subprocess.run( + [ + sys.executable, + "server.py", + "--port", + "0", + "--db", + str(db), + "--allow-demo-key", + ], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=8, + ) + finally: + fix2 = _sudo_n( + "chown", f"{os.geteuid()}:{os.getegid()}", str(server_key) + ) + assert fix2.returncode == 0, fix2.stderr + os.chmod(server_key, 0o600) + out = (proc.stdout or "") + (proc.stderr or "") + assert proc.returncode == 1, out + assert "acceptance key error:" in out + assert "Traceback" not in out + assert "PermissionError" not in out finally: - os.seteuid(0) + fix = _sudo_n("chown", f"{os.geteuid()}:{os.getegid()}", str(path)) + assert fix.returncode == 0, ( + f"sudo cleanup chown failed: stderr={fix.stderr!r}" + ) + os.chmod(path, 0o600) def test_fifo_at_key_path_fails_fast(monkeypatch, tmp_path): From c7b1ac7ce61940d134abde2429e7a8d69725f3f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 17:00:50 +0000 Subject: [PATCH 08/22] fix: refuse foreign-owned demo keys before open lstat ownership before O_RDONLY so a real sudo chown nobody 0600 key hits the owner check instead of masking as EACCES. Keeps the post-open fstat owner check; chmod-000 own-user path still maps EACCES via _key_oserror. Co-authored-by: Marcus Richards --- settlement/verifier.py | 29 +++++++++++++++++++++++++++++ tests/test_demo_key_rea_n_b.py | 12 +++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/settlement/verifier.py b/settlement/verifier.py index 7d9f412..3957038 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -180,12 +180,41 @@ def _clear_nonblock(fd: int) -> None: _fcntl.fcntl(fd, _fcntl.F_SETFL, flags & ~getattr(_os, "O_NONBLOCK", 0)) +def _reject_foreign_demo_key_owner(key_path: _Path) -> None: + """Refuse foreign-owned key paths before open. + + A 0600 file owned by another uid yields EACCES on open for a non-root + euid, which would mask the ownership failure. Check ``lstat`` first so + a real ``chown`` foreign-owner case reaches the owner check. Symlinks are + rejected here too; ``O_NOFOLLOW`` remains on the subsequent open. + """ + try: + st = _os.lstat(key_path) + except FileNotFoundError: + raise + except OSError as exc: + raise _key_oserror(key_path, exc) from None + if _stat.S_ISLNK(st.st_mode): + raise ValueError( + f"demo acceptance key at {key_path} must not be a symlink" + ) + euid = _os.geteuid() + if st.st_uid != euid: + raise ValueError( + f"demo acceptance key at {key_path} must be owned by euid " + f"{euid} (got uid {st.st_uid})" + ) + + def _open_demo_key_readonly(key_path: _Path) -> int: """Open ``key_path`` with O_NOFOLLOW|O_NONBLOCK; map OSError → ValueError. O_NONBLOCK keeps a FIFO (or other blocking special file) from hanging startup forever; the regular-file check then rejects non-files. + Ownership is checked via ``lstat`` before open so foreign-owned 0600 + files fail the owner check instead of masking as EACCES. """ + _reject_foreign_demo_key_owner(key_path) flags = ( _os.O_RDONLY | getattr(_os, "O_NOFOLLOW", 0) diff --git a/tests/test_demo_key_rea_n_b.py b/tests/test_demo_key_rea_n_b.py index e33147b..382ffa7 100644 --- a/tests/test_demo_key_rea_n_b.py +++ b/tests/test_demo_key_rea_n_b.py @@ -100,13 +100,14 @@ def test_foreign_owner_real_chown_clean_valueerror(tmp_path, monkeypatch): Creates a 0600 key, then ``sudo -n chown nobody``. GitHub Ubuntu runners have passwordless sudo; if ``sudo -n`` fails, this test FAILS (does not - skip). Non-root open then hits EACCES; that must map to a clean - ValueError / ``acceptance key error`` (exit 1, no traceback). Cleanup - uses sudo too. + skip). Pre-open ``lstat`` ownership check must raise a clean + ``owned by euid`` ValueError (this is the only test that reaches that + check with a real foreign uid). Server must exit 1 with + ``acceptance key error`` and no traceback. Cleanup uses sudo too. """ monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) assert os.geteuid() != 0, ( - "foreign-owner test must run as non-root so open hits EACCES; " + "foreign-owner test must run as non-root; " "do not skip — fix the environment" ) path = tmp_path / "foreign.acceptance_key" @@ -122,7 +123,7 @@ def test_foreign_owner_real_chown_clean_valueerror(tmp_path, monkeypatch): assert st.st_uid != os.geteuid(), "chown nobody did not change ownership" assert (st.st_mode & 0o777) == 0o600 - with pytest.raises(ValueError, match="cannot be opened"): + with pytest.raises(ValueError, match="owned by euid"): load_or_create_demo_key(path) # Also exercise the server entry point: clean exit 1, no traceback. @@ -161,6 +162,7 @@ def test_foreign_owner_real_chown_clean_valueerror(tmp_path, monkeypatch): out = (proc.stdout or "") + (proc.stderr or "") assert proc.returncode == 1, out assert "acceptance key error:" in out + assert "owned by euid" in out assert "Traceback" not in out assert "PermissionError" not in out finally: From 09b851a1961302baa1017591d3e8931adfad1592 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 18:33:20 +0000 Subject: [PATCH 09/22] fix: honour env acceptance key with --db; enforce HMAC at every SqliteLedger Regression from 2a1bbfe: --db --allow-demo-key pre-loaded the file key and ignored PERMIT_ACCEPTANCE_KEY. Env wins when set (refuse empty / whitespace / short); file is used only when unset. Move ensure_acceptance_key_commitment into settlement.verifier and call it from ReleaseVerifier for every SqliteLedger plus server.py --db. Rotation refuses startup (no rebind). Co-authored-by: Marcus Richards --- README.md | 2 +- SECURITY.md | 10 ++ server.py | 48 +++---- settlement/verifier.py | 27 ++++ tests/test_acceptance_key_commitment.py | 75 ++++++++--- tests/test_db_env_key_precedence.py | 166 ++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 44 deletions(-) create mode 100644 tests/test_db_env_key_precedence.py diff --git a/README.md b/README.md index 9afcce3..0e18cac 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Permit fails closed. When state is uncertain, money does not move. | Tampered delivery evidence | Release refused, hold voided | **What it does not defend (production boundaries):** -- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). +- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). Whenever `PERMIT_ACCEPTANCE_KEY` is set it wins over the file key; empty, whitespace-only, and wrong-length env values still refuse startup (#53 bar 3 / REA seq 1854). The durable ledger also stores an HMAC commitment of the acceptance key on first use; **rotating the acceptance key on an existing database refuses startup (no rebind for now)**. - HTTP POSTs require a single shared bearer token (`PERMIT_API_TOKEN`, #23) with no role split between principal and agent (F5). GETs, including `/api/ledger`, are unauthenticated; the server binds to 127.0.0.1. - Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, under 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53). - `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of at least 32 bytes. There is no module-level fallback key (#35/#53 API change). diff --git a/SECURITY.md b/SECURITY.md index acbb1be..462c939 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,6 +36,16 @@ There is **no bug bounty**, and no monetary reward is offered or implied. This i research project maintained by one person. What it can offer is a fast, honest response and public credit. +## Acceptance key + durable `--db` ledger + +- `PERMIT_ACCEPTANCE_KEY`, when set, is always used (including with `--db --allow-demo-key`). + Empty, whitespace-only, and under-32-byte values refuse startup; the demo flag never + papers over a bad configured key. +- With `--db`, the ledger stores an HMAC commitment of the acceptance key on first use + (`ensure_acceptance_key_commitment`, enforced in `ReleaseVerifier` for every + `SqliteLedger` and again at `server.py --db`). **Rotating the acceptance key on an + existing database refuses startup (no rebind for now).** + ## Related - Our agent-systems threat posture and the method behind these reviews: see the `adversarial-seat` diff --git a/server.py b/server.py index ec3a9fb..12b35d9 100644 --- a/server.py +++ b/server.py @@ -57,8 +57,9 @@ Evidence, PredicateType, ReleaseVerifier, - acceptance_key_commitment, + ensure_acceptance_key_commitment, load_or_create_demo_key, + resolve_acceptance_key, ) # ---------------------------------------------------------------- state @@ -499,25 +500,6 @@ def do_POST(self): return self._error(404, "not_found") -def _ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: - """Store HMAC(key, 'permit-acceptance-key-v1') on first use; refuse mismatch. - - Raises ValueError on mismatch so callers can exit as 'acceptance key error'. - """ - from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META - - digest = acceptance_key_commitment(key) - stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) - if stored is None: - db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) - return - if not hmac.compare_digest(stored, digest): - raise ValueError( - "acceptance key commitment mismatch with durable ledger " - "(HMAC key material changed since first use)" - ) - - # ---------------------------------------------------------------- main def main(): @@ -563,9 +545,11 @@ def main(): # Demo key is an explicit opt-in (#53), not implied by mock mode. # Open the durable ledger BEFORE resolving a demo key so a nonempty # db with a missing key file refuses to start instead of regenerating. - # Load the durable demo key at most once here, then pass the bytes into - # ReleaseVerifier — a second load could recreate a deleted file while - # the process kept signing with the first key (REA seq 1801). + # Key order (REA seq 1854 / regression from 2a1bbfe): whenever + # PERMIT_ACCEPTANCE_KEY is set, use that exact key (refuse empty / + # whitespace-only / wrong-length the same way 70b0fc6 did). Fall back + # to the durable key file only when the env var is unset. Load the + # file at most once, then pass the bytes into ReleaseVerifier. demo_key_path = None demo_key_bytes = None demo_key_mode = None @@ -582,7 +566,15 @@ def main(): if args.allow_demo_key: demo_key_path = f"{args.db}.acceptance_key" - if demo_key_path is not None: + env_key_set = "PERMIT_ACCEPTANCE_KEY" in os.environ + if env_key_set: + # Env wins over the durable file; invalid values fail closed even + # with --allow-demo-key (#53 bar 3). + try: + demo_key_bytes = resolve_acceptance_key() + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from None + elif demo_key_path is not None: try: demo_key_bytes, demo_key_mode = load_or_create_demo_key( demo_key_path, db_nonempty=demo_key_db_nonempty @@ -612,14 +604,14 @@ def _build_verifier(active_permits, active_ledger): flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) if args.db: - # Bind the acceptance key to the durable ledger on first use; refuse - # a same-length swapped key on later starts (REA seq 1801 / N-b). + # Explicit --db entry point: bind/refuse commitment (also enforced + # inside ReleaseVerifier for any SqliteLedger consumer). try: - _ensure_acceptance_key_commitment(ledger, verifier._acceptance_key) + ensure_acceptance_key_commitment(ledger, verifier._acceptance_key) except ValueError as exc: raise SystemExit(f"acceptance key error: {exc}") from None - if demo_key_path is not None and demo_key_mode is not None: + if demo_key_path is not None and demo_key_mode is not None and not env_key_set: log.info( f"demo acceptance key persisted at {demo_key_path} " f"(mode {demo_key_mode:04o})" diff --git a/settlement/verifier.py b/settlement/verifier.py index 3957038..abaa064 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -130,6 +130,30 @@ def acceptance_key_commitment(key: bytes) -> str: return hmac.new(key, ACCEPTANCE_KEY_COMMITMENT_MSG, hashlib.sha256).hexdigest() +def ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: + """Bind ``key`` to a durable ledger on first use; refuse rotation. + + Every ``--db`` / SqliteLedger entry point must call this after resolving + the acceptance key. Rotating the key on an existing database refuses + startup — there is no rebind for now (REA seq 1854). + """ + # Imported here to keep settlement.verifier free of a hard sqlite import + # cycle at module load for callers that never touch durable mode. + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + + digest = acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return + if not hmac.compare_digest(stored, digest): + raise ValueError( + "acceptance key commitment mismatch with durable ledger: " + "rotating the acceptance key on an existing database refuses " + "startup (no rebind for now)" + ) + + def _coerce_key(key: bytes | str | None) -> bytes | None: if key is None: return None @@ -445,6 +469,9 @@ def __init__( self.permits = permits # NOTE: explicit None check — an empty Ledger is falsy via __len__. self.ledger = ledger if ledger is not None else Ledger() + # Durable SqliteLedger (--db) entry point: bind/refuse key commitment. + if hasattr(self.ledger, "get_meta") and hasattr(self.ledger, "set_meta"): + ensure_acceptance_key_commitment(self.ledger, self._acceptance_key) self._escrows: dict[str, Escrow] = {} self._lock = threading.Lock() diff --git a/tests/test_acceptance_key_commitment.py b/tests/test_acceptance_key_commitment.py index 49f3703..8942416 100644 --- a/tests/test_acceptance_key_commitment.py +++ b/tests/test_acceptance_key_commitment.py @@ -1,10 +1,13 @@ -"""REA seq 1801 N-b: durable HMAC commitment for the acceptance key. +"""REA seq 1801 N-b / 1854: durable HMAC commitment for the acceptance key. Stored in the SQLite meta table on first use; a same-length corrupted key -must refuse to start. Lives in its own commit so it can be dropped. +must refuse to start. Lives in its own commit lineage so it can be dropped. -Imports of new symbols are deferred inside tests so this module still -collects on 70b0fc6, where the cases fail by assertion (or AttributeError). +Enforced at every --db / SqliteLedger entry point (not only server.py): + 1. settlement/verifier.py ReleaseVerifier.__init__ (SqliteLedger duck-type) + 2. server.py main --db (explicit SystemExit wrapper) + +Rotating the key on an existing database refuses startup (no rebind). """ from __future__ import annotations @@ -19,6 +22,10 @@ ROOT = Path(__file__).resolve().parents[1] MIN = 32 +REFUSE_ROTATION = ( + "rotating the acceptance key on an existing database refuses startup" +) + def _env_no_key() -> dict[str, str]: return {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} @@ -29,18 +36,20 @@ def test_commitment_stored_on_first_use(tmp_path): ACCEPTANCE_KEY_COMMITMENT_META, SqliteLedger, ) - import server as server_mod - from settlement.verifier import acceptance_key_commitment + from settlement.verifier import ( + acceptance_key_commitment, + ensure_acceptance_key_commitment, + ) db = tmp_path / "commit.db" lg = SqliteLedger(str(db)) try: key = os.urandom(MIN) assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) is None - server_mod._ensure_acceptance_key_commitment(lg, key) + ensure_acceptance_key_commitment(lg, key) stored = lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) assert stored == acceptance_key_commitment(key) - server_mod._ensure_acceptance_key_commitment(lg, key) + ensure_acceptance_key_commitment(lg, key) assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == stored finally: lg.close() @@ -48,7 +57,7 @@ def test_commitment_stored_on_first_use(tmp_path): def test_commitment_mismatch_raises_valueerror(tmp_path): from permit.sqlite_ledger import SqliteLedger - import server as server_mod + from settlement.verifier import ensure_acceptance_key_commitment db = tmp_path / "mismatch.db" lg = SqliteLedger(str(db)) @@ -56,14 +65,46 @@ def test_commitment_mismatch_raises_valueerror(tmp_path): key_a = os.urandom(MIN) key_b = os.urandom(MIN) assert key_a != key_b - server_mod._ensure_acceptance_key_commitment(lg, key_a) - with pytest.raises(ValueError, match="commitment mismatch"): - server_mod._ensure_acceptance_key_commitment(lg, key_b) + ensure_acceptance_key_commitment(lg, key_a) + with pytest.raises(ValueError, match=REFUSE_ROTATION): + ensure_acceptance_key_commitment(lg, key_b) finally: lg.close() -def test_same_length_corrupted_key_refuses_startup(tmp_path, monkeypatch): +def test_entry_release_verifier_sqlite_mismatch(tmp_path, monkeypatch): + """Entry point 1: ReleaseVerifier + SqliteLedger refuses rotation.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + from permit.ledger import Ledger + from permit.permit import PermitStore + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.paypal_client import MockPayPalClient + from settlement.verifier import ( + ReleaseVerifier, + acceptance_key_commitment, + ) + + db = tmp_path / "rv.db" + lg = SqliteLedger(str(db)) + good = os.urandom(MIN) + bad = os.urandom(MIN) + assert good != bad + lg.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, acceptance_key_commitment(good)) + paypal = MockPayPalClient() + # PermitStore needs a ledger; use in-memory for permits, sqlite for verifier. + permits = PermitStore(ledger=Ledger()) + with pytest.raises(ValueError, match=REFUSE_ROTATION): + ReleaseVerifier( + paypal, permits, ledger=lg, acceptance_key=bad + ) + lg.close() + + +def test_entry_server_db_mismatch(tmp_path, monkeypatch): + """Entry point 2: server.py --db refuses rotation with documented message.""" monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) from permit.sqlite_ledger import ( ACCEPTANCE_KEY_COMMITMENT_META, @@ -79,8 +120,6 @@ def test_same_length_corrupted_key_refuses_startup(tmp_path, monkeypatch): key_path.write_bytes(good) os.chmod(key_path, 0o600) lg = SqliteLedger(str(db)) - # On 70b0fc6 set_meta / ACCEPTANCE_KEY_COMMITMENT_META are absent — - # AttributeError fails the test. On head, seed the commitment then corrupt. lg.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, acceptance_key_commitment(good)) lg.close() @@ -109,4 +148,10 @@ def test_same_length_corrupted_key_refuses_startup(tmp_path, monkeypatch): assert proc.returncode != 0 assert "acceptance key error:" in out assert "commitment mismatch" in out + assert REFUSE_ROTATION in out assert "Traceback" not in out + + +# Keep the historical name as an alias so older reports still find it. +def test_same_length_corrupted_key_refuses_startup(tmp_path, monkeypatch): + test_entry_server_db_mismatch(tmp_path, monkeypatch) diff --git a/tests/test_db_env_key_precedence.py b/tests/test_db_env_key_precedence.py new file mode 100644 index 0000000..3adee9a --- /dev/null +++ b/tests/test_db_env_key_precedence.py @@ -0,0 +1,166 @@ +"""REA seq 1854: with --db --allow-demo-key, PERMIT_ACCEPTANCE_KEY must win. + +Regression from 2a1bbfe: the server pre-loaded the durable file and passed +those bytes as an explicit acceptance_key, so a set env key was ignored. +These cases must fail on c7b1ac7 and pass on head. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, +) +from settlement.verifier import acceptance_key_commitment + +ROOT = Path(__file__).resolve().parents[1] +MIN = 32 + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _env(**extra: str) -> dict[str, str]: + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + env.update(extra) + return env + + +def _run_server(db: Path, env: dict[str, str], *, timeout: float = 8.0): + port = _free_port() + proc = subprocess.Popen( + [ + sys.executable, + "server.py", + "--port", + str(port), + "--db", + str(db), + "--allow-demo-key", + ], + cwd=ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + deadline = time.time() + timeout + listened = False + try: + while time.time() < deadline: + rc = proc.poll() + if rc is not None: + out = proc.stdout.read() if proc.stdout else "" + return subprocess.CompletedProcess(proc.args, rc, out or "", "") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + listened = True + break + except OSError: + time.sleep(0.05) + out = "" + proc.terminate() + try: + out, _ = proc.communicate(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + out, _ = proc.communicate(timeout=3) + return subprocess.CompletedProcess( + proc.args, 0 if listened else (proc.returncode or 1), out or "", "" + ) + finally: + if proc.poll() is None: + proc.kill() + + +def _seed_db_and_file(tmp_path: Path, file_key: bytes) -> tuple[Path, Path]: + db = tmp_path / "ledger.db" + key_path = Path(f"{db}.acceptance_key") + SqliteLedger(str(db)).close() + key_path.write_bytes(file_key) + os.chmod(key_path, 0o600) + return db, key_path + + +def test_db_allow_demo_env_valid_uses_exact_env_key(tmp_path): + """Env set and valid: that exact key is used (not the file key).""" + file_key = b"F" * MIN + env_key = b"E" * MIN + assert file_key != env_key + db, key_path = _seed_db_and_file(tmp_path, file_key) + before = key_path.read_bytes() + + proc = _run_server(db, _env(PERMIT_ACCEPTANCE_KEY=env_key.decode())) + out = proc.stdout or "" + assert proc.returncode == 0, out + assert "acceptance key error" not in out + + # File must be untouched; commitment must bind the env key. + assert key_path.read_bytes() == before + lg = SqliteLedger(str(db)) + try: + stored = lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + assert stored == acceptance_key_commitment(env_key) + assert stored != acceptance_key_commitment(file_key) + finally: + lg.close() + + +def test_db_allow_demo_env_empty_refuses(tmp_path): + db, _ = _seed_db_and_file(tmp_path, b"F" * MIN) + proc = _run_server(db, _env(PERMIT_ACCEPTANCE_KEY="")) + out = proc.stdout or "" + assert proc.returncode != 0 + assert "acceptance key error:" in out + assert "Traceback" not in out + + +def test_db_allow_demo_env_31_bytes_refuses(tmp_path): + db, _ = _seed_db_and_file(tmp_path, b"F" * MIN) + proc = _run_server(db, _env(PERMIT_ACCEPTANCE_KEY="x" * 31)) + out = proc.stdout or "" + assert proc.returncode != 0 + assert "acceptance key error:" in out + assert "under 32" in out or "31" in out + assert "Traceback" not in out + + +def test_db_allow_demo_env_whitespace_refuses(tmp_path): + db, _ = _seed_db_and_file(tmp_path, b"F" * MIN) + proc = _run_server(db, _env(PERMIT_ACCEPTANCE_KEY=" " * 32)) + out = proc.stdout or "" + assert proc.returncode != 0 + assert "acceptance key error:" in out + assert "whitespace" in out + assert "Traceback" not in out + + +def test_db_allow_demo_env_unset_uses_file_key(tmp_path): + """Env unset: the durable file key is used.""" + file_key = b"Z" * MIN + db, key_path = _seed_db_and_file(tmp_path, file_key) + + proc = _run_server(db, _env()) + out = proc.stdout or "" + assert proc.returncode == 0, out + assert key_path.read_bytes() == file_key + + lg = SqliteLedger(str(db)) + try: + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( + acceptance_key_commitment(file_key) + ) + finally: + lg.close() From c61011fee84b6f5e3f8061699c714c32a819c549 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 18:33:20 +0000 Subject: [PATCH 10/22] test: kill demo-key mutants (NOFOLLOW, EXCL, fsync, chmod, map, symlink msg) Add killer tests for each survived mutant and a sweep script that reapplies them in-process (7/7 killed). Co-authored-by: Marcus Richards --- scripts/mutant_sweep_demo_key.py | 282 +++++++++++++++++++++++++++++++ tests/test_demo_key_mutants.py | 138 +++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 scripts/mutant_sweep_demo_key.py create mode 100644 tests/test_demo_key_mutants.py diff --git a/scripts/mutant_sweep_demo_key.py b/scripts/mutant_sweep_demo_key.py new file mode 100644 index 0000000..5f8ed79 --- /dev/null +++ b/scripts/mutant_sweep_demo_key.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Re-apply survived demo-key mutants and report killed/total (REA seq 1854). + +Mutants: + 1. drop O_NOFOLLOW on read open + 2. drop O_EXCL on create + 3. drop file fsync + 4. drop directory fsync + 5. drop fchmod on create + 6. drop pre-check OSError → ValueError mapping (re-raise raw) + 7. soften symlink error message text + +Each is applied in-process via monkeypatch, then the matching killer test +runs. Exit 0 only if every mutant is killed. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +# (mutant_id, killer_test_nodeid, apply_snippet_that_patches_verifier_mod) +MUTANTS = [ + ( + "drop_O_NOFOLLOW_on_read", + "tests/test_demo_key_mutants.py::test_mutant_nofollow_symlink_swapped_after_precheck", + """ + import settlement.verifier as v + real = v._open_demo_key_readonly + def open_no_nofollow(key_path): + v._reject_foreign_demo_key_owner(key_path) + flags = v._os.O_RDONLY | getattr(v._os, "O_NONBLOCK", 0) # no NOFOLLOW + try: + return v._os.open(key_path, flags) + except FileNotFoundError: + raise + except OSError as exc: + raise v._key_oserror(key_path, exc) from None + v._open_demo_key_readonly = open_no_nofollow + """, + ), + ( + "drop_O_EXCL_on_create", + "tests/test_demo_key_mutants.py::test_mutant_o_excl_required_on_create", + """ + import settlement.verifier as v + real = v._create_demo_key_file + def create_no_excl(key_path): + key = v._secrets.token_bytes(v.MIN_ACCEPTANCE_KEY_BYTES) + key_path.parent.mkdir(parents=True, exist_ok=True) + flags = ( + v._os.O_WRONLY + | v._os.O_CREAT + # O_EXCL dropped + | getattr(v._os, "O_NOFOLLOW", 0) + ) + try: + fd = v._os.open(key_path, flags, 0o600) + except OSError as exc: + raise v._key_oserror(key_path, exc) from None + try: + try: + v._os.write(fd, key) + v._os.fchmod(fd, 0o600) + v._os.fsync(fd) + except OSError as exc: + raise v._key_oserror(key_path, exc) from None + mode = v._require_demo_key_fstat(fd, key_path) + finally: + v._os.close(fd) + v._fsync_dir(key_path.parent) + return key, mode + v._create_demo_key_file = create_no_excl + # Also patch load_or_create to use mutated create + real_load = v.load_or_create_demo_key + def load(path, *, db_nonempty=False): + key_path = v._Path(path) + try: + fd = v._open_demo_key_readonly(key_path) + except FileNotFoundError: + if db_nonempty: + raise ValueError("missing") + return create_no_excl(key_path) + try: + mode = v._require_demo_key_fstat(fd, key_path) + v._clear_nonblock(fd) + key = v._read_exact_demo_key(fd, key_path) + return key, mode + finally: + v._os.close(fd) + v.load_or_create_demo_key = load + """, + ), + ( + "drop_file_fsync", + "tests/test_demo_key_mutants.py::test_mutant_file_and_dir_fsync_order", + """ + import settlement.verifier as v + real_create = v._create_demo_key_file + def create_no_file_fsync(key_path): + key = v._secrets.token_bytes(v.MIN_ACCEPTANCE_KEY_BYTES) + key_path.parent.mkdir(parents=True, exist_ok=True) + flags = ( + v._os.O_WRONLY | v._os.O_CREAT | v._os.O_EXCL + | getattr(v._os, "O_NOFOLLOW", 0) + ) + fd = v._os.open(key_path, flags, 0o600) + try: + v._os.write(fd, key) + v._os.fchmod(fd, 0o600) + # file fsync dropped + mode = v._require_demo_key_fstat(fd, key_path) + finally: + v._os.close(fd) + v._fsync_dir(key_path.parent) + return key, mode + v._create_demo_key_file = create_no_file_fsync + real_load = v.load_or_create_demo_key + def load(path, *, db_nonempty=False): + key_path = v._Path(path) + try: + fd = v._open_demo_key_readonly(key_path) + except FileNotFoundError: + if db_nonempty: + raise ValueError("missing") + return create_no_file_fsync(key_path) + try: + mode = v._require_demo_key_fstat(fd, key_path) + v._clear_nonblock(fd) + return v._read_exact_demo_key(fd, key_path), mode + finally: + v._os.close(fd) + v.load_or_create_demo_key = load + """, + ), + ( + "drop_dir_fsync", + "tests/test_demo_key_mutants.py::test_mutant_file_and_dir_fsync_order", + """ + import settlement.verifier as v + v._fsync_dir = lambda dir_path: None + """, + ), + ( + "drop_fchmod", + "tests/test_demo_key_mutants.py::test_mutant_fchmod_0600_on_create", + """ + import settlement.verifier as v + def create_no_fchmod(key_path): + key = v._secrets.token_bytes(v.MIN_ACCEPTANCE_KEY_BYTES) + key_path.parent.mkdir(parents=True, exist_ok=True) + flags = ( + v._os.O_WRONLY | v._os.O_CREAT | v._os.O_EXCL + | getattr(v._os, "O_NOFOLLOW", 0) + ) + fd = v._os.open(key_path, flags, 0o600) + try: + v._os.write(fd, key) + # fchmod dropped + v._os.fsync(fd) + mode = v._require_demo_key_fstat(fd, key_path) + finally: + v._os.close(fd) + v._fsync_dir(key_path.parent) + return key, mode + v._create_demo_key_file = create_no_fchmod + def load(path, *, db_nonempty=False): + key_path = v._Path(path) + try: + fd = v._open_demo_key_readonly(key_path) + except FileNotFoundError: + if db_nonempty: + raise ValueError("missing") + return create_no_fchmod(key_path) + try: + mode = v._require_demo_key_fstat(fd, key_path) + v._clear_nonblock(fd) + return v._read_exact_demo_key(fd, key_path), mode + finally: + v._os.close(fd) + v.load_or_create_demo_key = load + """, + ), + ( + "drop_precheck_oserror_map", + "tests/test_demo_key_mutants.py::test_mutant_precheck_oserror_mapped_clean", + """ + import settlement.verifier as v + def reject_raw(key_path): + st = v._os.lstat(key_path) # raw OSError propagates + if v._stat.S_ISLNK(st.st_mode): + raise ValueError( + f"demo acceptance key at {key_path} must not be a symlink" + ) + euid = v._os.geteuid() + if st.st_uid != euid: + raise ValueError( + f"demo acceptance key at {key_path} must be owned by euid " + f"{euid} (got uid {st.st_uid})" + ) + v._reject_foreign_demo_key_owner = reject_raw + """, + ), + ( + "soften_symlink_message", + "tests/test_demo_key_mutants.py::test_mutant_symlink_error_message_text", + """ + import settlement.verifier as v + def reject_soft(key_path): + try: + st = v._os.lstat(key_path) + except FileNotFoundError: + raise + except OSError as exc: + raise v._key_oserror(key_path, exc) from None + if v._stat.S_ISLNK(st.st_mode): + raise ValueError(f"bad link at {key_path}") # softened + euid = v._os.geteuid() + if st.st_uid != euid: + raise ValueError( + f"demo acceptance key at {key_path} must be owned by euid " + f"{euid} (got uid {st.st_uid})" + ) + v._reject_foreign_demo_key_owner = reject_soft + """, + ), +] + + +def _run_mutant(mutant_id: str, nodeid: str, apply_src: str) -> bool: + """Return True if the killer test FAILS under the mutant (mutant killed).""" + body = textwrap.dedent( + f""" + import sys + sys.path.insert(0, {str(ROOT)!r}) + {textwrap.dedent(apply_src)} + import pytest + raise SystemExit(pytest.main(["-q", "--tb=no", {nodeid!r}])) + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh: + fh.write(body) + path = fh.name + try: + proc = subprocess.run( + [sys.executable, path], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, + ) + finally: + os.unlink(path) + # Killer test should FAIL under the mutant → non-zero pytest exit. + killed = proc.returncode != 0 + status = "KILLED" if killed else "SURVIVED" + print(f"{status:9} {mutant_id} (pytest rc={proc.returncode})") + if not killed: + print(proc.stdout[-500:] if proc.stdout else "") + print(proc.stderr[-500:] if proc.stderr else "") + return killed + + +def main() -> int: + killed = 0 + total = len(MUTANTS) + for mid, nodeid, apply_src in MUTANTS: + if _run_mutant(mid, nodeid, apply_src): + killed += 1 + print(f"MUTANT SWEEP: {killed}/{total} killed") + return 0 if killed == total else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_demo_key_mutants.py b/tests/test_demo_key_mutants.py new file mode 100644 index 0000000..e99caf5 --- /dev/null +++ b/tests/test_demo_key_mutants.py @@ -0,0 +1,138 @@ +"""REA seq 1854: kill survived mutants on durable demo-key hardening. + +Each test fails if the named defence is dropped. A companion sweep script +(see test_mutant_sweep_report / scripts) re-applies the mutants and counts +kills. +""" + +from __future__ import annotations + +import errno +import os +import stat +from pathlib import Path +from unittest import mock + +import pytest + +import settlement.verifier as verifier_mod +from settlement.verifier import load_or_create_demo_key + +MIN = 32 + + +def test_mutant_nofollow_symlink_swapped_after_precheck(monkeypatch, tmp_path): + """Dropping O_NOFOLLOW on the read: symlink swapped in after pre-check.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "key.acceptance_key" + target = tmp_path / "target.key" + target.write_bytes(os.urandom(MIN)) + os.chmod(target, 0o600) + path.write_bytes(os.urandom(MIN)) + os.chmod(path, 0o600) + + real_reject = verifier_mod._reject_foreign_demo_key_owner + + def swap_after_precheck(key_path): + real_reject(key_path) + # TOCTOU: replace the regular file with a symlink after lstat/owner. + os.unlink(key_path) + os.symlink(target, key_path) + + monkeypatch.setattr( + verifier_mod, "_reject_foreign_demo_key_owner", swap_after_precheck + ) + with pytest.raises(ValueError, match="must not be a symlink"): + load_or_create_demo_key(path) + + +def test_mutant_o_excl_required_on_create(monkeypatch, tmp_path): + """Dropping O_EXCL on create: spy asserts O_EXCL is passed to os.open.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "new.acceptance_key" + flags_seen: list[int] = [] + real_open = verifier_mod._os.open + + def spy_open(p, flags, mode=0o777): + flags_seen.append(flags) + return real_open(p, flags, mode) + + monkeypatch.setattr(verifier_mod._os, "open", spy_open) + load_or_create_demo_key(path) + assert flags_seen, "expected at least one os.open during create" + assert any(f & os.O_EXCL for f in flags_seen), ( + f"O_EXCL missing from create opens: {flags_seen!r}" + ) + assert any(f & os.O_CREAT for f in flags_seen) + + +def test_mutant_file_and_dir_fsync_order(monkeypatch, tmp_path): + """Dropping file/dir fsync: spy asserts file fsync then directory fsync.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "sync.acceptance_key" + events: list[str] = [] + real_fsync = verifier_mod._os.fsync + real_fsync_dir = verifier_mod._fsync_dir + + def spy_fsync(fd): + events.append("file_fsync") + return real_fsync(fd) + + def spy_fsync_dir(dir_path): + events.append("dir_fsync") + return real_fsync_dir(dir_path) + + monkeypatch.setattr(verifier_mod._os, "fsync", spy_fsync) + monkeypatch.setattr(verifier_mod, "_fsync_dir", spy_fsync_dir) + load_or_create_demo_key(path) + assert "file_fsync" in events, events + assert "dir_fsync" in events, events + assert events.index("file_fsync") < events.index("dir_fsync"), events + + +def test_mutant_fchmod_0600_on_create(monkeypatch, tmp_path): + """Dropping chmod/fchmod: umask would leave non-0600 without fchmod.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "mode.acceptance_key" + old = os.umask(0o177) # open(..., 0o600) → 0o400 without fchmod + try: + key, mode = load_or_create_demo_key(path) + finally: + os.umask(old) + assert mode == 0o600 + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert len(key) == MIN + + +def test_mutant_precheck_oserror_mapped_clean(monkeypatch, tmp_path): + """Dropping pre-check error mapping: lstat OSError must become ValueError.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + path = tmp_path / "io.acceptance_key" + path.write_bytes(os.urandom(MIN)) + os.chmod(path, 0o600) + + def boom(_p): + raise OSError(errno.EIO, "Input/output error") + + monkeypatch.setattr(verifier_mod._os, "lstat", boom) + with pytest.raises(ValueError, match="cannot be opened") as excinfo: + load_or_create_demo_key(path) + assert "PermissionError" not in type(excinfo.value).__name__ + assert not isinstance(excinfo.value, OSError) or isinstance( + excinfo.value, ValueError + ) + + +def test_mutant_symlink_error_message_text(monkeypatch, tmp_path): + """Symlink refusal must use the exact message text.""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + real = tmp_path / "real.key" + link = tmp_path / "link.acceptance_key" + real.write_bytes(os.urandom(MIN)) + os.chmod(real, 0o600) + link.symlink_to(real) + with pytest.raises(ValueError, match="must not be a symlink") as excinfo: + load_or_create_demo_key(link) + assert str(excinfo.value) == ( + f"demo acceptance key at {link} must not be a symlink" + ) From 7070b635b6d7fe7b8d3dbebb017b468611ab65b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 18:33:20 +0000 Subject: [PATCH 11/22] ci: add -rs so pytest prints skip reasons One-flag change on matrix and -O jobs (Marc reviews at merge). Co-authored-by: Marcus Richards --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d85c576..3efbb72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,11 +33,11 @@ jobs: run: python -m pip install --upgrade pip "pytest>=8" "pytest-cov>=5" - name: tests if: matrix.python-version != '3.12' - run: python -m pytest -q + run: python -m pytest -q -rs - name: tests with coverage if: matrix.python-version == '3.12' run: > - python -m pytest -q + python -m pytest -q -rs --cov=permit --cov=settlement --cov-report=xml:coverage.xml --cov-report=term-missing:skip-covered @@ -67,7 +67,7 @@ jobs: run: python -m pip install --upgrade pip "pytest>=8" - name: tests under -O # Asserts are stripped under -O; suite must still pass (trust-boundary asserts are a known debt). - run: python -O -m pytest -q + run: python -O -m pytest -q -rs lint: name: ruff From 19b62ce16dc3bceb6c527676528d0370800841e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 18:34:52 +0000 Subject: [PATCH 12/22] ci: keep -rs on suite; print key-file PASSED lines by name Matrix/ -O stay -q -rs (skip reasons). Add a py3.12 -v step for the chmod-000 and sudo-chown foreign-owner tests so CI logs show PASSED by name. Co-authored-by: Marcus Richards --- .github/workflows/ci.yml | 8 ++++++++ README.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3efbb72..1a3b875 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,14 @@ jobs: --cov=permit --cov=settlement --cov-report=xml:coverage.xml --cov-report=term-missing:skip-covered + - name: key-file tests by name + if: matrix.python-version == '3.12' + # Named -v pass lines for REA (sudo chown foreign-owner must not skip). + run: > + python -m pytest -v -rs + tests/test_demo_key_rea_n_b.py::test_chmod_000_key_file_is_clean_valueerror + tests/test_demo_key_rea_n_b.py::test_chmod_000_server_exits_clean_no_traceback + tests/test_demo_key_rea_n_b.py::test_foreign_owner_real_chown_clean_valueerror - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: matrix.python-version == '3.12' with: diff --git a/README.md b/README.md index 0e18cac..33f88fc 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **426 tests pass, 1 skipped (427 collected):** `python -m pytest -q` and `python -O -m pytest -q` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801). CI runs the same suite plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. +- **439 tests pass, 1 skipped (440 collected):** `python -m pytest -rs -v` and `python -O -m pytest -rs -v` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854). CI runs the same suite (`-v -rs`) plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. - **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path. From 6bed5ffc35a3d09983cf184522d717ddc3b927cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 18:55:04 +0000 Subject: [PATCH 13/22] fix: durable ledger commitment, exact-32 keys, fchmod mutant (REA 1888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - umask 0o277 so dropping fchmod fails mode 0600; sweep still 7/7 - DurableLedger meta.json + requires_acceptance_key_commitment; never silently skip; harness key A→B mismatch test - Acceptance keys must be exactly 32 bytes (match key-file rule) - README: CI uses -q -rs plus -v for key-file tests only Co-authored-by: Marcus Richards --- .github/workflows/ci.yml | 2 +- README.md | 8 +-- SECURITY.md | 20 ++++-- permit/durability.py | 42 +++++++++++ permit/sqlite_ledger.py | 3 + scripts/mutant_sweep_demo_key.py | 2 +- settlement/verifier.py | 55 ++++++++++---- tests/conftest.py | 2 +- tests/test_acceptance_key.py | 20 +++++- tests/test_demo_key_mutants.py | 8 ++- tests/test_durable_key_commitment.py | 104 +++++++++++++++++++++++++++ 11 files changed, 235 insertions(+), 31 deletions(-) create mode 100644 tests/test_durable_key_commitment.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a3b875..c4ff8e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: if: matrix.python-version == '3.12' env: # Test-only key for the smoke path (#53: demo key is off by default). - PERMIT_ACCEPTANCE_KEY: permit-ci-only-acceptance-key-0123456789ab + PERMIT_ACCEPTANCE_KEY: permit-ci-only-key-32bytes!!!!!! run: python demo.py test-optimized: diff --git a/README.md b/README.md index 33f88fc..fa08b00 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ where `remaining(P) = cap(P) − reserved(P) − captured(P)`. Four clauses, no ```bash python -m pip install pytest python -m pytest -q # full suite: mock rail, no credentials, no network -export PERMIT_ACCEPTANCE_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')" +export PERMIT_ACCEPTANCE_KEY="$(python -c 'import secrets; print(secrets.token_hex(16))')" python demo.py # end-to-end mock demo, ends with the ledger chain check # Or, for a one-shot mock demo only: python demo.py --allow-demo-key ``` @@ -75,8 +75,8 @@ Permit fails closed. When state is uncertain, money does not move. **What it does not defend (production boundaries):** - With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). Whenever `PERMIT_ACCEPTANCE_KEY` is set it wins over the file key; empty, whitespace-only, and wrong-length env values still refuse startup (#53 bar 3 / REA seq 1854). The durable ledger also stores an HMAC commitment of the acceptance key on first use; **rotating the acceptance key on an existing database refuses startup (no rebind for now)**. - HTTP POSTs require a single shared bearer token (`PERMIT_API_TOKEN`, #23) with no role split between principal and agent (F5). GETs, including `/api/ledger`, are unauthenticated; the server binds to 127.0.0.1. -- Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of at least 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, under 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53). -- `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of at least 32 bytes. There is no module-level fallback key (#35/#53 API change). +- Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of exactly 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, not exactly 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53). +- `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of exactly 32 bytes. There is no module-level fallback key (#35/#53 API change). - The ledger is a hash chain, not a signature. It detects edits and reordering, but it cannot detect removal of final entries without an external checkpoint. - It is a single-merchant sandbox prototype, not a claim about all agent payments. - E-stop voids uncaptured authorizations. A completed capture needs a refund path, which is out of scope. @@ -106,7 +106,7 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **439 tests pass, 1 skipped (440 collected):** `python -m pytest -rs -v` and `python -O -m pytest -rs -v` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854). CI runs the same suite (`-v -rs`) plus `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. +- **439+ tests pass, 1 skipped:** `python -m pytest -rs` and `python -O -m pytest -rs` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854 + 1888). CI runs the suite with `-q -rs`, plus `-v` for the key-file tests only; also `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. - **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path. diff --git a/SECURITY.md b/SECURITY.md index 462c939..277efc4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,15 +36,21 @@ There is **no bug bounty**, and no monetary reward is offered or implied. This i research project maintained by one person. What it can offer is a fast, honest response and public credit. -## Acceptance key + durable `--db` ledger +## Acceptance key + durable ledgers - `PERMIT_ACCEPTANCE_KEY`, when set, is always used (including with `--db --allow-demo-key`). - Empty, whitespace-only, and under-32-byte values refuse startup; the demo flag never - papers over a bad configured key. -- With `--db`, the ledger stores an HMAC commitment of the acceptance key on first use - (`ensure_acceptance_key_commitment`, enforced in `ReleaseVerifier` for every - `SqliteLedger` and again at `server.py --db`). **Rotating the acceptance key on an - existing database refuses startup (no rebind for now).** + Empty, whitespace-only, and wrong-length values (not exactly 32 bytes after decoding) + refuse startup; the demo flag never papers over a bad configured key. The durable + key file uses the same exact-32-byte rule. +- Every durable ledger binds an HMAC commitment of the acceptance key on first use + (`ensure_acceptance_key_commitment` via `ReleaseVerifier`): + - `SqliteLedger` (server `--db`) + - `DurableLedger` (file-backed under `durable_dir`, including + `harness/durability_harness.py`) + Ledgers that require the commitment but lack `get_meta` / `set_meta` refuse startup + (no silent skip). Ephemeral in-memory `Ledger` does not store a commitment. + **Rotating the acceptance key on an existing durable store refuses startup (no + rebind for now).** ## Related diff --git a/permit/durability.py b/permit/durability.py index 59bb63d..0d26cab 100644 --- a/permit/durability.py +++ b/permit/durability.py @@ -131,9 +131,14 @@ def __init__(self, durable_dir=None): self._ledger_dev: int | None = None self._ledger_path: Path | None = None self._head_path: Path | None = None + self._meta_path: Path | None = None + # File-backed durable stores must bind the acceptance-key commitment + # (REA seq 1888). Ephemeral DurableLedger (no dir) does not. + self.requires_acceptance_key_commitment = self._durable_dir is not None if self._durable_dir is not None: self._ledger_path = self._durable_dir / "ledger.jsonl" self._head_path = self._durable_dir / "ledger.head" + self._meta_path = self._durable_dir / "meta.json" self._acquire_writer_lock() self._load() self._open_ledger_fd() @@ -212,6 +217,43 @@ def __del__(self): except Exception: pass + def _read_meta(self) -> dict: + if self._meta_path is None or not self._meta_path.exists(): + return {} + try: + raw = json.loads(self._meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise ValueError(f"corrupt durable meta.json: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("corrupt durable meta.json: not an object") + return {str(k): str(v) for k, v in raw.items()} + + def get_meta(self, key: str) -> str | None: + """Return a durable meta value, or None if unset. + + Required for acceptance-key commitment when + ``requires_acceptance_key_commitment`` is true (REA seq 1888). + """ + if self._meta_path is None: + raise ValueError( + "durable ledger cannot store acceptance key commitment " + "without durable_dir (refusing silent skip)" + ) + with self._lock: + return self._read_meta().get(key) + + def set_meta(self, key: str, value: str) -> None: + """Persist a meta value atomically under durable_dir/meta.json.""" + if self._meta_path is None: + raise ValueError( + "durable ledger cannot store acceptance key commitment " + "without durable_dir (refusing silent skip)" + ) + with self._lock: + meta = self._read_meta() + meta[str(key)] = str(value) + _atomic_write_json(self._meta_path, meta) + def _read_committed_head(self) -> dict | None: if self._head_path is None or not self._head_path.exists(): return None diff --git a/permit/sqlite_ledger.py b/permit/sqlite_ledger.py index 45fd8bb..93b18fe 100644 --- a/permit/sqlite_ledger.py +++ b/permit/sqlite_ledger.py @@ -92,6 +92,9 @@ def __init__(self, path: str) -> None: raise ValueError("path is required") self._lock = threading.Lock() self._path = path + # File-backed sqlite must bind the acceptance-key commitment; an + # in-memory db is ephemeral and does not (REA seq 1888). + self.requires_acceptance_key_commitment = path != ":memory:" # check_same_thread=False: the lock (not the connection) is the # cross-thread guard; without this the shared connection is a trap. self._conn = sqlite3.connect( diff --git a/scripts/mutant_sweep_demo_key.py b/scripts/mutant_sweep_demo_key.py index 5f8ed79..ad18ea0 100644 --- a/scripts/mutant_sweep_demo_key.py +++ b/scripts/mutant_sweep_demo_key.py @@ -162,7 +162,7 @@ def create_no_fchmod(key_path): fd = v._os.open(key_path, flags, 0o600) try: v._os.write(fd, key) - # fchmod dropped + # fchmod dropped — with umask 0o277 mode stays 0400 v._os.fsync(fd) mode = v._require_demo_key_fstat(fd, key_path) finally: diff --git a/settlement/verifier.py b/settlement/verifier.py index abaa064..cb628d2 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -104,7 +104,7 @@ class ReconcileResult: # Acceptance key (issues #35, #53): there is NO fallback key in library # code. A known fallback key lets anyone forge Type A acceptances in a # default deployment, so construction fails closed: the key must come -# from the caller or from PERMIT_ACCEPTANCE_KEY, be at least 32 bytes, +# from the caller or from PERMIT_ACCEPTANCE_KEY, be exactly 32 bytes, # and must not be whitespace-only. Demo entry points opt in explicitly # with --allow-demo-key / allow_demo_key=True (off by default). When that # flag is set and no key is configured, a fresh random key is generated; @@ -114,7 +114,8 @@ class ReconcileResult: # that IS set (env or argument) but invalid always raises — the demo flag # never papered over a bad configured key. If the durable ledger already # has entries and the key file is missing, startup refuses rather than -# silently regenerating. +# silently regenerating. Durable ledgers also bind an HMAC commitment of +# the key (SqliteLedger and DurableLedger); rotation refuses startup. import errno as _errno import os as _os import secrets as _secrets @@ -133,9 +134,10 @@ def acceptance_key_commitment(key: bytes) -> str: def ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: """Bind ``key`` to a durable ledger on first use; refuse rotation. - Every ``--db`` / SqliteLedger entry point must call this after resolving - the acceptance key. Rotating the key on an existing database refuses - startup — there is no rebind for now (REA seq 1854). + Every durable ledger entry point (SqliteLedger ``--db``, DurableLedger / + harness) must call this after resolving the acceptance key. Rotating the + key on an existing store refuses startup — there is no rebind for now + (REA seq 1854 / 1888). """ # Imported here to keep settlement.verifier free of a hard sqlite import # cycle at module load for callers that never touch durable mode. @@ -154,6 +156,27 @@ def ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: ) +def _bind_ledger_acceptance_key_commitment(ledger, key: bytes) -> None: + """Enforce commitment on durable ledgers; never skip silently (seq 1888). + + Ledgers that opt in via ``requires_acceptance_key_commitment`` must expose + ``get_meta`` / ``set_meta``. Ephemeral in-memory ``Ledger`` does not opt + in and is left unbound. + """ + requires = bool( + getattr(ledger, "requires_acceptance_key_commitment", False) + ) + has_meta = hasattr(ledger, "get_meta") and hasattr(ledger, "set_meta") + if not requires: + return + if not has_meta: + raise ValueError( + "durable ledger cannot store acceptance key commitment " + "(get_meta/set_meta required; refusing silent skip)" + ) + ensure_acceptance_key_commitment(ledger, key) + + def _coerce_key(key: bytes | str | None) -> bytes | None: if key is None: return None @@ -161,14 +184,14 @@ def _coerce_key(key: bytes | str | None) -> bytes | None: def _require_valid_key(resolved: bytes, *, what: str = "acceptance key") -> bytes: - """Reject empty, whitespace-only, and short keys.""" + """Reject empty, whitespace-only, and wrong-length keys (exactly 32 bytes).""" if resolved.strip() == b"": if len(resolved) == 0: raise ValueError(f"{what} is empty") raise ValueError(f"{what} is whitespace-only") - if len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: + if len(resolved) != MIN_ACCEPTANCE_KEY_BYTES: raise ValueError( - f"{what} under {MIN_ACCEPTANCE_KEY_BYTES} bytes " + f"{what} must be exactly {MIN_ACCEPTANCE_KEY_BYTES} bytes " f"(got {len(resolved)})" ) return resolved @@ -419,7 +442,7 @@ def resolve_acceptance_key( return _secrets.token_bytes(MIN_ACCEPTANCE_KEY_BYTES) raise ValueError( - "acceptance key missing, empty, whitespace-only, or under " + "acceptance key missing, empty, whitespace-only, or not exactly " f"{MIN_ACCEPTANCE_KEY_BYTES} bytes: pass acceptance_key or set " "PERMIT_ACCEPTANCE_KEY (fail closed, issues #35/#53)" ) @@ -431,11 +454,13 @@ def sign_acceptance( """What the counterparty (acceptance key holder) does off-camera. The key is required: there is no module-level key to fall back to. - Empty, whitespace-only, and short keys are rejected (#53). + Empty, whitespace-only, and wrong-length keys are rejected (#53 / seq 1888). """ resolved = _coerce_key(key) if resolved is None: - raise ValueError("sign_acceptance requires a key of at least 32 bytes") + raise ValueError( + "sign_acceptance requires a key of exactly 32 bytes" + ) resolved = _require_valid_key(resolved, what="sign_acceptance key") msg = f"{escrow_id}|{artifact_hash}|{amount_cents}".encode() return hmac.new(resolved, msg, hashlib.sha256).hexdigest() @@ -469,9 +494,11 @@ def __init__( self.permits = permits # NOTE: explicit None check — an empty Ledger is falsy via __len__. self.ledger = ledger if ledger is not None else Ledger() - # Durable SqliteLedger (--db) entry point: bind/refuse key commitment. - if hasattr(self.ledger, "get_meta") and hasattr(self.ledger, "set_meta"): - ensure_acceptance_key_commitment(self.ledger, self._acceptance_key) + # Durable ledgers (SqliteLedger --db, DurableLedger / harness) must + # bind/refuse the acceptance-key commitment; never skip silently. + _bind_ledger_acceptance_key_commitment( + self.ledger, self._acceptance_key + ) self._escrows: dict[str, Escrow] = {} self._lock = threading.Lock() diff --git a/tests/conftest.py b/tests/conftest.py index 4e82055..8119778 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ # Test-only acceptance key for the mock-rail suite (issue #35: the library # has no fallback key, so tests configure one explicitly via env). os.environ.setdefault( - "PERMIT_ACCEPTANCE_KEY", "permit-test-only-acceptance-key-0123456789" + "PERMIT_ACCEPTANCE_KEY", "permit-test-only-key-32bytes!!!!" # exactly 32 ) _SANDBOX_CREDS = ( diff --git a/tests/test_acceptance_key.py b/tests/test_acceptance_key.py index 513209e..fcec9a3 100644 --- a/tests/test_acceptance_key.py +++ b/tests/test_acceptance_key.py @@ -101,7 +101,25 @@ def test_env_key_and_explicit_key_construct(monkeypatch): _rig() monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY") _rig(acceptance_key=GOOD_KEY) - _rig(acceptance_key="s" * 40) # str keys are encoded + _rig(acceptance_key="s" * 32) # str keys are encoded; must be exactly 32 + + +def test_long_key_33_and_64_refused(monkeypatch): + """Keys must be exactly 32 bytes after decoding (same as the key file).""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + for bad in (b"x" * 33, b"y" * 64, "z" * 33, "w" * 64): + with pytest.raises(ValueError, match="exactly 32 bytes"): + _rig(acceptance_key=bad) + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", "e" * 33) + with pytest.raises(ValueError, match="exactly 32 bytes"): + _rig() + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", "e" * 64) + with pytest.raises(ValueError, match="exactly 32 bytes"): + _rig() + with pytest.raises(ValueError, match="exactly 32 bytes"): + sign_acceptance("esc", "hash", 100, b"L" * 33) + with pytest.raises(ValueError, match="exactly 32 bytes"): + sign_acceptance("esc", "hash", 100, b"L" * 64) def test_demo_opt_in_generates_per_instance_key(monkeypatch): diff --git a/tests/test_demo_key_mutants.py b/tests/test_demo_key_mutants.py index e99caf5..6f108fc 100644 --- a/tests/test_demo_key_mutants.py +++ b/tests/test_demo_key_mutants.py @@ -91,10 +91,14 @@ def spy_fsync_dir(dir_path): def test_mutant_fchmod_0600_on_create(monkeypatch, tmp_path): - """Dropping chmod/fchmod: umask would leave non-0600 without fchmod.""" + """Dropping fchmod: umask 0o277 yields 0400 from open(..., 0o600). + + umask 0o177 does *not* change 0600 (0600 & ~0177 == 0600), so it can + never kill an fchmod-drop mutant. 0o277 yields 0400 without fchmod. + """ monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) path = tmp_path / "mode.acceptance_key" - old = os.umask(0o177) # open(..., 0o600) → 0o400 without fchmod + old = os.umask(0o277) # open(..., 0o600) → 0o400 without fchmod try: key, mode = load_or_create_demo_key(path) finally: diff --git a/tests/test_durable_key_commitment.py b/tests/test_durable_key_commitment.py new file mode 100644 index 0000000..4483499 --- /dev/null +++ b/tests/test_durable_key_commitment.py @@ -0,0 +1,104 @@ +"""REA seq 1888: DurableLedger / harness must enforce acceptance-key commitment. + +On 19b62ce, DurableLedger lacked get_meta/set_meta so ReleaseVerifier +silently skipped the check. Reopening with key B must refuse. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from harness.durability_harness import _build, _close_ledger +from permit.durability import DurableLedger +from permit.ledger import Ledger +from permit.sqlite_ledger import SqliteLedger +from settlement.verifier import ReleaseVerifier, acceptance_key_commitment + +REFUSE = "rotating the acceptance key on an existing database refuses startup" +MIN = 32 + + +def test_durable_ledger_harness_key_rotation_refuses(tmp_path, monkeypatch): + """Build with key A via harness path; reopen with key B → refusal. + + Imports stay compatible with 19b62ce so collection succeeds and the + assertion fails there (silent skip / no meta). + """ + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + durable = tmp_path / "dur" + key_a = b"A" * MIN + key_b = b"B" * MIN + assert key_a != key_b + + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", key_a.decode()) + ledger, permits, paypal, verifier, flow = _build( + durable, allow_demo_key=False + ) + assert verifier._acceptance_key == key_a + # On head, commitment is stored; on 19b62ce get_meta is absent → AttributeError + # (fails the test). Prefer an explicit mismatch on reopen either way. + get_meta = getattr(ledger, "get_meta", None) + if callable(get_meta): + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + + assert get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( + acceptance_key_commitment(key_a) + ) + else: + pytest.fail( + "DurableLedger missing get_meta — commitment was silently skipped" + ) + _close_ledger(ledger) + + monkeypatch.setenv("PERMIT_ACCEPTANCE_KEY", key_b.decode()) + with pytest.raises(ValueError, match=REFUSE): + _build(durable, allow_demo_key=False) + + +def test_durable_ledger_direct_mismatch(tmp_path, monkeypatch): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + from permit.permit import PermitStore + from settlement.paypal_client import MockPayPalClient + + durable = tmp_path / "dur2" + key_a = b"C" * MIN + key_b = b"D" * MIN + lg = DurableLedger(durable_dir=durable) + assert getattr(lg, "requires_acceptance_key_commitment", False) is True + assert hasattr(lg, "get_meta") and hasattr(lg, "set_meta") + paypal = MockPayPalClient() + permits = PermitStore(ledger=Ledger()) + ReleaseVerifier(paypal, permits, ledger=lg, acceptance_key=key_a) + lg.close() + + lg2 = DurableLedger(durable_dir=durable) + with pytest.raises(ValueError, match=REFUSE): + ReleaseVerifier( + paypal, permits, ledger=lg2, acceptance_key=key_b + ) + lg2.close() + + +def test_no_ledger_type_silently_skips_commitment(): + """Every durable ledger type either enforces or raises — never skips.""" + from settlement.verifier import _bind_ledger_acceptance_key_commitment + + # In-memory Ledger: no commitment required. + mem = Ledger() + assert not getattr(mem, "requires_acceptance_key_commitment", False) + + # Sqlite file-backed + DurableLedger: meta API present. + assert hasattr(DurableLedger, "get_meta") + assert hasattr(DurableLedger, "set_meta") + assert hasattr(SqliteLedger, "get_meta") + assert hasattr(SqliteLedger, "set_meta") + + # A durable ledger that opts in but lacks meta must raise (no silent skip). + class _BrokenDurable: + requires_acceptance_key_commitment = True + + with pytest.raises(ValueError, match="refusing silent skip"): + _bind_ledger_acceptance_key_commitment(_BrokenDurable(), b"k" * MIN) From 92c5fd65bd3edef5af00d884fb428fe0a8db3eba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:21:50 +0000 Subject: [PATCH 14/22] fix: fail-closed acceptance-key commitment (REA r5 1-4) Refuse silent rebind on nonempty stores without a commitment; require commitment unless the ledger is explicitly ephemeral; bind atomically via try_bind_meta (INSERT OR IGNORE + re-read/compare); refuse corrupt meta.json instead of treating it as empty. Add one-time rebind_acceptance_key_commitment for pre-exact-32 migrations. Co-authored-by: Marcus Richards --- permit/durability.py | 52 ++++++++++++++++++---- permit/ledger.py | 4 ++ permit/sqlite_ledger.py | 30 +++++++++++-- settlement/verifier.py | 99 ++++++++++++++++++++++++++++++++++------- 4 files changed, 157 insertions(+), 28 deletions(-) diff --git a/permit/durability.py b/permit/durability.py index 0d26cab..66aa3a3 100644 --- a/permit/durability.py +++ b/permit/durability.py @@ -132,9 +132,6 @@ def __init__(self, durable_dir=None): self._ledger_path: Path | None = None self._head_path: Path | None = None self._meta_path: Path | None = None - # File-backed durable stores must bind the acceptance-key commitment - # (REA seq 1888). Ephemeral DurableLedger (no dir) does not. - self.requires_acceptance_key_commitment = self._durable_dir is not None if self._durable_dir is not None: self._ledger_path = self._durable_dir / "ledger.jsonl" self._head_path = self._durable_dir / "ledger.head" @@ -217,12 +214,30 @@ def __del__(self): except Exception: pass + @property + def ephemeral_acceptance_key_commitment(self) -> bool: + """Only a DurableLedger without durable_dir is ephemeral.""" + return self._durable_dir is None + + @property + def requires_acceptance_key_commitment(self) -> bool: + return not self.ephemeral_acceptance_key_commitment + def _read_meta(self) -> dict: - if self._meta_path is None or not self._meta_path.exists(): + """Load meta.json. Missing file → {}; corrupt file → ValueError (never empty).""" + if self._meta_path is None: + return {} + if not self._meta_path.exists(): return {} try: - raw = json.loads(self._meta_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError) as exc: + text = self._meta_path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"corrupt durable meta.json: {exc}") from exc + if not text.strip(): + raise ValueError("corrupt durable meta.json: empty file") + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: raise ValueError(f"corrupt durable meta.json: {exc}") from exc if not isinstance(raw, dict): raise ValueError("corrupt durable meta.json: not an object") @@ -231,8 +246,8 @@ def _read_meta(self) -> dict: def get_meta(self, key: str) -> str | None: """Return a durable meta value, or None if unset. - Required for acceptance-key commitment when - ``requires_acceptance_key_commitment`` is true (REA seq 1888). + Required for acceptance-key commitment when the ledger is not + ephemeral (REA seq 1888 / round 5). """ if self._meta_path is None: raise ValueError( @@ -254,6 +269,23 @@ def set_meta(self, key: str, value: str) -> None: meta[str(key)] = str(value) _atomic_write_json(self._meta_path, meta) + def try_bind_meta(self, key: str, value: str) -> str: + """Bind ``key`` only if absent; re-read and return the stored value.""" + if self._meta_path is None: + raise ValueError( + "durable ledger cannot store acceptance key commitment " + "without durable_dir (refusing silent skip)" + ) + with self._lock: + meta = self._read_meta() + if key not in meta: + meta[str(key)] = str(value) + _atomic_write_json(self._meta_path, meta) + meta = self._read_meta() + if key not in meta: + raise RuntimeError(f"meta bind failed for key {key!r}") + return str(meta[key]) + def _read_committed_head(self) -> dict | None: if self._head_path is None or not self._head_path.exists(): return None @@ -321,6 +353,10 @@ def _load(self) -> None: if not ok: raise ValueError(f"ledger chain broken on load: {reason}") + # Fail closed on corrupt meta.json at open (never treat as empty). + if self._meta_path is not None and self._meta_path.exists(): + self._read_meta() + # Advance or create the committed head to match the loaded tip. if committed is None or int(committed.get("len", -1)) < tip["len"]: self._write_committed_head(tip) diff --git a/permit/ledger.py b/permit/ledger.py index 6da3d9d..b463fe6 100644 --- a/permit/ledger.py +++ b/permit/ledger.py @@ -58,6 +58,10 @@ def __post_init__(self): class Ledger: """Thread-safe append-only receipt log with chain verification.""" + # In-memory only: ReleaseVerifier skips acceptance-key commitment. + # Durable ledgers must leave this False (fail-closed default). + ephemeral_acceptance_key_commitment = True + def __init__(self): self._lock = threading.Lock() self._receipts: list[Receipt] = [] diff --git a/permit/sqlite_ledger.py b/permit/sqlite_ledger.py index 93b18fe..6fe0eb4 100644 --- a/permit/sqlite_ledger.py +++ b/permit/sqlite_ledger.py @@ -92,9 +92,6 @@ def __init__(self, path: str) -> None: raise ValueError("path is required") self._lock = threading.Lock() self._path = path - # File-backed sqlite must bind the acceptance-key commitment; an - # in-memory db is ephemeral and does not (REA seq 1888). - self.requires_acceptance_key_commitment = path != ":memory:" # check_same_thread=False: the lock (not the connection) is the # cross-thread guard; without this the shared connection is a trap. self._conn = sqlite3.connect( @@ -279,6 +276,15 @@ def verify_chain(self) -> tuple[bool, str]: receipts = list(self._receipts) return self._verify_receipts(receipts) + @property + def ephemeral_acceptance_key_commitment(self) -> bool: + """Only ``:memory:`` is ephemeral; file-backed cannot opt out (seq 1888).""" + return self._path == ":memory:" + + @property + def requires_acceptance_key_commitment(self) -> bool: + return not self.ephemeral_acceptance_key_commitment + def get_meta(self, key: str) -> str | None: """Return a meta value, or None if unset.""" with self._lock: @@ -296,6 +302,24 @@ def set_meta(self, key: str, value: str) -> None: (key, value), ) + def try_bind_meta(self, key: str, value: str) -> str: + """Atomically bind ``key`` on first write (INSERT OR IGNORE), then re-read. + + Concurrent first-boot: the winner's value sticks; losers re-read and + compare (REA seq round 5). + """ + with self._lock: + self._conn.execute( + "INSERT OR IGNORE INTO meta(key, value) VALUES(?, ?)", + (key, value), + ) + row = self._conn.execute( + "SELECT value FROM meta WHERE key = ?", (key,) + ).fetchone() + if row is None: + raise RuntimeError(f"meta bind failed for key {key!r}") + return str(row[0]) + def close(self) -> None: """Close the database connection. The instance must not be used after.""" with self._lock: diff --git a/settlement/verifier.py b/settlement/verifier.py index cb628d2..34c76ec 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -131,13 +131,26 @@ def acceptance_key_commitment(key: bytes) -> str: return hmac.new(key, ACCEPTANCE_KEY_COMMITMENT_MSG, hashlib.sha256).hexdigest() +_SILENT_REBIND_MSG = ( + "acceptance key commitment missing on nonempty durable ledger: " + "refusing silent rebind (use rebind_acceptance_key_commitment " + "with the old key to migrate)" +) +_ROTATION_REFUSE_MSG = ( + "acceptance key commitment mismatch with durable ledger: " + "rotating the acceptance key on an existing database refuses " + "startup (use rebind_acceptance_key_commitment with the old key)" +) + + def ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: - """Bind ``key`` to a durable ledger on first use; refuse rotation. + """Bind ``key`` to a durable ledger on first use; refuse silent rebind. - Every durable ledger entry point (SqliteLedger ``--db``, DurableLedger / - harness) must call this after resolving the acceptance key. Rotating the - key on an existing store refuses startup — there is no rebind for now - (REA seq 1854 / 1888). + Only an *empty* store may bind a key for the first time. A nonempty store + without a commitment refuses startup (no silent rebind). Concurrent + first-boot uses ``try_bind_meta`` (INSERT OR IGNORE + re-read/compare) + when available. Rotating the key without ``rebind_acceptance_key_commitment`` + refuses startup (REA seq 1854 / 1888 / round 5). """ # Imported here to keep settlement.verifier free of a hard sqlite import # cycle at module load for callers that never touch durable mode. @@ -146,29 +159,81 @@ def ensure_acceptance_key_commitment(db_ledger, key: bytes) -> None: digest = acceptance_key_commitment(key) stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) if stored is None: - db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + if len(db_ledger) > 0: + raise ValueError(_SILENT_REBIND_MSG) + try_bind = getattr(db_ledger, "try_bind_meta", None) + if callable(try_bind): + bound = try_bind(ACCEPTANCE_KEY_COMMITMENT_META, digest) + else: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + bound = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if bound is None or not hmac.compare_digest(str(bound), digest): + raise ValueError(_ROTATION_REFUSE_MSG) return if not hmac.compare_digest(stored, digest): + raise ValueError(_ROTATION_REFUSE_MSG) + + +def rebind_acceptance_key_commitment( + db_ledger, + old_key: bytes | str, + new_key: bytes | str, +) -> None: + """One-time migration: verify ``old_key``, then bind exact-32 ``new_key``. + + Pre-exact-32 databases (e.g. bound at 19b62ce with ``token_hex(32)`` / + 64 UTF-8 bytes) are locked out after the exact-32 upgrade. Operators pass + the OLD key (any non-empty length) plus a new exact-32-byte key. The OLD + key is checked against the stored commitment; on match the commitment is + replaced atomically via ``set_meta``. + """ + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + + old = _coerce_key(old_key) + if old is None or old.strip() == b"": + raise ValueError("old acceptance key is empty or whitespace-only") + new = _coerce_key(new_key) + if new is None: + raise ValueError("new acceptance key is empty") + new = _require_valid_key(new, what="new acceptance key") + + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + raise ValueError( + "no acceptance key commitment to rebind on this durable ledger" + ) + if not hmac.compare_digest(stored, acceptance_key_commitment(old)): raise ValueError( - "acceptance key commitment mismatch with durable ledger: " - "rotating the acceptance key on an existing database refuses " - "startup (no rebind for now)" + "old acceptance key does not match stored commitment; " + "refusing rebind" ) + new_digest = acceptance_key_commitment(new) + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, new_digest) + bound = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if bound is None or not hmac.compare_digest(str(bound), new_digest): + raise RuntimeError("acceptance key rebind failed to persist") def _bind_ledger_acceptance_key_commitment(ledger, key: bytes) -> None: - """Enforce commitment on durable ledgers; never skip silently (seq 1888). + """Enforce commitment unless the ledger is explicitly ephemeral (round 5). - Ledgers that opt in via ``requires_acceptance_key_commitment`` must expose - ``get_meta`` / ``set_meta``. Ephemeral in-memory ``Ledger`` does not opt - in and is left unbound. + Fail-closed default: commitment is *required*. Only ledgers marked + ``ephemeral_acceptance_key_commitment`` (in-memory ``Ledger``, + ``:memory:`` SqliteLedger, DurableLedger without ``durable_dir``) skip. + File-backed SqliteLedger cannot opt out (property with no setter). """ - requires = bool( - getattr(ledger, "requires_acceptance_key_commitment", False) + ephemeral_prop = getattr( + type(ledger), "ephemeral_acceptance_key_commitment", None ) - has_meta = hasattr(ledger, "get_meta") and hasattr(ledger, "set_meta") - if not requires: + if isinstance(ephemeral_prop, property) and ephemeral_prop.fget is not None: + ephemeral = bool(ephemeral_prop.fget(ledger)) + else: + ephemeral = bool( + getattr(ledger, "ephemeral_acceptance_key_commitment", False) + ) + if ephemeral: return + has_meta = hasattr(ledger, "get_meta") and hasattr(ledger, "set_meta") if not has_meta: raise ValueError( "durable ledger cannot store acceptance key commitment " From 312e89b545d5a00d1e348d7cf592f222ba42b149 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:21:50 +0000 Subject: [PATCH 15/22] fix: persist harness demo key for K1 reopen (REA r5 N-h) With --allow-demo-key and no env key, durability_harness stores the demo key under durable_dir/acceptance_key (0600) so kill/reopen survives. Co-authored-by: Marcus Richards --- harness/durability_harness.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/harness/durability_harness.py b/harness/durability_harness.py index 73660b9..889426a 100644 --- a/harness/durability_harness.py +++ b/harness/durability_harness.py @@ -39,12 +39,18 @@ def _build(durable_dir: Path, *, allow_demo_key: bool = False): ledger = DurableLedger(durable_dir=durable_dir) permits = DurablePermitStore(ledger=ledger, durable_dir=durable_dir) paypal = DurableMockPayPalClient(durable_dir=durable_dir) + # r2 N-h / REA round 5: with --allow-demo-key and no env key, persist the + # demo key under the harness state dir (mode 0600) so kill/reopen survives. + key_kwargs: dict = {"allow_demo_key": allow_demo_key} + if allow_demo_key: + key_kwargs["demo_key_path"] = str(durable_dir / "acceptance_key") + key_kwargs["demo_key_db_nonempty"] = len(ledger) > 0 verifier = DurableReleaseVerifier( paypal, permits, ledger=ledger, durable_dir=durable_dir, - allow_demo_key=allow_demo_key, + **key_kwargs, ) flow = DurableSpendPipeline(permits, paypal, verifier, ledger=ledger, durable_dir=durable_dir) return ledger, permits, paypal, verifier, flow @@ -117,12 +123,16 @@ def child_main(q, kill_point_inner, durable_dir_str, allow_demo_key_inner): ledger_c = DurableLedger(durable_dir=d) permits_c = DurablePermitStore(ledger=ledger_c, durable_dir=d) paypal_c = DurableMockPayPalClient(durable_dir=d) + child_key_kwargs = {"allow_demo_key": allow_demo_key_inner} + if allow_demo_key_inner: + child_key_kwargs["demo_key_path"] = str(d / "acceptance_key") + child_key_kwargs["demo_key_db_nonempty"] = len(ledger_c) > 0 verifier_c = DurableReleaseVerifier( paypal_c, permits_c, ledger=ledger_c, durable_dir=d, - allow_demo_key=allow_demo_key_inner, + **child_key_kwargs, ) flow_c = DurableSpendPipeline(permits_c, paypal_c, verifier_c, ledger=ledger_c, durable_dir=d) permit_c = permits_c.get(permit_id) From a898740d3eab06e349e54962d876b84cf05411bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:21:50 +0000 Subject: [PATCH 16/22] docs: acceptance-key rebind upgrade path and UTF-8 wording Document one-time rebind for pre-exact-32 keys; SECURITY.md now states keys are UTF-8 encoded bytes (nothing is decoded). Add scripts.rebind_acceptance_key CLI. Co-authored-by: Marcus Richards --- README.md | 2 +- SECURITY.md | 42 ++++++++++-- scripts/__init__.py | 1 + scripts/rebind_acceptance_key.py | 113 +++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 scripts/rebind_acceptance_key.py diff --git a/README.md b/README.md index fa08b00..df97c66 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Permit fails closed. When state is uncertain, money does not move. | Tampered delivery evidence | Release refused, hold voided | **What it does not defend (production boundaries):** -- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). Whenever `PERMIT_ACCEPTANCE_KEY` is set it wins over the file key; empty, whitespace-only, and wrong-length env values still refuse startup (#53 bar 3 / REA seq 1854). The durable ledger also stores an HMAC commitment of the acceptance key on first use; **rotating the acceptance key on an existing database refuses startup (no rebind for now)**. +- With `--db PATH`, the SQLite ledger is durable (#24): survives crashes, restarts and a second writer on one machine; after some crashes an operator step is needed (rate unmeasured). Durable mode restores permits and reservations from receipts, but does not restore escrows, pending ops, or approvals (F3). Default (in-memory) mode still loses permits, escrows, revocations, and the ledger on restart. If you also pass `--allow-demo-key` (and leave `PERMIT_ACCEPTANCE_KEY` unset), the demo key is persisted next to the ledger as `.acceptance_key`: a regular file owned by the server euid, mode exactly `0600`, opened with `O_NOFOLLOW`, exactly 32 bytes with EOF, created via `O_CREAT|O_EXCL|O_NOFOLLOW` and fsynced (file + directory). If the ledger already has entries and the key file is missing, startup refuses rather than regenerating (#53 / REA seq 1690). Whenever `PERMIT_ACCEPTANCE_KEY` is set it wins over the file key; empty, whitespace-only, and wrong-length env values still refuse startup (#53 bar 3 / REA seq 1854). The durable ledger also stores an HMAC commitment of the acceptance key on first use (required unless the ledger is explicitly ephemeral). Only an empty store may first-bind; a nonempty store without a commitment refuses startup. **Rotating the acceptance key refuses startup** — use the one-time rebind to migrate (see SECURITY.md / `python -m scripts.rebind_acceptance_key`). Keys are UTF-8 encoded bytes; nothing is decoded. - HTTP POSTs require a single shared bearer token (`PERMIT_API_TOKEN`, #23) with no role split between principal and agent (F5). GETs, including `/api/ledger`, are unauthenticated; the server binds to 127.0.0.1. - Predicate A acceptance fails closed: the verifier refuses to construct without a configured acceptance key of exactly 32 non-whitespace bytes (`PERMIT_ACCEPTANCE_KEY`). Whitespace-only keys are rejected. A key that is set but invalid (missing content, not exactly 32 bytes, or whitespace-only) raises even when `--allow-demo-key` is on. Demo entry points (`demo.py`, `demos/*`, `python -m permit`, `trace.py`, `server.py` mock mode) require that flag to generate a key; it is off by default (#35, #53). - `sign_acceptance(escrow_id, artifact_hash, amount_cents, key)` requires an explicit key argument of exactly 32 bytes. There is no module-level fallback key (#35/#53 API change). diff --git a/SECURITY.md b/SECURITY.md index 277efc4..591f5b2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,18 +39,46 @@ credit. ## Acceptance key + durable ledgers - `PERMIT_ACCEPTANCE_KEY`, when set, is always used (including with `--db --allow-demo-key`). - Empty, whitespace-only, and wrong-length values (not exactly 32 bytes after decoding) - refuse startup; the demo flag never papers over a bad configured key. The durable - key file uses the same exact-32-byte rule. + The value is taken as UTF-8 encoded bytes (nothing is decoded). Empty, + whitespace-only, and wrong-length values (not exactly 32 bytes) refuse startup; + the demo flag never papers over a bad configured key. The durable key file uses + the same exact-32-byte rule. - Every durable ledger binds an HMAC commitment of the acceptance key on first use (`ensure_acceptance_key_commitment` via `ReleaseVerifier`): - `SqliteLedger` (server `--db`) - `DurableLedger` (file-backed under `durable_dir`, including `harness/durability_harness.py`) - Ledgers that require the commitment but lack `get_meta` / `set_meta` refuse startup - (no silent skip). Ephemeral in-memory `Ledger` does not store a commitment. - **Rotating the acceptance key on an existing durable store refuses startup (no - rebind for now).** + Commitment is **required** unless the ledger is explicitly ephemeral (in-memory + `Ledger`, `:memory:` SqliteLedger, or DurableLedger without `durable_dir`). + File-backed SqliteLedger cannot opt out. Ledgers that need the commitment but + lack `get_meta` / `set_meta` refuse startup (no silent skip). + Only an **empty** store may bind a key for the first time; a nonempty store + without a commitment refuses startup (no silent rebind). Concurrent first-boot + binds atomically (`INSERT OR IGNORE`, then re-read and compare). Corrupt + `meta.json` is refused (never treated as empty). + **Rotating the acceptance key on an existing durable store refuses startup.** + +### Upgrading from a pre-exact-32 key (e.g. commit `19b62ce`) + +Databases bound with a key longer than 32 bytes (for example 64 hex characters from +`secrets.token_hex(32)`) are locked out after the exact-32 upgrade. Run the one-time +rebind with the OLD key and a new exact-32-byte key: + +```bash +# OLD may be any non-empty length (e.g. 64 hex chars from token_hex(32)). +# NEW must be exactly 32 UTF-8 bytes (e.g. token_hex(16)). +printf '%s' "$OLD_LONG_KEY" > /tmp/old.key +python -c 'import secrets; open("/tmp/new.key","w").write(secrets.token_hex(16))' + +python -m scripts.rebind_acceptance_key --db /path/to/ledger.db \ + --old-key-file /tmp/old.key --new-key-file /tmp/new.key + +export PERMIT_ACCEPTANCE_KEY="$(cat /tmp/new.key)" +python server.py --db /path/to/ledger.db +``` + +The command checks the OLD key against the stored HMAC commitment and rebinds +atomically. A wrong OLD key is refused; the commitment is left unchanged. ## Related diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..5bc939b --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +# Scripts package (rebind_acceptance_key, mutant sweeps). diff --git a/scripts/rebind_acceptance_key.py b/scripts/rebind_acceptance_key.py new file mode 100644 index 0000000..c8d2211 --- /dev/null +++ b/scripts/rebind_acceptance_key.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""One-time acceptance-key rebind for exact-32 upgrade (REA round 5). + +Databases bound before the exact-32-byte rule (e.g. at 19b62ce with +``secrets.token_hex(32)`` → 64 UTF-8 bytes) store an HMAC commitment of that +longer key. After upgrade, startup refuses the long key. This command checks +the OLD key against the stored commitment, then binds a new exact-32-byte key. + +Usage: + python -m scripts.rebind_acceptance_key --db PATH \\ + --old-key-file OLD --new-key-file NEW + + # or via env (OLD may be any non-empty length; NEW must be exact 32 bytes): + PERMIT_OLD_ACCEPTANCE_KEY=... PERMIT_ACCEPTANCE_KEY=... \\ + python -m scripts.rebind_acceptance_key --db PATH + + # DurableLedger / harness state dir: + python -m scripts.rebind_acceptance_key --durable-dir DIR \\ + --old-key-file OLD --new-key-file NEW +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + + +def _read_key_file(path: Path) -> bytes: + data = path.read_bytes() + if data.endswith(b"\n") and b"\n" not in data[:-1]: + # Allow a single trailing newline from shell redirects; strip it. + data = data[:-1] + return data + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + description="One-time rebind of the durable acceptance-key commitment" + ) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--db", type=str, help="SqliteLedger database path") + g.add_argument( + "--durable-dir", + type=str, + help="DurableLedger / harness state directory", + ) + ap.add_argument( + "--old-key-file", + type=str, + default=None, + help="File containing the OLD acceptance key (any non-empty length)", + ) + ap.add_argument( + "--new-key-file", + type=str, + default=None, + help="File containing the NEW acceptance key (exact 32 bytes)", + ) + args = ap.parse_args(argv) + + if args.old_key_file: + old_key = _read_key_file(Path(args.old_key_file)) + elif "PERMIT_OLD_ACCEPTANCE_KEY" in os.environ: + old_key = os.environ["PERMIT_OLD_ACCEPTANCE_KEY"].encode("utf-8") + else: + ap.error( + "pass --old-key-file or set PERMIT_OLD_ACCEPTANCE_KEY" + ) + + if args.new_key_file: + new_key = _read_key_file(Path(args.new_key_file)) + elif "PERMIT_ACCEPTANCE_KEY" in os.environ: + new_key = os.environ["PERMIT_ACCEPTANCE_KEY"].encode("utf-8") + else: + ap.error("pass --new-key-file or set PERMIT_ACCEPTANCE_KEY") + + from settlement.verifier import rebind_acceptance_key_commitment + + if args.db: + from permit.sqlite_ledger import SqliteLedger + + ledger = SqliteLedger(args.db) + try: + rebind_acceptance_key_commitment(ledger, old_key, new_key) + except (ValueError, RuntimeError) as exc: + print(f"rebind failed: {exc}", file=sys.stderr) + return 1 + finally: + ledger.close() + else: + from permit.durability import DurableLedger + + ledger = DurableLedger(durable_dir=Path(args.durable_dir)) + try: + rebind_acceptance_key_commitment(ledger, old_key, new_key) + except (ValueError, RuntimeError) as exc: + print(f"rebind failed: {exc}", file=sys.stderr) + return 1 + finally: + ledger.close() + + print("rebind ok: acceptance key commitment updated") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 5a4ceef97e81433b8401e4e6f32254e0b5bbf3d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:21:50 +0000 Subject: [PATCH 17/22] test: REA r5 commitment bars + expand mutant sweep to 31 Silent rebind, ephemeral fail-closed, 20-trial concurrent first boot, corrupt meta killer, harness K1 demo-key persist, rebind right/wrong old key. Mutant sweep covers 31 demo-key + commitment mutants. Co-authored-by: Marcus Richards --- scripts/mutant_sweep_demo_key.py | 453 +++++++++++++++++++++- tests/test_durable_key_commitment.py | 7 +- tests/test_rea_r5_commitment.py | 540 +++++++++++++++++++++++++++ 3 files changed, 987 insertions(+), 13 deletions(-) create mode 100644 tests/test_rea_r5_commitment.py diff --git a/scripts/mutant_sweep_demo_key.py b/scripts/mutant_sweep_demo_key.py index ad18ea0..22a8f0f 100644 --- a/scripts/mutant_sweep_demo_key.py +++ b/scripts/mutant_sweep_demo_key.py @@ -1,17 +1,15 @@ #!/usr/bin/env python3 -"""Re-apply survived demo-key mutants and report killed/total (REA seq 1854). +"""Re-apply survived demo-key + REA round-5 mutants; report killed/total. -Mutants: - 1. drop O_NOFOLLOW on read open - 2. drop O_EXCL on create - 3. drop file fsync - 4. drop directory fsync - 5. drop fchmod on create - 6. drop pre-check OSError → ValueError mapping (re-raise raw) - 7. soften symlink error message text +Demo-key mutants (seq 1854): + 1-7: O_NOFOLLOW, O_EXCL, file/dir fsync, fchmod, precheck map, symlink text + +REA round-5 mutants (commitment / meta / rebind / ephemeral): + 8-31: silent rebind, corrupt-meta-as-empty, skip compare after bind, + fail-open default, rebind without old-key check, etc. Each is applied in-process via monkeypatch, then the matching killer test -runs. Exit 0 only if every mutant is killed. +runs. Exit 0 only if every mutant is killed (target: 31/31). """ from __future__ import annotations @@ -230,6 +228,441 @@ def reject_soft(key_path): v._reject_foreign_demo_key_owner = reject_soft """, ), + # ----- REA round 5 (8-31) ----- + ( + "silent_rebind_skip_nonempty_check", + "tests/test_rea_r5_commitment.py::test_sqlite_nonempty_without_commitment_refuses_silent_rebind", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_silent(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return # MUTANT: no nonempty check + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_silent + """, + ), + ( + "durable_silent_rebind_skip_nonempty", + "tests/test_rea_r5_commitment.py::test_durable_nonempty_missing_meta_refuses_silent_rebind", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_silent(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_silent + """, + ), + ( + "corrupt_meta_returns_empty", + "tests/test_rea_r5_commitment.py::test_corrupt_meta_json_refused_not_empty", + """ + import json + from permit.durability import DurableLedger + def mutant_read(self): + if self._meta_path is None or not self._meta_path.exists(): + return {} + try: + raw = json.loads(self._meta_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, ValueError): + return {} + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items()} + DurableLedger._read_meta = mutant_read + """, + ), + ( + "empty_meta_file_returns_empty", + "tests/test_rea_r5_commitment.py::test_empty_meta_json_file_refused", + """ + import json + from permit.durability import DurableLedger + def mutant_read(self): + if self._meta_path is None or not self._meta_path.exists(): + return {} + try: + text = self._meta_path.read_text(encoding="utf-8") + if not text.strip(): + return {} + raw = json.loads(text) + except (json.JSONDecodeError, OSError, ValueError): + return {} + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items()} + DurableLedger._read_meta = mutant_read + """, + ), + ( + "fail_open_bind_default", + "tests/test_rea_r5_commitment.py::test_commitment_required_by_default_not_opt_in", + """ + import settlement.verifier as v + def bind_opt_in(ledger, key): + requires = bool(getattr(ledger, "requires_acceptance_key_commitment", False)) + if not requires: + return # MUTANT: fail-open default + v.ensure_acceptance_key_commitment(ledger, key) + v._bind_ledger_acceptance_key_commitment = bind_opt_in + """, + ), + ( + "memory_sqlite_still_binds", + "tests/test_rea_r5_commitment.py::test_memory_sqlite_is_ephemeral", + """ + import settlement.verifier as v + def bind_always(ledger, key): + # MUTANT: ignore ephemeral; always require meta bind + if not (hasattr(ledger, "get_meta") and hasattr(ledger, "set_meta")): + raise ValueError("refusing silent skip") + v.ensure_acceptance_key_commitment(ledger, key) + v._bind_ledger_acceptance_key_commitment = bind_always + """, + ), + ( + "rebind_skips_old_key_check", + "tests/test_rea_r5_commitment.py::test_rebind_refused_with_wrong_old_key", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def rebind_no_check(db_ledger, old_key, new_key): + new = v._coerce_key(new_key) + new = v._require_valid_key(new, what="new acceptance key") + # MUTANT: no old-key verification + db_ledger.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, + v.acceptance_key_commitment(new), + ) + v.rebind_acceptance_key_commitment = rebind_no_check + """, + ), + ( + "ensure_uses_set_meta_no_compare", + "tests/test_rea_r5_commitment.py::test_sqlite_concurrent_first_boot_20_trials", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_racy(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + if len(db_ledger) > 0: + raise ValueError(v._SILENT_REBIND_MSG) + # MUTANT: set_meta without try_bind / compare + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_racy + """, + ), + ( + "harness_no_demo_key_path", + "tests/test_rea_r5_commitment.py::test_k1_allow_demo_key_survives_reopen_without_env", + """ + import harness.durability_harness as h + from permit.durability import ( + DurableLedger, DurablePermitStore, DurableMockPayPalClient, + DurableReleaseVerifier, DurableSpendPipeline, + ) + def build_no_persist(durable_dir, *, allow_demo_key=False): + ledger = DurableLedger(durable_dir=durable_dir) + permits = DurablePermitStore(ledger=ledger, durable_dir=durable_dir) + paypal = DurableMockPayPalClient(durable_dir=durable_dir) + # MUTANT: no demo_key_path persistence + verifier = DurableReleaseVerifier( + paypal, permits, ledger=ledger, durable_dir=durable_dir, + allow_demo_key=allow_demo_key, + ) + flow = DurableSpendPipeline( + permits, paypal, verifier, ledger=ledger, durable_dir=durable_dir + ) + return ledger, permits, paypal, verifier, flow + h._build = build_no_persist + """, + ), + ( + "drop_ledger_ephemeral_flag", + "tests/test_rea_r5_commitment.py::test_in_memory_ledger_is_ephemeral", + """ + from permit import ledger as led + led.Ledger.ephemeral_acceptance_key_commitment = False # MUTANT + """, + ), + ( + "file_sqlite_ephemeral_force_true", + "tests/test_rea_r5_commitment.py::test_file_sqlite_cannot_turn_off_ephemeral", + """ + from permit.sqlite_ledger import SqliteLedger + # MUTANT: property always True (opts out of commitment) + SqliteLedger.ephemeral_acceptance_key_commitment = property( + lambda self: True + ) + SqliteLedger.requires_acceptance_key_commitment = property( + lambda self: False + ) + """, + ), + ( + "meta_row_deleted_allows_rebind", + "tests/test_rea_r5_commitment.py::test_sqlite_meta_row_deleted_nonempty_refuses", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_silent(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_silent + """, + ), + ( + "durable_meta_without_commitment_allows", + "tests/test_rea_r5_commitment.py::test_durable_nonempty_meta_without_commitment_refuses", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_silent(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_silent + """, + ), + ( + "rebind_accepts_wrong_then_overwrites", + "tests/test_rea_r5_commitment.py::test_rebind_refused_with_wrong_old_key", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def rebind_soft(db_ledger, old_key, new_key): + old = v._coerce_key(old_key) + new = v._require_valid_key(v._coerce_key(new_key), what="new acceptance key") + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + # MUTANT: ignore mismatch + db_ledger.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, + v.acceptance_key_commitment(new), + ) + v.rebind_acceptance_key_commitment = rebind_soft + """, + ), + ( + "mutant_killer_corrupt_meta_survives", + "tests/test_rea_r5_commitment.py::test_mutant_corrupt_meta_treated_as_empty_is_killed", + """ + import json + from permit.durability import DurableLedger + def mutant_read(self): + if self._meta_path is None or not self._meta_path.exists(): + return {} + try: + text = self._meta_path.read_text(encoding="utf-8") + if not text.strip(): + return {} + raw = json.loads(text) + except (json.JSONDecodeError, OSError, ValueError): + return {} + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items()} + DurableLedger._read_meta = mutant_read + """, + ), + ( + "harness_cli_no_persist", + "tests/test_rea_r5_commitment.py::test_harness_cli_k1_allow_demo_key_no_env", + """ + import harness.durability_harness as h + from permit.durability import ( + DurableLedger, DurablePermitStore, DurableMockPayPalClient, + DurableReleaseVerifier, DurableSpendPipeline, + ) + def build_no_persist(durable_dir, *, allow_demo_key=False): + ledger = DurableLedger(durable_dir=durable_dir) + permits = DurablePermitStore(ledger=ledger, durable_dir=durable_dir) + paypal = DurableMockPayPalClient(durable_dir=durable_dir) + verifier = DurableReleaseVerifier( + paypal, permits, ledger=ledger, durable_dir=durable_dir, + allow_demo_key=allow_demo_key, + ) + flow = DurableSpendPipeline( + permits, paypal, verifier, ledger=ledger, durable_dir=durable_dir + ) + return ledger, permits, paypal, verifier, flow + h._build = build_no_persist + """, + ), + ( + "try_bind_skipped_set_meta_only", + "tests/test_acceptance_key_commitment.py::test_commitment_mismatch_raises_valueerror", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + real = v.ensure_acceptance_key_commitment + def ensure_no_try_bind(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + if len(db_ledger) > 0: + raise ValueError(v._SILENT_REBIND_MSG) + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + return # MUTANT: no re-read/compare after first bind + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_no_try_bind + """, + ), + ( + "broken_durable_silently_skipped", + "tests/test_durable_key_commitment.py::test_no_ledger_type_silently_skips_commitment", + """ + import settlement.verifier as v + def bind_skip(ledger, key): + return # MUTANT: always skip + v._bind_ledger_acceptance_key_commitment = bind_skip + """, + ), + ( + "rotation_message_softened", + "tests/test_acceptance_key_commitment.py::test_commitment_mismatch_raises_valueerror", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_soft(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + if len(db_ledger) > 0: + raise ValueError(v._SILENT_REBIND_MSG) + try_bind = getattr(db_ledger, "try_bind_meta", None) + if callable(try_bind): + bound = try_bind(ACCEPTANCE_KEY_COMMITMENT_META, digest) + else: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + bound = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if bound is None or not v.hmac.compare_digest(str(bound), digest): + raise ValueError("key changed") # MUTANT softened + return + if not v.hmac.compare_digest(stored, digest): + raise ValueError("key changed") # MUTANT softened + v.ensure_acceptance_key_commitment = ensure_soft + """, + ), + ( + "rebind_wrong_still_ok_via_cli", + "tests/test_rea_r5_commitment.py::test_rebind_cli_script", + """ + import settlement.verifier as v + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def rebind_no_check(db_ledger, old_key, new_key): + new = v._require_valid_key(v._coerce_key(new_key), what="new acceptance key") + db_ledger.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, + v.acceptance_key_commitment(new), + ) + v.rebind_acceptance_key_commitment = rebind_no_check + """, + ), + ( + "drop_nonempty_check_on_deleted_meta", + "tests/test_rea_r5_commitment.py::test_sqlite_meta_row_deleted_nonempty_refuses", + """ + import settlement.verifier as v + real = v.ensure_acceptance_key_commitment + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + def ensure_allow(db_ledger, key): + digest = v.acceptance_key_commitment(key) + stored = db_ledger.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + if stored is None: + # MUTANT: bind even when nonempty + try_bind = getattr(db_ledger, "try_bind_meta", None) + if callable(try_bind): + bound = try_bind(ACCEPTANCE_KEY_COMMITMENT_META, digest) + else: + db_ledger.set_meta(ACCEPTANCE_KEY_COMMITMENT_META, digest) + bound = digest + return + if not v.hmac.compare_digest(stored, digest): + raise ValueError(v._ROTATION_REFUSE_MSG) + v.ensure_acceptance_key_commitment = ensure_allow + """, + ), + ( + "skip_load_meta_validation", + "tests/test_rea_r5_commitment.py::test_corrupt_meta_json_refused_not_empty", + """ + from permit.durability import DurableLedger + real_load = DurableLedger._load + def load_no_meta(self): + # Call real load but skip our meta validation by temporarily + # clearing meta path existence check via mutant _read_meta + import json + def soft_read(self2): + if self2._meta_path is None or not self2._meta_path.exists(): + return {} + try: + return { + str(k): str(v) + for k, v in json.loads( + self2._meta_path.read_text(encoding="utf-8") + ).items() + } + except Exception: + return {} + DurableLedger._read_meta = soft_read + return real_load(self) + DurableLedger._load = load_no_meta + """, + ), + ( + "plain_object_treated_ephemeral", + "tests/test_rea_r5_commitment.py::test_commitment_required_by_default_not_opt_in", + """ + import settlement.verifier as v + def bind_default_ephemeral(ledger, key): + # MUTANT: default ephemeral (fail-open) + ephemeral = bool( + getattr(ledger, "ephemeral_acceptance_key_commitment", True) + ) + if ephemeral: + return + v.ensure_acceptance_key_commitment(ledger, key) + v._bind_ledger_acceptance_key_commitment = bind_default_ephemeral + """, + ), + ( + "rebind_success_path_noop", + "tests/test_rea_r5_commitment.py::test_rebind_succeeds_with_correct_old_key", + """ + import settlement.verifier as v + def rebind_noop(db_ledger, old_key, new_key): + raise ValueError( + "old acceptance key does not match stored commitment; " + "refusing rebind" + ) # MUTANT: always refuse + v.rebind_acceptance_key_commitment = rebind_noop + """, + ), ] diff --git a/tests/test_durable_key_commitment.py b/tests/test_durable_key_commitment.py index 4483499..234ea46 100644 --- a/tests/test_durable_key_commitment.py +++ b/tests/test_durable_key_commitment.py @@ -86,8 +86,9 @@ def test_no_ledger_type_silently_skips_commitment(): """Every durable ledger type either enforces or raises — never skips.""" from settlement.verifier import _bind_ledger_acceptance_key_commitment - # In-memory Ledger: no commitment required. + # In-memory Ledger: explicitly ephemeral (fail-closed default otherwise). mem = Ledger() + assert mem.ephemeral_acceptance_key_commitment is True assert not getattr(mem, "requires_acceptance_key_commitment", False) # Sqlite file-backed + DurableLedger: meta API present. @@ -96,9 +97,9 @@ def test_no_ledger_type_silently_skips_commitment(): assert hasattr(SqliteLedger, "get_meta") assert hasattr(SqliteLedger, "set_meta") - # A durable ledger that opts in but lacks meta must raise (no silent skip). + # Fail-closed: no ephemeral mark and no meta → refuse (silent skip banned). class _BrokenDurable: - requires_acceptance_key_commitment = True + pass with pytest.raises(ValueError, match="refusing silent skip"): _bind_ledger_acceptance_key_commitment(_BrokenDurable(), b"k" * MIN) diff --git a/tests/test_rea_r5_commitment.py b/tests/test_rea_r5_commitment.py new file mode 100644 index 0000000..e678c71 --- /dev/null +++ b/tests/test_rea_r5_commitment.py @@ -0,0 +1,540 @@ +"""REA round 5: silent-rebind refuse, fail-closed ephemeral, concurrent bind, +corrupt meta, harness K1 demo-key persist, one-time rebind. + +These tests must FAIL on 6bed5ff (pre-r5 tip) and PASS on head. +""" + +from __future__ import annotations + +import json +import multiprocessing +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MIN = 32 +SILENT = "refusing silent rebind" +REFUSE = "rotating the acceptance key on an existing database refuses startup" +CORRUPT = "corrupt durable meta.json" +REBIND_REFUSE = "old acceptance key does not match stored commitment" + + +def _env_no_key() -> dict[str, str]: + return {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_KEY"} + + +def _append_one(ledger) -> None: + ledger.append("TEST", {"n": 1}) + + +# --------------------------------------------------------------------------- +# 1. Silent rebind: nonempty store without commitment refuses +# --------------------------------------------------------------------------- + + +def test_sqlite_nonempty_without_commitment_refuses_silent_rebind(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import ensure_acceptance_key_commitment + + db = tmp_path / "nonempty.db" + lg = SqliteLedger(str(db)) + try: + _append_one(lg) + assert len(lg) > 0 + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) is None + with pytest.raises(ValueError, match=SILENT): + ensure_acceptance_key_commitment(lg, b"A" * MIN) + finally: + lg.close() + + +def test_durable_nonempty_missing_meta_refuses_silent_rebind(tmp_path): + from permit.durability import DurableLedger + from settlement.verifier import ensure_acceptance_key_commitment + + durable = tmp_path / "dur" + lg = DurableLedger(durable_dir=durable) + try: + _append_one(lg) + assert len(lg) > 0 + meta = durable / "meta.json" + if meta.exists(): + meta.unlink() + with pytest.raises(ValueError, match=SILENT): + ensure_acceptance_key_commitment(lg, b"B" * MIN) + finally: + lg.close() + + +def test_durable_nonempty_meta_without_commitment_refuses(tmp_path): + from permit.durability import DurableLedger + from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META + from settlement.verifier import ensure_acceptance_key_commitment + + durable = tmp_path / "dur2" + lg = DurableLedger(durable_dir=durable) + try: + _append_one(lg) + lg.set_meta("other", "x") + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) is None + with pytest.raises(ValueError, match=SILENT): + ensure_acceptance_key_commitment(lg, b"C" * MIN) + finally: + lg.close() + + +def test_sqlite_meta_row_deleted_nonempty_refuses(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import ( + acceptance_key_commitment, + ensure_acceptance_key_commitment, + ) + + db = tmp_path / "del.db" + lg = SqliteLedger(str(db)) + try: + key = b"D" * MIN + ensure_acceptance_key_commitment(lg, key) + _append_one(lg) + lg._conn.execute( + "DELETE FROM meta WHERE key = ?", + (ACCEPTANCE_KEY_COMMITMENT_META,), + ) + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) is None + with pytest.raises(ValueError, match=SILENT): + ensure_acceptance_key_commitment(lg, key) + # Wrong key also refuses (still silent-rebind path) + with pytest.raises(ValueError, match=SILENT): + ensure_acceptance_key_commitment(lg, b"E" * MIN) + finally: + lg.close() + + +def test_empty_store_may_first_bind(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import ( + acceptance_key_commitment, + ensure_acceptance_key_commitment, + ) + + db = tmp_path / "empty.db" + lg = SqliteLedger(str(db)) + try: + assert len(lg) == 0 + key = b"F" * MIN + ensure_acceptance_key_commitment(lg, key) + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( + acceptance_key_commitment(key) + ) + finally: + lg.close() + + +# --------------------------------------------------------------------------- +# 2. Fail-closed default: required unless ephemeral +# --------------------------------------------------------------------------- + + +def test_commitment_required_by_default_not_opt_in(): + from settlement.verifier import _bind_ledger_acceptance_key_commitment + + class _PlainNoFlag: + """No ephemeral mark and no meta — must refuse (fail-closed).""" + + with pytest.raises(ValueError, match="refusing silent skip"): + _bind_ledger_acceptance_key_commitment(_PlainNoFlag(), b"k" * MIN) + + +def test_in_memory_ledger_is_ephemeral(): + from permit.ledger import Ledger + from settlement.verifier import _bind_ledger_acceptance_key_commitment + + mem = Ledger() + assert mem.ephemeral_acceptance_key_commitment is True + # Must not raise (skip commitment). + _bind_ledger_acceptance_key_commitment(mem, b"k" * MIN) + + +def test_file_sqlite_cannot_turn_off_ephemeral(tmp_path): + from permit.sqlite_ledger import SqliteLedger + from settlement.verifier import _bind_ledger_acceptance_key_commitment + + db = tmp_path / "req.db" + lg = SqliteLedger(str(db)) + try: + assert lg.ephemeral_acceptance_key_commitment is False + assert lg.requires_acceptance_key_commitment is True + with pytest.raises(AttributeError): + lg.ephemeral_acceptance_key_commitment = True # type: ignore[misc] + # Still required after failed assign attempt. + assert lg.ephemeral_acceptance_key_commitment is False + _bind_ledger_acceptance_key_commitment(lg, b"G" * MIN) + assert lg.get_meta("acceptance_key_commitment_v1") is not None + finally: + lg.close() + + +def test_memory_sqlite_is_ephemeral(): + from permit.sqlite_ledger import SqliteLedger + from settlement.verifier import _bind_ledger_acceptance_key_commitment + + lg = SqliteLedger(":memory:") + try: + assert lg.ephemeral_acceptance_key_commitment is True + _bind_ledger_acceptance_key_commitment(lg, b"H" * MIN) + assert lg.get_meta("acceptance_key_commitment_v1") is None + finally: + lg.close() + + +# --------------------------------------------------------------------------- +# 3. SQLite concurrent first boot (20 trials) +# --------------------------------------------------------------------------- + + +def _race_worker(db_path: str, key_hex: str, ready, go, q) -> None: + import sqlite3 + import time + + from permit.sqlite_ledger import SqliteLedger + from settlement.verifier import ensure_acceptance_key_commitment + + key = bytes.fromhex(key_hex) + lg = None + for attempt in range(50): + try: + lg = SqliteLedger(db_path) + break + except sqlite3.OperationalError: + time.sleep(0.01) + if lg is None: + q.put(("boot_err", "could not open db")) + ready.set() + return + ready.set() + go.wait(timeout=10) + try: + ensure_acceptance_key_commitment(lg, key) + q.put(("ok", key_hex)) + except ValueError as exc: + q.put(("err", str(exc))) + finally: + lg.close() + + +def test_sqlite_concurrent_first_boot_20_trials(tmp_path): + """Two processes, different keys: exactly one binds; the other mismatches.""" + ctx = multiprocessing.get_context("spawn") + wins_a = wins_b = mismatches = 0 + for trial in range(20): + db = tmp_path / f"race_{trial}.db" + # Pre-create empty schema so both racers only contend on meta bind. + from permit.sqlite_ledger import SqliteLedger + + SqliteLedger(str(db)).close() + key_a = os.urandom(MIN) + key_b = os.urandom(MIN) + assert key_a != key_b + q = ctx.Queue() + ready1 = ctx.Event() + ready2 = ctx.Event() + go = ctx.Event() + p1 = ctx.Process( + target=_race_worker, + args=(str(db), key_a.hex(), ready1, go, q), + ) + p2 = ctx.Process( + target=_race_worker, + args=(str(db), key_b.hex(), ready2, go, q), + ) + p1.start() + p2.start() + assert ready1.wait(timeout=10) and ready2.wait(timeout=10) + go.set() + p1.join(timeout=10) + p2.join(timeout=10) + assert p1.exitcode == 0 and p2.exitcode == 0, ( + f"trial {trial}: exitcodes {p1.exitcode}/{p2.exitcode}" + ) + results = [q.get(timeout=2), q.get(timeout=2)] + oks = [r for r in results if r[0] == "ok"] + errs = [r for r in results if r[0] == "err"] + assert len(oks) == 1, f"trial {trial}: {results}" + assert len(errs) == 1, f"trial {trial}: {results}" + assert "mismatch" in errs[0][1] or "refuses startup" in errs[0][1] + if oks[0][1] == key_a.hex(): + wins_a += 1 + else: + wins_b += 1 + mismatches += 1 + assert mismatches == 20 + assert wins_a + wins_b == 20 + + +# --------------------------------------------------------------------------- +# 4. Corrupt meta.json refused (not empty) + mutant killer +# --------------------------------------------------------------------------- + + +def test_corrupt_meta_json_refused_not_empty(tmp_path): + from permit.durability import DurableLedger + + durable = tmp_path / "badmeta" + durable.mkdir() + (durable / "meta.json").write_text("{not-json", encoding="utf-8") + with pytest.raises(ValueError, match=CORRUPT): + DurableLedger(durable_dir=durable) + + +def test_empty_meta_json_file_refused(tmp_path): + from permit.durability import DurableLedger + + durable = tmp_path / "emptymeta" + durable.mkdir() + (durable / "meta.json").write_text(" \n", encoding="utf-8") + with pytest.raises(ValueError, match=CORRUPT): + DurableLedger(durable_dir=durable) + + +def test_mutant_corrupt_meta_treated_as_empty_is_killed(tmp_path, monkeypatch): + """Surviving mutant: _read_meta returns {} on JSON error → killed here.""" + from permit.durability import DurableLedger + + durable = tmp_path / "mut" + durable.mkdir() + (durable / "meta.json").write_text("{broken", encoding="utf-8") + + real_read = DurableLedger._read_meta + + def mutant_read(self): + if self._meta_path is None: + return {} + if not self._meta_path.exists(): + return {} + try: + text = self._meta_path.read_text(encoding="utf-8") + if not text.strip(): + return {} + raw = json.loads(text) + except (json.JSONDecodeError, OSError, ValueError): + return {} # MUTANT: treat corrupt as empty + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items()} + + monkeypatch.setattr(DurableLedger, "_read_meta", mutant_read) + # Under the mutant, construction succeeds (corrupt treated as empty). + lg = DurableLedger(durable_dir=durable) + lg.close() + monkeypatch.setattr(DurableLedger, "_read_meta", real_read) + # Real code must still refuse at open. + with pytest.raises(ValueError, match=CORRUPT): + DurableLedger(durable_dir=durable) + + +# --------------------------------------------------------------------------- +# 5. Harness K1 + --allow-demo-key, no env (r2 N-h) +# --------------------------------------------------------------------------- + + +def test_k1_allow_demo_key_survives_reopen_without_env(tmp_path, monkeypatch): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + from harness.durability_harness import _run_kill_point + + work = tmp_path / "K1demo" + res = _run_kill_point("K1", work, allow_demo_key=True) + assert res["pass"], "\n".join(res.get("details", [])) + f"\nerror: {res.get('error')}" + key_path = work / "acceptance_key" + assert key_path.is_file(), "demo key must be persisted under harness state dir" + mode = stat.S_IMODE(key_path.stat().st_mode) + assert mode == 0o600, f"expected 0600, got {mode:04o}" + assert len(key_path.read_bytes()) == MIN + + +def test_harness_cli_k1_allow_demo_key_no_env(tmp_path, monkeypatch): + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + work = tmp_path / "cliK1" + proc = subprocess.run( + [ + sys.executable, + "-m", + "harness.durability_harness", + "--kill-point", + "K1", + "--work-dir", + str(work), + "--allow-demo-key", + ], + cwd=ROOT, + env=_env_no_key(), + capture_output=True, + text=True, + timeout=30, + ) + out = (proc.stdout or "") + (proc.stderr or "") + assert proc.returncode == 0, out + assert "PASS" in out + key_path = work / "K1" / "acceptance_key" + assert key_path.is_file() + assert stat.S_IMODE(key_path.stat().st_mode) == 0o600 + + +# --------------------------------------------------------------------------- +# 6. One-time rebind (long OLD key → exact-32 NEW) +# --------------------------------------------------------------------------- + + +def test_rebind_succeeds_with_correct_old_key(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import ( + acceptance_key_commitment, + ensure_acceptance_key_commitment, + rebind_acceptance_key_commitment, + ) + + # Simulate 19b62ce binding with token_hex(32) → 64 UTF-8 bytes. + old_key = os.urandom(32).hex().encode("utf-8") + assert len(old_key) == 64 + new_key = os.urandom(MIN) + db = tmp_path / "mig.db" + lg = SqliteLedger(str(db)) + try: + # Direct set_meta of old commitment (as 19b62ce would have stored). + lg.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, + acceptance_key_commitment(old_key), + ) + _append_one(lg) + rebind_acceptance_key_commitment(lg, old_key, new_key) + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( + acceptance_key_commitment(new_key) + ) + ensure_acceptance_key_commitment(lg, new_key) + finally: + lg.close() + + +def test_rebind_refused_with_wrong_old_key(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import ( + acceptance_key_commitment, + rebind_acceptance_key_commitment, + ) + + old_key = os.urandom(32).hex().encode("utf-8") + wrong = os.urandom(32).hex().encode("utf-8") + new_key = os.urandom(MIN) + db = tmp_path / "mig_bad.db" + lg = SqliteLedger(str(db)) + try: + lg.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, + acceptance_key_commitment(old_key), + ) + with pytest.raises(ValueError, match=REBIND_REFUSE): + rebind_acceptance_key_commitment(lg, wrong, new_key) + # Commitment unchanged. + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( + acceptance_key_commitment(old_key) + ) + finally: + lg.close() + + +def test_rebind_cli_script(tmp_path): + from permit.sqlite_ledger import ( + ACCEPTANCE_KEY_COMMITMENT_META, + SqliteLedger, + ) + from settlement.verifier import acceptance_key_commitment + + old_key = ("a" * 64).encode("utf-8") + new_key = b"n" * MIN + db = tmp_path / "cli.db" + lg = SqliteLedger(str(db)) + lg.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, acceptance_key_commitment(old_key) + ) + lg.close() + + old_path = tmp_path / "old.key" + new_path = tmp_path / "new.key" + old_path.write_bytes(old_key) + new_path.write_bytes(new_key) + + proc = subprocess.run( + [ + sys.executable, + "-m", + "scripts.rebind_acceptance_key", + "--db", + str(db), + "--old-key-file", + str(old_path), + "--new-key-file", + str(new_path), + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=10, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "rebind ok" in proc.stdout + + lg2 = SqliteLedger(str(db)) + try: + assert lg2.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( + acceptance_key_commitment(new_key) + ) + finally: + lg2.close() + + # Wrong old key via CLI. + bad_old = tmp_path / "bad.key" + bad_old.write_bytes(("b" * 64).encode("utf-8")) + # Re-seed commitment to old again for the refuse path. + lg3 = SqliteLedger(str(db)) + lg3.set_meta( + ACCEPTANCE_KEY_COMMITMENT_META, acceptance_key_commitment(old_key) + ) + lg3.close() + proc2 = subprocess.run( + [ + sys.executable, + "-m", + "scripts.rebind_acceptance_key", + "--db", + str(db), + "--old-key-file", + str(bad_old), + "--new-key-file", + str(new_path), + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=10, + ) + assert proc2.returncode != 0 + assert "rebind failed" in (proc2.stderr or "") From 61a20181cccb4ea992013b9b0bd923581cad0cf8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:23:39 +0000 Subject: [PATCH 18/22] docs: update suite count for REA r5 (461 passed) Co-authored-by: Marcus Richards --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index df97c66..057d68c 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **439+ tests pass, 1 skipped:** `python -m pytest -rs` and `python -O -m pytest -rs` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854 + 1888). CI runs the suite with `-q -rs`, plus `-v` for the key-file tests only; also `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. +- **461 tests pass, 1 skipped:** `python -m pytest -rs` and `python -O -m pytest -rs` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854 + 1888 + r5). CI runs the suite with `-q -rs`, plus `-v` for the key-file tests only; also `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. Mutant sweep: `python -m scripts.mutant_sweep_demo_key` → 31/31. - **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path. From 7306aab7d31465752d1f3b0a1c5711d2b2aef85b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:24:54 +0000 Subject: [PATCH 19/22] test: subprocess durability_harness.py K1 --allow-demo-key exit 0 CI-visible pytest that runs harness/durability_harness.py --kill-point K1 with --allow-demo-key and no PERMIT_ACCEPTANCE_KEY through kill/reopen. Fails on 6bed5ff (ephemeral demo key); no ci.yml change needed. Co-authored-by: Marcus Richards --- tests/test_rea_r5_commitment.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_rea_r5_commitment.py b/tests/test_rea_r5_commitment.py index e678c71..ce9ede2 100644 --- a/tests/test_rea_r5_commitment.py +++ b/tests/test_rea_r5_commitment.py @@ -365,14 +365,22 @@ def test_k1_allow_demo_key_survives_reopen_without_env(tmp_path, monkeypatch): assert len(key_path.read_bytes()) == MIN -def test_harness_cli_k1_allow_demo_key_no_env(tmp_path, monkeypatch): +def test_durability_harness_py_k1_allow_demo_key_no_env_exit_0( + tmp_path, monkeypatch +): + """Subprocess: durability_harness.py --kill-point K1 --allow-demo-key, no env. + + Runs the harness script through kill + reopen and asserts exit 0 so CI + catches the r2 N-h bar without ci.yml changes (REA r5 fix 5). + """ monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) work = tmp_path / "cliK1" + harness_py = ROOT / "harness" / "durability_harness.py" + assert harness_py.is_file() proc = subprocess.run( [ sys.executable, - "-m", - "harness.durability_harness", + str(harness_py), "--kill-point", "K1", "--work-dir", From 83ac6aa4688639876333259d0d7209e43e5f0a13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:25:05 +0000 Subject: [PATCH 20/22] chore: point mutant sweep at renamed K1 subprocess test Co-authored-by: Marcus Richards --- scripts/mutant_sweep_demo_key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/mutant_sweep_demo_key.py b/scripts/mutant_sweep_demo_key.py index 22a8f0f..8b9f7e7 100644 --- a/scripts/mutant_sweep_demo_key.py +++ b/scripts/mutant_sweep_demo_key.py @@ -490,7 +490,7 @@ def mutant_read(self): ), ( "harness_cli_no_persist", - "tests/test_rea_r5_commitment.py::test_harness_cli_k1_allow_demo_key_no_env", + "tests/test_rea_r5_commitment.py::test_durability_harness_py_k1_allow_demo_key_no_env_exit_0", """ import harness.durability_harness as h from permit.durability import ( From 90fc5830cdcbfc4eae6e20c131d4f4b533a71d45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:26:00 +0000 Subject: [PATCH 21/22] test: rebind covers 48-byte and token_hex(32) old keys Both lengths are refused at startup before rebind (exact-32 / commitment mismatch) and succeed after rebind with the right old key; wrong old key still refused for each length. Co-authored-by: Marcus Richards --- scripts/mutant_sweep_demo_key.py | 6 +-- tests/test_rea_r5_commitment.py | 92 ++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/scripts/mutant_sweep_demo_key.py b/scripts/mutant_sweep_demo_key.py index 8b9f7e7..0772e1f 100644 --- a/scripts/mutant_sweep_demo_key.py +++ b/scripts/mutant_sweep_demo_key.py @@ -332,7 +332,7 @@ def bind_always(ledger, key): ), ( "rebind_skips_old_key_check", - "tests/test_rea_r5_commitment.py::test_rebind_refused_with_wrong_old_key", + "tests/test_rea_r5_commitment.py::test_rebind_refused_with_wrong_old_key[token_hex_32_64_chars]", """ import settlement.verifier as v from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META @@ -450,7 +450,7 @@ def ensure_silent(db_ledger, key): ), ( "rebind_accepts_wrong_then_overwrites", - "tests/test_rea_r5_commitment.py::test_rebind_refused_with_wrong_old_key", + "tests/test_rea_r5_commitment.py::test_rebind_refused_with_wrong_old_key[48_bytes]", """ import settlement.verifier as v from permit.sqlite_ledger import ACCEPTANCE_KEY_COMMITMENT_META @@ -652,7 +652,7 @@ def bind_default_ephemeral(ledger, key): ), ( "rebind_success_path_noop", - "tests/test_rea_r5_commitment.py::test_rebind_succeeds_with_correct_old_key", + "tests/test_rea_r5_commitment.py::test_rebind_migration_refused_before_works_after[token_hex_32_64_chars]", """ import settlement.verifier as v def rebind_noop(db_ledger, old_key, new_key): diff --git a/tests/test_rea_r5_commitment.py b/tests/test_rea_r5_commitment.py index ce9ede2..3691195 100644 --- a/tests/test_rea_r5_commitment.py +++ b/tests/test_rea_r5_commitment.py @@ -406,40 +406,111 @@ def test_durability_harness_py_k1_allow_demo_key_no_env_exit_0( # --------------------------------------------------------------------------- -def test_rebind_succeeds_with_correct_old_key(tmp_path): +def _old_key_48() -> bytes: + return os.urandom(48) + + +def _old_key_token_hex_32() -> bytes: + """64 UTF-8 characters from secrets.token_hex(32) (pre-exact-32 README).""" + import secrets + + key = secrets.token_hex(32).encode("utf-8") + assert len(key) == 64 + return key + + +@pytest.mark.parametrize( + "old_key_factory,label", + [ + (_old_key_48, "48_bytes"), + (_old_key_token_hex_32, "token_hex_32_64_chars"), + ], + ids=["48_bytes", "token_hex_32_64_chars"], +) +def test_rebind_migration_refused_before_works_after( + tmp_path, monkeypatch, old_key_factory, label +): + """Long OLD keys are locked out at startup; rebind with the right OLD unlocks. + + Covers 48-byte keys and 64-char token_hex(32) keys (REA r5 fix 6). + """ + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + from permit.ledger import Ledger + from permit.permit import PermitStore from permit.sqlite_ledger import ( ACCEPTANCE_KEY_COMMITMENT_META, SqliteLedger, ) + from settlement.paypal_client import MockPayPalClient from settlement.verifier import ( + ReleaseVerifier, acceptance_key_commitment, ensure_acceptance_key_commitment, rebind_acceptance_key_commitment, ) - # Simulate 19b62ce binding with token_hex(32) → 64 UTF-8 bytes. - old_key = os.urandom(32).hex().encode("utf-8") - assert len(old_key) == 64 + old_key = old_key_factory() + assert len(old_key) != MIN, f"{label}: old key must not be exact-32" new_key = os.urandom(MIN) - db = tmp_path / "mig.db" + assert len(new_key) == MIN + + db = tmp_path / f"mig_{label}.db" lg = SqliteLedger(str(db)) try: - # Direct set_meta of old commitment (as 19b62ce would have stored). + # Simulate pre-upgrade bind (19b62ce stored HMAC of the long key). lg.set_meta( ACCEPTANCE_KEY_COMMITMENT_META, acceptance_key_commitment(old_key), ) _append_one(lg) + + # Before rebind: long OLD key refused at startup (exact-32 rule). + with pytest.raises(ValueError, match="exactly 32 bytes"): + ReleaseVerifier( + MockPayPalClient(), + PermitStore(ledger=Ledger()), + ledger=lg, + acceptance_key=old_key, + ) + + # Before rebind: a fresh exact-32 key is also refused (commitment mismatch). + with pytest.raises(ValueError, match=REFUSE): + ensure_acceptance_key_commitment(lg, new_key) + with pytest.raises(ValueError, match=REFUSE): + ReleaseVerifier( + MockPayPalClient(), + PermitStore(ledger=Ledger()), + ledger=lg, + acceptance_key=new_key, + ) + + # Rebind with the right OLD key → NEW binds. rebind_acceptance_key_commitment(lg, old_key, new_key) assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) == ( acceptance_key_commitment(new_key) ) + + # After rebind: startup with NEW succeeds. ensure_acceptance_key_commitment(lg, new_key) + ReleaseVerifier( + MockPayPalClient(), + PermitStore(ledger=Ledger()), + ledger=lg, + acceptance_key=new_key, + ) finally: lg.close() -def test_rebind_refused_with_wrong_old_key(tmp_path): +@pytest.mark.parametrize( + "old_key_factory,label", + [ + (_old_key_48, "48_bytes"), + (_old_key_token_hex_32, "token_hex_32_64_chars"), + ], + ids=["48_bytes", "token_hex_32_64_chars"], +) +def test_rebind_refused_with_wrong_old_key(tmp_path, old_key_factory, label): from permit.sqlite_ledger import ( ACCEPTANCE_KEY_COMMITMENT_META, SqliteLedger, @@ -449,10 +520,11 @@ def test_rebind_refused_with_wrong_old_key(tmp_path): rebind_acceptance_key_commitment, ) - old_key = os.urandom(32).hex().encode("utf-8") - wrong = os.urandom(32).hex().encode("utf-8") + old_key = old_key_factory() + wrong = os.urandom(len(old_key)) + assert wrong != old_key new_key = os.urandom(MIN) - db = tmp_path / "mig_bad.db" + db = tmp_path / f"mig_bad_{label}.db" lg = SqliteLedger(str(db)) try: lg.set_meta( From 0de2140d33dc0cf15edfe37d973a84b724fc98c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 19:28:30 +0000 Subject: [PATCH 22/22] docs: suite count 463 for r5 rebind length params Co-authored-by: Marcus Richards --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 057d68c..8da2290 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ check → reserve cap → PayPal AUTHORIZE hold → escrow registered ## Evidence -- **461 tests pass, 1 skipped:** `python -m pytest -rs` and `python -O -m pytest -rs` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854 + 1888 + r5). CI runs the suite with `-q -rs`, plus `-v` for the key-file tests only; also `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. Mutant sweep: `python -m scripts.mutant_sweep_demo_key` → 31/31. +- **463 tests pass, 1 skipped:** `python -m pytest -rs` and `python -O -m pytest -rs` on `launch/53-key-handling` (issue #53 / REA seq 1690 + 1801 + 1854 + 1888 + r5). CI runs the suite with `-q -rs`, plus `-v` for the key-file tests only; also `python demo.py` with a test-only `PERMIT_ACCEPTANCE_KEY` on every push; forge also runs on pull_request. Mutant sweep: `python -m scripts.mutant_sweep_demo_key` → 31/31. - **Mock end-to-end demo:** `PERMIT_ACCEPTANCE_KEY=... python demo.py` (or `python demo.py --allow-demo-key`) ends with `ledger chain: VERIFIED (16 receipts)`. - **Mock rail vs sandbox, kept separate:** - `demos/demo_six_beat.py` runs on the mock rail, with no PayPal network or credentials. Its agent still needs `PERMIT_AGENT_CLI` or a recorded transcript — one ships as `demos/fixtures/demo_transcript.jsonl` for the offline replay path.