op-screening: the audit log can outlive the process that wrote it #295
2 changed files with 264 additions and 29 deletions
|
|
@ -14,6 +14,26 @@
|
|||
//! hash breaks).
|
||||
//!
|
||||
//! [`AuditLog::verify`] checks both.
|
||||
//!
|
||||
//! ## Surviving a restart
|
||||
//!
|
||||
//! Both invariants are about a log a regulator reads *later*, so the log has
|
||||
//! to outlive the process that wrote it. [`AuditLog::new`] mints a fresh
|
||||
//! keypair, which is right for a one-shot run and wrong for anything
|
||||
//! inspected after the fact: the verifying key the regulator ingested
|
||||
//! out-of-band no longer matches, and rows written before the restart cannot
|
||||
//! be verified by any process.
|
||||
//!
|
||||
//! So: [`AuditLog::with_signing_key`] signs with an operator-held key,
|
||||
//! [`AuditLog::save_entries`] / [`AuditLog::load_entries`] persist the rows
|
||||
//! (never the key — that belongs where `compliance/hsm-kms-guidance.md` says),
|
||||
//! [`AuditLog::resume`] continues the chain onto its real head, and
|
||||
//! [`verify_entries`] verifies a detached run of rows from the published
|
||||
//! verifying key alone, with no signing key and no live log.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
|
|
@ -75,6 +95,76 @@ impl AuditLog {
|
|||
Self { signing_key, verifying_key, entries: Vec::new() }
|
||||
}
|
||||
|
||||
/// Build a log that signs with a key the operator already holds, so the
|
||||
/// chain can be continued after a restart.
|
||||
///
|
||||
/// [`Self::new`] mints a fresh keypair every time it is called, which
|
||||
/// silently rotates the key a regulator was given. Hand the long-lived key
|
||||
/// in here instead; keep it where `compliance/hsm-kms-guidance.md` says to
|
||||
/// keep it.
|
||||
#[must_use]
|
||||
pub fn with_signing_key(signing_key: SigningKey) -> Self {
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
Self {
|
||||
signing_key,
|
||||
verifying_key,
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Continue an existing chain: the same signing key, plus the rows written
|
||||
/// before the restart so the next row hashes onto the real head rather
|
||||
/// than onto an empty one.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::AuditChainBroken`] or [`Error::AuditSignatureInvalid`] if the
|
||||
/// rows do not verify under this key. 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.
|
||||
pub fn resume(signing_key: SigningKey, entries: Vec<AuditEntry>) -> Result<Self> {
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
verify_entries(&entries, &verifying_key)?;
|
||||
Ok(Self {
|
||||
signing_key,
|
||||
verifying_key,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the rows to a CBOR file, as
|
||||
/// [`SanctionsIndex::save_snapshot`](crate::SanctionsIndex::save_snapshot)
|
||||
/// does for the index.
|
||||
///
|
||||
/// The signing key is deliberately **not** written: a private key does not
|
||||
/// belong in a file beside the rows it signed. Pair this with
|
||||
/// [`Self::resume`] and a key from the operator's own store.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Io`] if the file cannot be written, [`Error::Cbor`] on an
|
||||
/// encoding failure.
|
||||
pub fn save_entries(&self, path: &Path) -> Result<()> {
|
||||
let file = File::create(path)?;
|
||||
let writer = BufWriter::new(file);
|
||||
ciborium::into_writer(&self.entries, writer).map_err(|e| Error::Cbor(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read rows back from a file written by [`Self::save_entries`].
|
||||
///
|
||||
/// Returns the rows unverified — pass them to [`Self::resume`] to continue
|
||||
/// signing, or to [`verify_entries`] to check them against a published key.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Io`] if the file cannot be read, [`Error::Cbor`] on a decoding
|
||||
/// failure.
|
||||
pub fn load_entries(path: &Path) -> Result<Vec<AuditEntry>> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let entries: Vec<AuditEntry> =
|
||||
ciborium::from_reader(reader).map_err(|e| Error::Cbor(e.to_string()))?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Public verifying key (regulators ingest this out-of-band).
|
||||
#[must_use]
|
||||
pub fn verifying_key(&self) -> VerifyingKey {
|
||||
|
|
@ -156,39 +246,59 @@ impl AuditLog {
|
|||
/// Recompute every entry's chain hash and verify every signature.
|
||||
///
|
||||
/// Returns `Ok(())` only if the entire log is intact.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`verify_entries`], which this delegates to.
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
let mut prev_hash = String::new();
|
||||
for (i, e) in self.entries.iter().enumerate() {
|
||||
let body = serde_json::to_string(&AuditBody {
|
||||
seq: e.seq,
|
||||
at: e.at,
|
||||
query: &e.query,
|
||||
decision: &e.decision,
|
||||
hit_count: e.hit_count,
|
||||
top_hit: e.top_hit.clone(),
|
||||
id: e.id.clone(),
|
||||
})?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(prev_hash.as_bytes());
|
||||
hasher.update(body.as_bytes());
|
||||
let recomputed = hex(hasher.finalize().as_bytes());
|
||||
if recomputed != e.chain_hash {
|
||||
return Err(Error::AuditChainBroken(i));
|
||||
}
|
||||
let sig_bytes = unhex(&e.signature).ok_or(Error::AuditSignatureInvalid)?;
|
||||
let sig_array: [u8; 64] = sig_bytes
|
||||
.try_into()
|
||||
.map_err(|_| Error::AuditSignatureInvalid)?;
|
||||
let sig = Signature::from_bytes(&sig_array);
|
||||
self.verifying_key
|
||||
.verify(e.chain_hash.as_bytes(), &sig)
|
||||
.map_err(|_| Error::AuditSignatureInvalid)?;
|
||||
prev_hash = e.chain_hash.clone();
|
||||
}
|
||||
Ok(())
|
||||
verify_entries(&self.entries, &self.verifying_key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a detached run of rows against a published verifying key.
|
||||
///
|
||||
/// This is the regulator's path. The crate header's claim is that "regulators
|
||||
/// can inspect the screening history of any payment attempt after the fact",
|
||||
/// which requires verification to be possible from the rows plus the public
|
||||
/// key alone — no signing key, no live [`AuditLog`], no process that is still
|
||||
/// running. [`AuditLog::verify`] delegates here so there is one
|
||||
/// implementation of the two invariants rather than two that can drift.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::AuditChainBroken`] with the index of the first row whose chain
|
||||
/// hash does not recompute, or [`Error::AuditSignatureInvalid`] if a signature
|
||||
/// is malformed or does not verify under `verifying_key`.
|
||||
pub fn verify_entries(entries: &[AuditEntry], verifying_key: &VerifyingKey) -> Result<()> {
|
||||
let mut prev_hash = String::new();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let body = serde_json::to_string(&AuditBody {
|
||||
seq: e.seq,
|
||||
at: e.at,
|
||||
query: &e.query,
|
||||
decision: &e.decision,
|
||||
hit_count: e.hit_count,
|
||||
top_hit: e.top_hit.clone(),
|
||||
id: e.id.clone(),
|
||||
})?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(prev_hash.as_bytes());
|
||||
hasher.update(body.as_bytes());
|
||||
let recomputed = hex(hasher.finalize().as_bytes());
|
||||
if recomputed != e.chain_hash {
|
||||
return Err(Error::AuditChainBroken(i));
|
||||
}
|
||||
let sig_bytes = unhex(&e.signature).ok_or(Error::AuditSignatureInvalid)?;
|
||||
let sig_array: [u8; 64] = sig_bytes
|
||||
.try_into()
|
||||
.map_err(|_| Error::AuditSignatureInvalid)?;
|
||||
let sig = Signature::from_bytes(&sig_array);
|
||||
verifying_key
|
||||
.verify(e.chain_hash.as_bytes(), &sig)
|
||||
.map_err(|_| Error::AuditSignatureInvalid)?;
|
||||
prev_hash = e.chain_hash.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Default for AuditLog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
|
@ -305,4 +415,129 @@ mod tests {
|
|||
other => panic!("wrong err: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- surviving a restart ----------------------------------------
|
||||
|
||||
fn key(seed: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[seed; 32])
|
||||
}
|
||||
|
||||
fn tempdir() -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let uniq = format!(
|
||||
"op-screening-audit-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
p.push(uniq);
|
||||
std::fs::create_dir_all(&p).expect("mkdir");
|
||||
p
|
||||
}
|
||||
|
||||
/// The restart the crate header's claim depends on: two rows written, the
|
||||
/// process ends, the rows come back and the chain continues onto its real
|
||||
/// head rather than onto an empty one.
|
||||
#[test]
|
||||
fn a_restart_continues_the_chain() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("audit.cbor");
|
||||
|
||||
let mut before = AuditLog::with_signing_key(key(1));
|
||||
before.record("Alpha Corp", &[], "clear").expect("row 0");
|
||||
before.record("Beta Ltd", &[], "review").expect("row 1");
|
||||
before.save_entries(&path).expect("save");
|
||||
let head = before.entries().last().expect("two rows").chain_hash.clone();
|
||||
drop(before);
|
||||
|
||||
let rows = AuditLog::load_entries(&path).expect("load");
|
||||
assert_eq!(rows.len(), 2, "both pre-restart rows came back");
|
||||
let mut after = AuditLog::resume(key(1), rows).expect("the rows verify under this key");
|
||||
after.record("Gamma GmbH", &[], "clear").expect("row 2");
|
||||
|
||||
let all = after.entries();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[2].seq, 2, "the sequence continues, it does not restart at 0");
|
||||
assert_ne!(
|
||||
all[2].chain_hash, head,
|
||||
"the new row is its own link, not a repeat of the old head"
|
||||
);
|
||||
after.verify().expect("the whole chain verifies across the restart");
|
||||
}
|
||||
|
||||
/// The regulator's path: rows on disk plus the published verifying key, no
|
||||
/// signing key and no live log. That is what "inspect the screening
|
||||
/// history after the fact" has to mean.
|
||||
#[test]
|
||||
fn a_regulator_verifies_dumped_rows_with_only_the_public_key() {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("audit.cbor");
|
||||
|
||||
let mut log = AuditLog::with_signing_key(key(2));
|
||||
log.record("Delta SA", &[], "clear").expect("row");
|
||||
log.record("Epsilon BV", &[], "hit").expect("row");
|
||||
log.save_entries(&path).expect("save");
|
||||
let published = log.verifying_key();
|
||||
drop(log);
|
||||
|
||||
let rows = AuditLog::load_entries(&path).expect("load");
|
||||
verify_entries(&rows, &published).expect("the dumped rows verify on their own");
|
||||
|
||||
// Both invariants still bite on the detached rows.
|
||||
let mut edited = rows.clone();
|
||||
edited[0].query = "Not Delta SA".into();
|
||||
assert!(
|
||||
matches!(
|
||||
verify_entries(&edited, &published),
|
||||
Err(Error::AuditChainBroken(0))
|
||||
),
|
||||
"an edited row breaks its chain hash"
|
||||
);
|
||||
|
||||
let mut dropped = rows.clone();
|
||||
dropped.remove(0);
|
||||
assert!(
|
||||
verify_entries(&dropped, &published).is_err(),
|
||||
"a removed row breaks the chain"
|
||||
);
|
||||
}
|
||||
|
||||
/// Resuming onto rows a different key signed fails here, rather than
|
||||
/// building a log that fails later at the regulator over a chain nobody
|
||||
/// can repair.
|
||||
#[test]
|
||||
fn resuming_with_the_wrong_key_is_refused() {
|
||||
let mut log = AuditLog::with_signing_key(key(3));
|
||||
log.record("Zeta Oy", &[], "clear").expect("row");
|
||||
let rows = log.entries().to_vec();
|
||||
|
||||
assert!(
|
||||
AuditLog::resume(key(4), rows.clone()).is_err(),
|
||||
"the rows do not verify under this key"
|
||||
);
|
||||
AuditLog::resume(key(3), rows).expect("the right key resumes");
|
||||
}
|
||||
|
||||
/// Why `with_signing_key` exists. `new()` mints a fresh keypair, so a
|
||||
/// second run cannot verify the first run's rows at all — pinned here so
|
||||
/// it reads as a known limit of `new()` rather than a surprise found by a
|
||||
/// regulator.
|
||||
#[test]
|
||||
fn a_fresh_log_cannot_verify_another_runs_rows() {
|
||||
let mut first = AuditLog::new();
|
||||
first.record("Eta AB", &[], "clear").expect("row");
|
||||
let rows = first.entries().to_vec();
|
||||
|
||||
let second = AuditLog::new();
|
||||
assert_ne!(
|
||||
first.verifying_key().to_bytes(),
|
||||
second.verifying_key().to_bytes(),
|
||||
"each new() mints its own key"
|
||||
);
|
||||
assert!(
|
||||
verify_entries(&rows, &second.verifying_key()).is_err(),
|
||||
"so the previous run's rows are unverifiable under the new key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ pub mod screener;
|
|||
pub mod storage;
|
||||
pub mod updater;
|
||||
|
||||
pub use audit::{AuditEntry, AuditLog, AuditVerifyError};
|
||||
pub use audit::{AuditEntry, AuditLog, AuditVerifyError, verify_entries};
|
||||
pub use bsa::{CtrHelper, SarHelper, Transaction, Trigger, TriggerKind};
|
||||
pub use error::{Error, Result};
|
||||
pub use lists::{
|
||||
|
|
|
|||
Loading…
Reference in a new issue