diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 938fbc7..c4ff8e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,14 +33,22 @@ 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 + - 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: @@ -50,6 +58,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-key-32bytes!!!!!! run: python demo.py test-optimized: @@ -64,7 +75,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 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/.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..8da2290 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(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 ``` 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`: 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 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 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. @@ -103,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`). +- **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. - 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`. diff --git a/SECURITY.md b/SECURITY.md index acbb1be..591f5b2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,6 +36,50 @@ 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 ledgers + +- `PERMIT_ACCEPTANCE_KEY`, when set, is always used (including with `--db --allow-demo-key`). + 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`) + 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 - Our agent-systems threat posture and the method behind these reviews: see the `adversarial-seat` 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/harness/durability_harness.py b/harness/durability_harness.py index 17aab81..889426a 100644 --- a/harness/durability_harness.py +++ b/harness/durability_harness.py @@ -35,11 +35,23 @@ 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) + # 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, + **key_kwargs, + ) flow = DurableSpendPipeline(permits, paypal, verifier, ledger=ledger, durable_dir=durable_dir) return ledger, permits, paypal, verifier, flow @@ -69,12 +81,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 +112,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 +123,17 @@ 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) + 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, + **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) if permit_c is None: @@ -201,7 +231,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 +288,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 +383,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 +416,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 +443,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/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/durability.py b/permit/durability.py index 59bb63d..66aa3a3 100644 --- a/permit/durability.py +++ b/permit/durability.py @@ -131,9 +131,11 @@ 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 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 +214,78 @@ 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: + """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: + 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") + 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 the ledger is not + ephemeral (REA seq 1888 / round 5). + """ + 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 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 @@ -279,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 ffc67a4..b463fe6 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 @@ -57,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 2f8c0d2..6fe0eb4 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,50 @@ 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: + 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 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/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/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/mutant_sweep_demo_key.py b/scripts/mutant_sweep_demo_key.py new file mode 100644 index 0000000..0772e1f --- /dev/null +++ b/scripts/mutant_sweep_demo_key.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +"""Re-apply survived demo-key + REA round-5 mutants; report killed/total. + +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 (target: 31/31). +""" + +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 — with umask 0o277 mode stays 0400 + 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 + """, + ), + # ----- 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[token_hex_32_64_chars]", + """ + 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[48_bytes]", + """ + 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_durability_harness_py_k1_allow_demo_key_no_env_exit_0", + """ + 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_migration_refused_before_works_after[token_hex_32_64_chars]", + """ + 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 + """, + ), +] + + +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/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()) diff --git a/server.py b/server.py index 828aed9..12b35d9 100644 --- a/server.py +++ b/server.py @@ -57,6 +57,9 @@ Evidence, PredicateType, ReleaseVerifier, + ensure_acceptance_key_commitment, + load_or_create_demo_key, + resolve_acceptance_key, ) # ---------------------------------------------------------------- state @@ -508,6 +511,18 @@ 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 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() if args.sandbox: @@ -527,22 +542,80 @@ def main(): paypal = MockPayPalClient() log.info("mock rail (no network, no credentials)") - verifier = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=MOCK_MODE) - flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) - + # 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. + # 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 + demo_key_db_nonempty = False 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 = ReleaseVerifier(paypal, permits, ledger=ledger, allow_demo_key=MOCK_MODE) - flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) + 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" + + 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 + ) + 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, + ) + except ValueError as exc: + raise SystemExit(f"acceptance key error: {exc}") from None + + verifier = _build_verifier(permits, ledger) + flow = SpendPipeline(permits, paypal, verifier, ledger=ledger) + + if args.db: + # Explicit --db entry point: bind/refuse commitment (also enforced + # inside ReleaseVerifier for any SqliteLedger consumer). + 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 and not env_key_set: + log.info( + f"demo acceptance key persisted at {demo_key_path} " + f"(mode {demo_key_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 f637842..34c76ec 100644 --- a/settlement/verifier.py +++ b/settlement/verifier.py @@ -101,16 +101,145 @@ 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 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; +# if a durable demo_key_path is also given (server --db), the key is +# 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. 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 +import stat as _stat +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() + + +_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 silent rebind. + + 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. + 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: + 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( + "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 unless the ledger is explicitly ephemeral (round 5). + + 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). + """ + ephemeral_prop = getattr( + type(ledger), "ephemeral_acceptance_key_commitment", None + ) + 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 " + "(get_meta/set_meta required; refusing silent skip)" + ) + ensure_acceptance_key_commitment(ledger, key) def _coerce_key(key: bytes | str | None) -> bytes | None: @@ -119,28 +248,269 @@ 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 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: + raise ValueError( + f"{what} must be exactly {MIN_ACCEPTANCE_KEY_BYTES} bytes " + f"(got {len(resolved)})" + ) + 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 _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) + | getattr(_os, "O_NONBLOCK", 0) + ) + try: + return _os.open(key_path, flags) + except FileNotFoundError: + raise + except OSError as exc: + raise _key_oserror(key_path, exc) from None + + +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). + """ + 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" + ) + 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 + + +def _read_exact_demo_key(fd: int, key_path: _Path) -> bytes: + """Read exactly MIN_ACCEPTANCE_KEY_BYTES and require EOF (no trailing).""" + 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 " + 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: + 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: + _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 + 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. + - 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 — and without opening the file a second time. + """ + key_path = _Path(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: + # 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: + _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) + flags = ( + _os.O_WRONLY + | _os.O_CREAT + | _os.O_EXCL + | getattr(_os, "O_NOFOLLOW", 0) + ) + try: + 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) + _fsync_dir(key_path.parent) + return key, mode + + 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, + demo_key_db_nonempty: bool = False, ) -> 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 + (see ``load_or_create_demo_key``); 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: + 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( + "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)" + ) def sign_acceptance( @@ -149,10 +519,14 @@ 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 wrong-length keys are rejected (#53 / seq 1888). """ resolved = _coerce_key(key) - if resolved is None or len(resolved) < MIN_ACCEPTANCE_KEY_BYTES: - raise ValueError("sign_acceptance requires a key of at least 32 bytes") + if resolved is None: + 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() @@ -166,17 +540,30 @@ 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 (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, + 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 # NOTE: explicit None check — an empty Ledger is falsy via __len__. self.ledger = ledger if ledger is not None else Ledger() + # 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 9846bab..fcec9a3 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,12 +67,59 @@ 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() 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): @@ -84,6 +131,23 @@ 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, mode = load_or_create_demo_key(path) + assert k3 == k1 + assert mode == 0o600 + + def test_sign_acceptance_requires_a_real_key(): with pytest.raises(ValueError): sign_acceptance("esc", "hash", 100, b"short") @@ -123,3 +187,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_acceptance_key_commitment.py b/tests/test_acceptance_key_commitment.py new file mode 100644 index 0000000..8942416 --- /dev/null +++ b/tests/test_acceptance_key_commitment.py @@ -0,0 +1,157 @@ +"""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 lineage so it can be dropped. + +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 + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +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"} + + +def test_commitment_stored_on_first_use(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 / "commit.db" + lg = SqliteLedger(str(db)) + try: + key = os.urandom(MIN) + assert lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) is None + ensure_acceptance_key_commitment(lg, key) + stored = lg.get_meta(ACCEPTANCE_KEY_COMMITMENT_META) + assert stored == acceptance_key_commitment(key) + 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 + from settlement.verifier import ensure_acceptance_key_commitment + + 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 + 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_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, + 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)) + 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 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_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_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() 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) diff --git a/tests/test_demo_key_mutants.py b/tests/test_demo_key_mutants.py new file mode 100644 index 0000000..6f108fc --- /dev/null +++ b/tests/test_demo_key_mutants.py @@ -0,0 +1,142 @@ +"""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 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(0o277) # 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" + ) 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..382ffa7 --- /dev/null +++ b/tests/test_demo_key_rea_n_b.py @@ -0,0 +1,296 @@ +"""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): + """Own-user mode-000 file: EACCES → ValueError (never skip; fail if root).""" + monkeypatch.delenv("PERMIT_ACCEPTANCE_KEY", raising=False) + 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) + finally: + os.chmod(path, 0o600) + + +def test_chmod_000_server_exits_clean_no_traceback(monkeypatch, tmp_path): + """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) + assert key.stat().st_uid == os.geteuid() + env = {k: v for k, v in os.environ.items() if k != "PERMIT_ACCEPTANCE_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 == 1, out + assert "acceptance key error:" in out + assert "Traceback" not in out + assert "PermissionError" not in out + + +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 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). 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; " + "do not skip — fix the environment" + ) + path = tmp_path / "foreign.acceptance_key" + _write_key(path, os.urandom(MIN), 0o600) + + 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="owned by euid"): + 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 "owned by euid" in out + assert "Traceback" not in out + assert "PermissionError" not in out + finally: + 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): + """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() diff --git a/tests/test_durable_key_commitment.py b/tests/test_durable_key_commitment.py new file mode 100644 index 0000000..234ea46 --- /dev/null +++ b/tests/test_durable_key_commitment.py @@ -0,0 +1,105 @@ +"""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: 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. + assert hasattr(DurableLedger, "get_meta") + assert hasattr(DurableLedger, "set_meta") + assert hasattr(SqliteLedger, "get_meta") + assert hasattr(SqliteLedger, "set_meta") + + # Fail-closed: no ephemeral mark and no meta → refuse (silent skip banned). + class _BrokenDurable: + pass + + with pytest.raises(ValueError, match="refusing silent skip"): + _bind_ledger_acceptance_key_commitment(_BrokenDurable(), b"k" * MIN) 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/tests/test_rea_r5_commitment.py b/tests/test_rea_r5_commitment.py new file mode 100644 index 0000000..3691195 --- /dev/null +++ b/tests/test_rea_r5_commitment.py @@ -0,0 +1,620 @@ +"""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_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, + str(harness_py), + "--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 _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, + ) + + old_key = old_key_factory() + assert len(old_key) != MIN, f"{label}: old key must not be exact-32" + new_key = os.urandom(MIN) + assert len(new_key) == MIN + + db = tmp_path / f"mig_{label}.db" + lg = SqliteLedger(str(db)) + try: + # 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() + + +@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, + ) + from settlement.verifier import ( + acceptance_key_commitment, + rebind_acceptance_key_commitment, + ) + + old_key = old_key_factory() + wrong = os.urandom(len(old_key)) + assert wrong != old_key + new_key = os.urandom(MIN) + db = tmp_path / f"mig_bad_{label}.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 "") 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))