open-standards/openpay/crates/op-screening/src/audit.rs
Transaction Science 6575ff1eb7 Ingest batch 3: 9 more crates across openpay / smart-byte / eoc
openpay/ (+3 crates)
  op-bnpl/        Affirm + Klarna + Afterpay/Clearpay rails. Full lifecycle
                  typestate (Initiated→Approved→Captured→Settled→Refunded).
                  Per-provider sandbox+production base URLs, regions (NA/EU/OC
                  for Klarna; US/AuNz/Gb/Ca/Eu for Afterpay). HMAC-SHA256
                  webhook verification with constant-time compare. 61 tests.
  op-batch/       NACHA ACH (94-char records, PPD/CCD/WEB/TEL/CTX, R01-R99
                  return codes, Same-Day ACH cutoffs); SEPA pain.001.001.09
                  (CT) + pain.008.001.08 (DD) with SCT-Inst flagging; Fedwire
                  MT103/MT202/CHIPS + pacs.008.001.12 / pacs.009.001.10 +
                  UETR; Bacs Standard 18. Per-rail cutoff scheduler. Exception
                  taxonomy unified across rails. 51 tests.
  op-screening/   OFAC SDN + EU consolidated + UN consolidated + HMT UK
                  consolidated + Australia DFAT + Canada SEMA + Japan MOF.
                  Levenshtein + Jaro-Winkler + Soundex + Metaphone + token
                  Jaccard scoring. Unicode NFKD normalisation + Cyrillic /
                  Arabic transliteration. Ed25519-signed BLAKE3-chained
                  audit log. BSA helpers: CTR + SAR pattern detection
                  (structuring, round-dollar, rapid-in-out). 43 tests.

smart-byte/smart-byte-rs/ (+3 crates)
  smart-byte-bbs/      BBS+ signatures (IETF draft-irtf-cfrg-bbs-signatures-08).
                       BLS12-381 keys, signatures, disclosure proofs with
                       cryptographic unlinkability. bbs-2023 Data Integrity
                       cryptosuite. AnonCreds-shaped link secrets +
                       predicate proofs. SecretKey zeroize-on-drop. 49 tests.
  smart-byte-didcomm/  DIDComm v2 messaging per DIF spec. Pack-encrypted
                       (ECDH-1PU + ECDH-ES via X25519), pack-signed (JWS),
                       pack-plaintext. Aries protocols: issue-credential v3,
                       present-proof v3, basic-message, trust-ping, discover-
                       features, out-of-band, coordinate-mediation, message-
                       pickup. Mediator role with pluggable storage. 23 tests.
  smart-byte-mdl/      ISO/IEC 18013-5 mobile driver's licence + 18013-7
                       online presentment. mDoc + MSO with salted-hash
                       selective disclosure (per item; SHA-256 digest tree).
                       org.iso.18013.5.1 namespace + AAMVA add-on. ES256
                       device-auth + issuer-auth. QR-code device-engagement
                       scaffolding. 28 tests.

eoc/eoc-rs/ (+3 crates)
  eoc-local/      Local-inference backends behind one NeuralBackend:
                  llama.cpp (GGUF, Q4/Q5/Q6/Q8/F16/F32 quantisation),
                  MLX (Apple Silicon, feature-gated), MLC-LLM (TVM, feature-
                  gated marker), ONNX Runtime. GGUF file-format reader.
                  HuggingFace tokenizers. Greedy / top-k / top-p / temperature /
                  Mirostat samplers. Model registry scanning ~/.eoc/models/.
                  Joule attribution via eoc-meter. 39 tests.
  eoc-rerank/     BM25 sparse + HNSW dense + RRF + min-max hybrid fusion.
                  Cohere v3.5 + Voyage v2 + bge-reranker v2-m3 + ms-marco
                  MiniLM + ColBERT v2 rerankers. Two-stage pipeline:
                  retrieve top-K hybrid → cross-encoder rerank → top-N.
                  KV-stage recovery shim into the cascade. 31 tests.
  eoc-eval/       15 canonical eval harnesses: MMLU, MMLU-Pro, GPQA,
                  HumanEval (pass@1 with python3 sandbox), BBH, IFEval (25
                  verifiable constraints), AlpacaEval, AGIEval, HellaSwag,
                  ARC (Easy + Challenge), TruthfulQA (MC1+MC2), BoolQ,
                  GSM8K, MATH (boxed-answer equivalence), Winogrande. Real
                  questions in BuiltinSamples. EvalRunner emits joules-per-
                  correct alongside accuracy. 49 tests.

Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 374 new tests, all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]

Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
2026-05-24 01:09:35 -04:00

308 lines
9.3 KiB
Rust

