op-screening: the audit log can outlive the process that wrote it #295

Merged
dcharlot merged 1 commit from fix/openpay-audit-log-survives-restart into main 2026-10-17 16:02:58 -04:00

2026-10-17

Transaction Science
op-screening: the audit log can outlive the process that wrote it
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 13s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m59s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 5m24s
The crate header claims every `Screener::screen` call is recorded into a
chained, signed audit log "so that regulators can inspect the screening
history of any payment attempt after the fact". The log could not do
that. Its whole public surface was `new`, `verifying_key`, `entries`,
`record` and `verify`: no save, no load, and no way to supply an
existing key. The only constructor mints a fresh keypair from `OsRng`,
and every call site in the repo is `AuditLog::new()`.

So the trail died with the process, twice over. The rows were gone, and
the key was gone — which is the worse half: on the next run `new()`
mints a new keypair, so the verifying key a regulator ingested
out-of-band no longer matches anything, and rows an operator had
dumped to disk by hand could never be verified by any process, because
`verify` only ever checked against the key the running log happened to
own. "After the fact" was the one time the log could not be read.

The same crate already persists its other long-lived state —
`SanctionsIndex::save_snapshot` / `load_snapshot`, CBOR via ciborium —
so this follows that precedent rather than inventing one:

- `AuditLog::with_signing_key` signs with a key the operator already
  holds, instead of rotating it on every start.
- `save_entries` / `load_entries` persist the rows. The signing key is
  deliberately **not** written: a private key does not belong in a file
  beside the rows it signed, and this crate ships
  `compliance/hsm-kms-guidance.md` saying where it does belong.
- `resume(signing_key, entries)` continues the chain onto its real head,
  so the next row's `prev_hash` and `seq` follow the pre-restart rows
  rather than starting over. It verifies them first: resuming onto rows
  this key did not write would build a log that fails verification
  later, at the regulator, over a chain nobody can repair — so it fails
  here instead.
- `verify_entries(entries, verifying_key)` is a free function, which is
  the regulator's path: rows plus the published public key, with no
  signing key and no live log. `AuditLog::verify` delegates to it, so
  the two invariants have one implementation rather than two that can
  drift.

Additive throughout — `new`, `Default` and every existing call site are
unchanged.

Tests: a restart writes two rows, loads them back, resumes and appends a
third, and the whole chain verifies with `seq` continuing at 2; a
regulator verifies dumped rows from the public key alone, and both
invariants still bite on the detached rows (an edited row breaks its
chain hash at index 0, a removed row breaks the chain); resuming under
the wrong key is refused; and `a_fresh_log_cannot_verify_another_runs_rows`
pins the limit of `new()` that motivated all of it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-17 09:35:49 -04:00