op-screening: the audit log can outlive the process that wrote it #295
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/openpay-audit-log-survives-restart"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
First of the "state claimed durable that dies with the process" family.
The defect
op-screening's crate header claims everyScreener::screencall is recorded into a chained, signed audit logThe log could not do that. Its entire public surface was
new,verifying_key,entries,record,verify— no save, no load, and no way to supply an existing key. The only constructor mints a fresh keypair fromOsRng, and every call site in the repo isAuditLog::new().So the trail died with the process, twice over:
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:verifyonly 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 fix
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_keysigns with a key the operator already holds, instead of rotating it on every start.save_entries/load_entriespersist 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 shipscompliance/hsm-kms-guidance.mdsaying where it does belong.resume(signing_key, entries)continues the chain onto its real head, so the next row'sprev_hashandseqfollow 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 — the regulator's path: rows plus the published public key, with no signing key and no live log.AuditLog::verifydelegates to it, so the two invariants have one implementation rather than two that can drift.Additive throughout:
new,Defaultand every existing call site are unchanged.Tests
a_restart_continues_the_chain— two rows written, saved, loaded, resumed, a third appended; the whole chain verifies andseqcontinues at 2 rather than restarting at 0.a_regulator_verifies_dumped_rows_with_only_the_public_key— verification from the rows and the published 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_with_the_wrong_key_is_refused.a_fresh_log_cannot_verify_another_runs_rows— pins the limit ofnew()that motivated all of it, so it reads as a known constraint rather than something a regulator discovers.Falsification
resumeskips verifying the rows it resumes ontoresuming_with_the_wrong_key_is_refusedFAILEDsave_entriespersists an empty vecverify_entriesalways returnsOktamper_breaks_chainandtamper_signature_only_breaksA3 is the one worth noting: moving
verify()'s body into the freeverify_entriesis a refactor of security-relevant code, and A3 shows the two pre-existing tamper tests still depend on it — so the behaviour they pin was preserved, not re-implemented beside them.Verification
On the build host (aarch64), the CI-equivalent gate for
openpay:cargo check --workspace --all-targets— exit 0cargo test --workspace— exit 0, 0 failed suites, 163 passing suitescargo test -p op-screening— 45 + 4 pass, including the pre-existingaudit_log_records_every_call_and_verifiesintegration testOne thing I did not change
AuditLog::new()still exists and still mints a fresh keypair. It is the right constructor for a one-shot process and for the ~10 test call sites, and removing it would be a breaking change that buys nothing the new constructors do not already offer. The limit is now documented on it and pinned by a test instead.🤖 Generated with Claude Code