//! Cryptographic audit log.
//!
//! Each call to [`crate::screener::Screener::screen`] appends one row.
//! Every row is:
//!
//! - **Hashed** with BLAKE3 against the previous row's hash (Merkle-chained).
//! - **Signed** with Ed25519 using a key the [`AuditLog`] owns.
//!
//! Two invariants regulators care about:
//!
//! 1. Once written, an entry cannot be silently edited (the signature
//! breaks).
//! 2. An entry cannot be silently removed or reordered (the chain
//! hash breaks).
//!
//! [`AuditLog::verify`] checks both.
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::error::{Error, Result};
use crate::matching::MatchScore;
/// One row in the audit log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
/// Sequence index within the log (0-based).
pub seq: usize,
/// UTC timestamp.
pub at: DateTime<Utc>,
/// Query name (raw, untruncated — the regulator reads it).
pub query: String,
/// `clear` / `hit` / `review`.
pub decision: String,
/// Number of hits returned to the caller.
pub hit_count: usize,
/// Top entity ID and source list, if any.
pub top_hit: Option<(String, String)>,
/// Hex-encoded BLAKE3 hash of `(prev_hash || serialised body)`.
pub chain_hash: String,
/// Hex-encoded Ed25519 signature over `chain_hash`.
pub signature: String,
/// Globally unique row id (uuid v4 string).
pub id: String,
}
/// Errors specific to verification.
#[derive(Debug, Error)]
pub enum AuditVerifyError {
/// The chain hash at `seq` does not match the recomputed value.
#[error("chain hash mismatch at seq {0}")]
ChainMismatch(usize),
/// Signature verification failed at `seq`.
#[error("signature invalid at seq {0}")]
BadSignature(usize),
}
/// Chained, signed in-memory audit log.
#[derive(Debug)]
pub struct AuditLog {
signing_key: SigningKey,
verifying_key: VerifyingKey,
entries: Vec<AuditEntry>,
}
impl AuditLog {
/// Build a fresh log with a new keypair.
#[must_use]
pub fn new() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
Self { signing_key, verifying_key, entries: Vec::new() }
}
/// Public verifying key (regulators ingest this out-of-band).
#[must_use]
pub fn verifying_key(&self) -> VerifyingKey {
self.verifying_key
}
/// All entries.
#[must_use]
pub fn entries(&self) -> &[AuditEntry] {
&self.entries
}
/// All entries, mutably. ONLY for tests that intentionally tamper.
#[cfg(test)]
pub fn entries_mut(&mut self) -> &mut Vec<AuditEntry> {
&mut self.entries
}
/// Append one row. Returns the new row's id.
pub fn record(
&mut self,
query: &str,
hits: &[MatchScore],
decision: &str,
) -> Result<String> {
let seq = self.entries.len();
let at = Utc::now();
let prev_hash = self
.entries
.last()
.map(|e| e.chain_hash.clone())
.unwrap_or_default();
let top_hit = hits.first().map(|h| {
(
h.entity.id.clone(),
h.entity.source_list.label().to_string(),
)
});
let id = uuid_v4();
// Serialise body deterministically. Bincode would be smaller, but
// JSON's deterministic-ish output is fine and lets us debug failures
// by hand. Field order is fixed by serde derive ordering.
let body = serde_json::to_string(&AuditBody {
seq,
at,
query,
decision,
hit_count: hits.len(),
top_hit: top_hit.clone(),
id: id.clone(),
})?;
// chain_hash = BLAKE3(prev_hash || body)
let mut hasher = blake3::Hasher::new();
hasher.update(prev_hash.as_bytes());
hasher.update(body.as_bytes());
let chain_hash = hex(hasher.finalize().as_bytes());
// Sign chain_hash.
let sig: Signature = self.signing_key.sign(chain_hash.as_bytes());
let signature = hex(&sig.to_bytes());
let entry = AuditEntry {
seq,
at,
query: query.to_string(),
decision: decision.to_string(),
hit_count: hits.len(),
top_hit,
chain_hash,
signature,
id: id.clone(),
};
self.entries.push(entry);
Ok(id)
}
/// Recompute every entry's chain hash and verify every signature.
///
/// Returns `Ok(())` only if the entire log is intact.
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(())
}
}
impl Default for AuditLog {
fn default() -> Self {
Self::new()
}
}
/// Internal body shape — distinct from [`AuditEntry`] so that
/// `chain_hash` / `signature` are not part of their own input.
#[derive(Debug, Serialize)]
struct AuditBody<'a> {
seq: usize,
at: DateTime<Utc>,
query: &'a str,
decision: &'a str,
hit_count: usize,
top_hit: Option<(String, String)>,
id: String,
}
/// Minimal hex encoder so we don't need an extra dep.
fn hex(bytes: &[u8]) -> String {
const TABLE: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(TABLE[(b >> 4) as usize] as char);
out.push(TABLE[(b & 0x0f) as usize] as char);
}
out
}
fn unhex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(s.len() / 2);
for chunk in bytes.chunks(2) {
let hi = hex_digit(chunk[0])?;
let lo = hex_digit(chunk[1])?;
out.push((hi << 4) | lo);
}
Some(out)
}
const fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// Tiny standalone uuid v4 generator using BLAKE3 of fresh OS bytes.
fn uuid_v4() -> String {
use rand_core::RngCore;
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_log_verifies() {
AuditLog::new().verify().expect("empty");
}
#[test]
fn record_then_verify() {
let mut log = AuditLog::new();
log.record("alpha", &[], "clear").unwrap();
log.record("beta", &[], "clear").unwrap();
log.record("gamma", &[], "clear").unwrap();
log.verify().expect("intact");
}
#[test]
fn tamper_breaks_chain() {
let mut log = AuditLog::new();
log.record("alpha", &[], "clear").unwrap();
log.record("beta", &[], "clear").unwrap();
log.record("gamma", &[], "clear").unwrap();
// Tamper with entry 1's query field. Chain rehash will fail.
log.entries_mut()[1].query = "MUTATED".to_string();
let err = log.verify().expect_err("must fail");
match err {
Error::AuditChainBroken(i) => assert_eq!(i, 1),
other => panic!("wrong err: {other:?}"),
}
}
#[test]
fn tamper_signature_only_breaks() {
let mut log = AuditLog::new();
log.record("alpha", &[], "clear").unwrap();
// Replace signature with garbage.
log.entries_mut()[0].signature =
"00".repeat(64);
let err = log.verify().expect_err("must fail");
match err {
Error::AuditSignatureInvalid => {}
other => panic!("wrong err: {other:?}"),
}
}
}