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]>