From efeecff5a8655c943b0bbd2289fdc9902843b9c1 Mon Sep 17 00:00:00 2001 From: Sean Gearin Date: Thu, 23 Jul 2026 12:53:46 -0400 Subject: [PATCH] feat(audit): detect chain truncation and add `buzz-admin audit verify` buzz-audit's per-community SHA-256 hash chain had no operational verifier -- verify_chain was only ever called from tests, even though the conformance notes describe audit reads as "operator-internal (consumed by buzz-admin)" -- and the verifier accepted truncated chains: it seeded expected_prev = None, so a chain with its earliest entries deleted still verified; mid-range verifies never linked the first row to the row before the range; and nothing reported coverage against an expected head, so deleting the newest rows was undetectable. Harden the walk and give operators a surface: - verify_entries (pub, pure): the chain's explicit seq-contiguity pass. A single uniform walk that enumerates entries in seq order and asserts (1) seq is contiguous and (2) prev_hash == the previous entry's hash, then recomputes each stored hash. Prefix truncation, interior deletion, and (via verify_full_chain's head check) suffix truncation all fail as breaks in that one pass, the same way tampered entries do -- no per-case detection path. Unit-tested without Postgres. This seq-contiguity framing was suggested by @SaravananJaichandar on #2620. - verify_chain: same signature, now anchored. from_seq <= 1 asserts genesis linkage (seq 1, NULL prev_hash); from_seq > 1 requires the from_seq-1 row to exist, match its own stored hash, and be linked to by the first row in range. - verify_full_chain: pages genesis-to-head and returns {entries_verified, head_seq, head_hash}; passing a previously recorded head as expected_head_seq applies the same "seq must reach where it should" rule to the tail, turning suffix truncation -- invisible to any stored-rows walk by construction -- into a hard error. - New seq-only AuditError variants (MissingGenesis, MissingAnchor, SequenceGap, TruncatedTail) name which edge of the contiguity pass broke; they are diagnostic labels over the one check, wired into the existing error-text sanitization fence so no community_id or constraint name can leak. - buzz-admin audit verify [--expect-head N]: resolves the tenant via the RELAY_URL host map like every other buzz-admin command, prints a JSON report, exits 0 verified / 1 integrity failure / 5 operational error. buzz-admin already declared the buzz-audit dependency; this is its first real use. Deliberately no new HTTP endpoint: the absence of an audit wire surface is a documented isolation property. - Wire buzz-audit into the unit-test gates: just test-unit gains cargo nextest run -p buzz-audit --lib, and the run-tests.sh fallback gains the matching cargo test step (the two crate lists mirror each other). The Postgres-backed buzz-audit tests are #[ignore]d, so the step stays infra-free -- same shape as the existing buzz-db gate. Without it the new pure chain-walk tests would compile in CI but never execute. ARCHITECTURE.md's buzz-admin subcommand table documents the new command. Co-Authored-By: Claude Fable 5 Signed-off-by: Sean Gearin --- ARCHITECTURE.md | 1 + Justfile | 4 + crates/buzz-admin/src/main.rs | 88 +++++ crates/buzz-audit/src/error.rs | 47 +++ crates/buzz-audit/src/lib.rs | 2 +- crates/buzz-audit/src/service.rs | 541 ++++++++++++++++++++++++++++++- scripts/run-tests.sh | 6 + 7 files changed, 673 insertions(+), 16 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 90cbbac0cf..983b966de7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -686,6 +686,7 @@ Subcommands: | `list-members` | List all relay members | | `generate-key` | Generate a new Nostr keypair (for bootstrapping) | | `reconcile-channels` | Emit kind:39000/39002 discovery events for channels missing them (idempotent) | +| `audit verify` | Verify the community's audit-log hash chain genesis-to-head (optional `--expect-head N` catches tail truncation); JSON report on stdout; exit 0 verified / 1 integrity failure / 5 operational error | The `buzz-admin` binary is shipped in the relay Docker image (`/usr/local/bin/buzz-admin`) and is the recommended way to manage relay membership in production. Use `./run.sh add-member`, `./run.sh remove-member`, and `./run.sh list-members` in Docker Compose deployments. diff --git a/Justfile b/Justfile index 3e4b6bee09..bb0b725bef 100644 --- a/Justfile +++ b/Justfile @@ -276,6 +276,10 @@ test-unit: # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib + # buzz-audit chain-walk tests: pure in-memory hash-chain verification + # (verify_entries) plus error-text sanitization. The Postgres-backed + # buzz-audit tests are #[ignore]d, so --lib runs only the infra-free set. + cargo nextest run -p buzz-audit --lib # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index bb30ddfae4..bf2713b4d3 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -93,6 +93,38 @@ enum Command { #[arg(long)] relay_key: Option, }, + /// Inspect the tamper-evident audit log. + Audit { + #[command(subcommand)] + command: AuditCommand, + }, +} + +#[derive(Subcommand)] +enum AuditCommand { + /// Verify this relay's audit hash chain, genesis to head. + /// + /// Walks the community's whole chain: the first entry must be the genesis + /// (seq 1, no predecessor), sequence numbers must be contiguous, every + /// entry must link to its predecessor's hash, and every stored hash must + /// match a recomputation over the stored fields. Any deletion or edit of + /// stored entries fails the walk. + /// + /// Prints a JSON report with the verified head seq and head hash. Record + /// both somewhere outside the database after each run and pass the + /// recorded seq back via --expect-head on the next run: deleting the + /// newest entries is otherwise invisible, because the head is defined by + /// what is stored. + /// + /// Exit codes: 0 = chain verified; 1 = verification failed (report on + /// stdout says why); 5 = operational error (DB unreachable, unmapped + /// host, ...). + Verify { + /// Fail with a tail-truncation error unless the verified head has + /// reached at least this seq (from a previously recorded report). + #[arg(long)] + expect_head: Option, + }, } #[derive(Subcommand)] @@ -152,6 +184,62 @@ async fn run(cli: Cli) -> Result { reconcile_channels(relay_key).await?; Ok(0) } + Command::Audit { + command: AuditCommand::Verify { expect_head }, + } => cmd_audit_verify(expect_head).await, + } +} + +/// Run `buzz-admin audit verify [--expect-head N]`. +/// +/// Resolves the deployment's community the same way every other buzz-admin +/// command does (RELAY_URL host → durable host map), then verifies that +/// community's full audit chain. Integrity failures are the command's +/// *finding*, not an operational error: they print a JSON verdict on stdout +/// and exit 1, while DB/serialization failures propagate as errors (exit 5). +async fn cmd_audit_verify(expect_head: Option) -> Result { + let db = connect_db().await?; + let tenant = resolve_admin_tenant(&db).await?; + + // The audit service owns a plain PgPool rather than a Db — mirror the + // relay's own construction (buzz-relay main.rs) with a small dedicated + // pool for this one-shot read. + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let audit_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&db_url) + .await + .map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?; + let audit = buzz_audit::AuditService::new(audit_pool); + + match audit + .verify_full_chain(tenant.community(), expect_head) + .await + { + Ok(report) => { + let out = serde_json::json!({ + "ok": true, + "community": tenant.community().as_uuid(), + "entries_verified": report.entries_verified, + "head_seq": report.head_seq, + "head_hash": report.head_hash, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(0) + } + Err( + e @ (buzz_audit::AuditError::Database(_) | buzz_audit::AuditError::Serialization(_)), + ) => Err(e.into()), + Err(e) => { + let out = serde_json::json!({ + "ok": false, + "community": tenant.community().as_uuid(), + "error": e.to_string(), + }); + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(1) + } } } diff --git a/crates/buzz-audit/src/error.rs b/crates/buzz-audit/src/error.rs index b4ffd24d83..102d19660a 100644 --- a/crates/buzz-audit/src/error.rs +++ b/crates/buzz-audit/src/error.rs @@ -31,6 +31,43 @@ pub enum AuditError { seq: i64, }, + /// A verification that should have started at the chain's genesis found a + /// different first entry — the earliest entries have been removed. + #[error("chain does not start at genesis: expected seq 1 with no predecessor, found seq {found_seq} first")] + MissingGenesis { + /// Per-community sequence number of the first entry actually found. + found_seq: i64, + }, + + /// A mid-chain verification could not find the entry immediately before + /// its range. In an append-only chain every predecessor must exist, so a + /// missing anchor means entries in front of the range were removed. + #[error("missing anchor entry at seq {anchor_seq}: the verified segment cannot be linked to the preceding chain")] + MissingAnchor { + /// Per-community sequence number of the absent anchor entry. + anchor_seq: i64, + }, + + /// Sequence numbers stopped being contiguous — one or more entries were + /// removed from the middle of the chain. + #[error("chain gap: expected seq {expected_seq} next but found seq {found_seq}")] + SequenceGap { + /// Per-community sequence number that should have come next. + expected_seq: i64, + /// Per-community sequence number actually found. + found_seq: i64, + }, + + /// The verified chain head is behind the head the caller expected (from an + /// externally recorded report) — the newest entries have been removed. + #[error("chain head at seq {head_seq} is behind the expected head seq {expected_seq}: tail entries are missing")] + TruncatedTail { + /// Per-community sequence number of the verified head. + head_seq: i64, + /// Per-community sequence number the caller expected the head to reach. + expected_seq: i64, + }, + /// An unrecognised action string was found in the database. #[error("unknown audit action in database")] UnknownAction, @@ -68,6 +105,16 @@ mod tests { let domain_errors = [ AuditError::ChainViolation { seq: 7 }, AuditError::HashMismatch { seq: 42 }, + AuditError::MissingGenesis { found_seq: 9 }, + AuditError::MissingAnchor { anchor_seq: 4 }, + AuditError::SequenceGap { + expected_seq: 5, + found_seq: 8, + }, + AuditError::TruncatedTail { + head_seq: 11, + expected_seq: 15, + }, AuditError::UnknownAction, ]; diff --git a/crates/buzz-audit/src/lib.rs b/crates/buzz-audit/src/lib.rs index 0248a7dfd3..9ea8122e80 100644 --- a/crates/buzz-audit/src/lib.rs +++ b/crates/buzz-audit/src/lib.rs @@ -32,4 +32,4 @@ pub use action::AuditAction; pub use entry::{AuditEntry, NewAuditEntry}; pub use error::AuditError; pub use hash::{compute_hash, GENESIS_HASH}; -pub use service::AuditService; +pub use service::{verify_entries, AuditService, ChainVerification}; diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 913131a591..ad7006d6c5 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -146,7 +146,22 @@ impl AuditService { /// /// Reads exactly that community's chain — it can never observe another /// community's entries or head. Returns `Ok(false)` if the range is empty, - /// `Ok(true)` if the segment is internally consistent. + /// `Ok(true)` if the segment verifies. + /// + /// The segment is anchored on the left, so a verified range can't hide + /// prefix truncation: + /// + /// - `from_seq <= 1`: the first entry must be the genesis (seq 1 with no + /// predecessor). Otherwise the earliest entries were deleted and the + /// walk fails with [`AuditError::MissingGenesis`]. + /// - `from_seq > 1`: the entry at `from_seq - 1` must exist (append-only + /// chains have no legitimate holes), its stored hash must match a + /// recomputation, and the first entry in the range must link to it. + /// A missing anchor fails with [`AuditError::MissingAnchor`]. + /// + /// `to_seq` past the current head is not an error — the walk covers what + /// is stored. Detecting *tail* truncation requires an externally recorded + /// head; see [`AuditService::verify_full_chain`]. #[instrument(skip(self))] pub async fn verify_chain( &self, @@ -173,27 +188,115 @@ impl AuditService { return Ok(false); } - let mut expected_prev: Option> = None; + let entries: Vec = rows + .iter() + .map(row_to_audit_entry) + .collect::>()?; + + if from_seq > 1 { + let anchor_seq = from_seq - 1; + let Some(anchor) = self.fetch_entry(community, anchor_seq).await? else { + return Err(AuditError::MissingAnchor { anchor_seq }); + }; + // The anchor's own stored hash must match its content — otherwise + // a tampered anchor could vouch for the segment above it. + let computed = compute_hash(&anchor)?; + if computed.as_slice() != anchor.hash.as_slice() { + return Err(AuditError::HashMismatch { seq: anchor.seq }); + } + verify_entries(&entries, from_seq, Some(&anchor.hash))?; + } else { + verify_entries(&entries, 1, None)?; + } - for row in &rows { - let entry = row_to_audit_entry(row)?; + Ok(true) + } - if let Some(ref expected) = expected_prev { - // The previous entry's hash must equal this entry's prev_hash. - if entry.prev_hash.as_deref() != Some(expected.as_slice()) { - return Err(AuditError::ChainViolation { seq: entry.seq }); - } + /// Verify one community's entire chain, genesis to head, and report what + /// was covered. + /// + /// This is the operational entry point behind `buzz-admin audit verify`: + /// it runs the [`verify_entries`] seq-contiguity pass over the whole chain + /// in pages (genesis linkage, seq contiguity, prev-hash linkage, per-entry + /// hash recomputation), then applies that same "seq must reach where it + /// should" rule to the tail. + /// + /// Tail truncation — deleting the *newest* entries — is invisible to a + /// walk over stored rows, because the head is defined by what is stored. + /// Callers close that hole by recording the returned + /// [`ChainVerification::head_seq`] / [`ChainVerification::head_hash`] + /// externally and passing the recorded seq as `expected_head_seq` on the + /// next run: a head behind it fails with [`AuditError::TruncatedTail`]. + /// + /// An empty chain yields a zeroed report (nothing to verify) unless + /// `expected_head_seq` says entries should exist. + #[instrument(skip(self))] + pub async fn verify_full_chain( + &self, + community: CommunityId, + expected_head_seq: Option, + ) -> Result { + let mut next_seq = 1i64; + let mut anchor: Option> = None; + let mut entries_verified = 0u64; + let mut head_seq = 0i64; + let mut head_hash: Option = None; + + loop { + let batch = self + .get_entries(community, next_seq, VERIFY_PAGE_SIZE) + .await?; + let Some(last) = batch.last() else { break }; + + verify_entries(&batch, next_seq, anchor.as_deref())?; + + entries_verified += batch.len() as u64; + head_seq = last.seq; + head_hash = Some(hex::encode(&last.hash)); + anchor = Some(last.hash.clone()); + next_seq = last.seq + 1; + + if (batch.len() as i64) < VERIFY_PAGE_SIZE { + break; } + } - let computed = compute_hash(&entry)?; - if computed.as_slice() != entry.hash.as_slice() { - return Err(AuditError::HashMismatch { seq: entry.seq }); + if let Some(expected_seq) = expected_head_seq { + if head_seq < expected_seq { + return Err(AuditError::TruncatedTail { + head_seq, + expected_seq, + }); } - - expected_prev = Some(entry.hash); } - Ok(true) + Ok(ChainVerification { + entries_verified, + head_seq, + head_hash, + }) + } + + /// Fetch a single entry of one community's chain, if present. + async fn fetch_entry( + &self, + community: CommunityId, + seq: i64, + ) -> Result, AuditError> { + let row = sqlx::query( + r#" + SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, + object_id, detail, created_at + FROM audit_log + WHERE community_id = $1 AND seq = $2 + "#, + ) + .bind(community.as_uuid()) + .bind(seq) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(row_to_audit_entry).transpose() } /// Returns up to `limit` entries from one community's chain starting at @@ -226,6 +329,120 @@ impl AuditService { } } +/// Entries fetched per round-trip by [`AuditService::verify_full_chain`]. +const VERIFY_PAGE_SIZE: i64 = 1000; + +/// Outcome of a successful [`AuditService::verify_full_chain`] walk. +/// +/// `head_seq` and `head_hash` are the external anchor: record them after each +/// verification, and pass the recorded seq as `expected_head_seq` next time so +/// tail truncation (which a stored-rows walk cannot see) becomes detectable. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ChainVerification { + /// Number of entries that were fetched and verified. + pub entries_verified: u64, + /// Highest sequence number verified — the chain head. `0` when the chain + /// is empty. + pub head_seq: i64, + /// Hex-encoded SHA-256 of the head entry; `None` when the chain is empty. + pub head_hash: Option, +} + +/// The audit chain's **seq-contiguity pass**: verify an already-fetched, +/// ordered run of one community's entries. +/// +/// This is a single uniform walk that enumerates entries in `seq` order and +/// makes two assertions per entry — (1) `seq` is contiguous with the expected +/// next value and (2) `prev_hash` equals the previous entry's `hash` — then +/// recomputes the stored hash. Truncation is not a special case of this walk; +/// it *is* the walk failing: +/// +/// - **prefix truncation** (earliest entries deleted) leaves the run starting +/// at the wrong `seq`, so contiguity breaks at the front; +/// - **interior deletion** leaves a hole, so contiguity breaks mid-run; +/// - **suffix truncation** (newest entries deleted) leaves the sequence ending +/// short — invisible here because the run *is* the stored rows, but caught by +/// the same "seq must reach where it should" check once +/// [`AuditService::verify_full_chain`] compares the head against an +/// externally recorded one. +/// +/// So prefix, interior, and suffix truncation all fail the way tampered entries +/// do: as a break in the seq/prev_hash chain, with no per-case detection path. +/// The distinct [`AuditError`] variants ([`AuditError::MissingGenesis`], +/// [`AuditError::SequenceGap`], [`AuditError::TruncatedTail`]) are diagnostic +/// labels naming *which* edge broke — the underlying check is one pass. (Naming +/// this the seq-contiguity pass, and the observation that both truncation ends +/// surface as seq gaps, are due to @SaravananJaichandar on #2620.) +/// +/// Shared by [`AuditService::verify_chain`] and +/// [`AuditService::verify_full_chain`], and usable directly by callers that +/// obtained entries through another read path. +/// +/// The left edge of the run is explicit, so a valid-looking interior can't +/// hide a truncated front: +/// +/// - `anchor_hash` is `None` for a run that must start at the genesis: +/// `expected_first_seq` should be `1` and the first entry must be seq 1 +/// with no `prev_hash` ([`AuditError::MissingGenesis`] / +/// [`AuditError::ChainViolation`] otherwise). +/// - `anchor_hash` is `Some(hash)` for a run anchored to the stored entry at +/// `expected_first_seq - 1`; the first entry must link to that hash. +/// +/// Every entry must continue the sequence contiguously +/// ([`AuditError::SequenceGap`]), link to its predecessor's hash +/// ([`AuditError::ChainViolation`]), and hash to its stored value +/// ([`AuditError::HashMismatch`]). An empty run verifies vacuously. +pub fn verify_entries( + entries: &[AuditEntry], + expected_first_seq: i64, + anchor_hash: Option<&[u8]>, +) -> Result<(), AuditError> { + let mut expected_prev: Option> = anchor_hash.map(<[u8]>::to_vec); + + for (expected_seq, entry) in (expected_first_seq..).zip(entries.iter()) { + // Assertion 1 — seq contiguity. A break at the genesis position is + // prefix truncation; a break anywhere else is an interior gap. Both + // are the same missing-seq failure, split only for a clearer message. + if entry.seq != expected_seq { + if expected_seq == 1 && expected_prev.is_none() { + return Err(AuditError::MissingGenesis { + found_seq: entry.seq, + }); + } + return Err(AuditError::SequenceGap { + expected_seq, + found_seq: entry.seq, + }); + } + + // Assertion 2 — prev-hash linkage: this entry's prev_hash must equal + // the previous entry's hash (the anchor for the first entry, or nothing + // at all for the genesis). + let linked = match (&expected_prev, &entry.prev_hash) { + // Genesis: the first entry of a chain has no predecessor. + (None, None) => true, + // Interior: prev_hash must equal the preceding entry's hash. + (Some(want), Some(got)) => want == got, + // Genesis with a predecessor, or interior without one. + _ => false, + }; + if !linked { + return Err(AuditError::ChainViolation { seq: entry.seq }); + } + + // Independent of the chain links, every stored hash must match a + // recomputation over the entry's own fields. + let computed = compute_hash(entry)?; + if computed.as_slice() != entry.hash.as_slice() { + return Err(AuditError::HashMismatch { seq: entry.seq }); + } + + expected_prev = Some(entry.hash.clone()); + } + + Ok(()) +} + fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result { let action_str: String = row.get("action"); let action: AuditAction = action_str.parse().map_err(|_| { @@ -501,4 +718,298 @@ mod tests { .await .unwrap()); } + + // ---- pure chain-walk tests (no database) -------------------------------- + + /// Build a valid in-memory chain of `n` entries for one community. + fn build_chain(n: i64) -> Vec { + let community_id = Uuid::from_u128(0xC0FFEE); + let created_at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + let mut prev: Option> = None; + (1..=n) + .map(|seq| { + let mut e = AuditEntry { + community_id, + seq, + hash: Vec::new(), + prev_hash: prev.clone(), + action: AuditAction::EventCreated, + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some(format!("obj-{seq}")), + detail: serde_json::json!({ "seq": seq }), + created_at, + }; + e.hash = compute_hash(&e).unwrap().to_vec(); + prev = Some(e.hash.clone()); + e + }) + .collect() + } + + #[test] + fn verify_entries_accepts_valid_genesis_run() { + let chain = build_chain(5); + assert!(verify_entries(&chain, 1, None).is_ok()); + // Empty runs verify vacuously. + assert!(verify_entries(&[], 1, None).is_ok()); + } + + #[test] + fn verify_entries_detects_prefix_truncation() { + // Prefix truncation, the front edge of the seq-contiguity pass. Delete + // the genesis: the remaining interior still self-links, which is + // exactly why the old walk accepted it. Now the run starts at seq 2 + // where seq 1 was expected, so contiguity breaks at the front. + let chain = build_chain(5); + let r = verify_entries(&chain[1..], 1, None); + assert!(matches!( + r, + Err(AuditError::MissingGenesis { found_seq: 2 }) + )); + } + + #[test] + fn verify_entries_rejects_regrafted_genesis() { + // A seq-1 entry whose prev_hash is non-null (chain re-rooted onto a + // fabricated predecessor) — hash is honestly recomputed, so only the + // genesis linkage rule can catch it. + let mut chain = build_chain(3); + chain[0].prev_hash = Some(vec![0xee; 32]); + chain[0].hash = compute_hash(&chain[0]).unwrap().to_vec(); + let r = verify_entries(&chain[..1], 1, None); + assert!(matches!(r, Err(AuditError::ChainViolation { seq: 1 }))); + } + + #[test] + fn verify_entries_detects_interior_gap() { + let mut chain = build_chain(5); + chain.remove(2); // drop seq 3 + let r = verify_entries(&chain, 1, None); + assert!(matches!( + r, + Err(AuditError::SequenceGap { + expected_seq: 3, + found_seq: 4 + }) + )); + } + + #[test] + fn verify_entries_detects_tampering() { + // Mutated content without a recomputed hash → HashMismatch there. + let mut chain = build_chain(3); + chain[1].object_id = Some("forged".into()); + let r = verify_entries(&chain, 1, None); + assert!(matches!(r, Err(AuditError::HashMismatch { seq: 2 }))); + + // Mutated content *with* a recomputed hash → the next link breaks. + let mut chain = build_chain(3); + chain[1].object_id = Some("forged".into()); + chain[1].hash = compute_hash(&chain[1]).unwrap().to_vec(); + let r = verify_entries(&chain, 1, None); + assert!(matches!(r, Err(AuditError::ChainViolation { seq: 3 }))); + } + + #[test] + fn verify_entries_anchored_run_checks_left_edge() { + let chain = build_chain(5); + + // Correctly anchored mid-segment verifies. + assert!(verify_entries(&chain[2..], 3, Some(&chain[1].hash)).is_ok()); + + // A wrong anchor hash is a broken link at the first entry. + let r = verify_entries(&chain[2..], 3, Some(&[0xff; 32])); + assert!(matches!(r, Err(AuditError::ChainViolation { seq: 3 }))); + + // A segment that starts past its declared anchor point is a gap. + let r = verify_entries(&chain[3..], 3, Some(&chain[1].hash)); + assert!(matches!( + r, + Err(AuditError::SequenceGap { + expected_seq: 3, + found_seq: 4 + }) + )); + } + + #[test] + fn verify_entries_suffix_truncation_needs_the_recorded_head() { + // Suffix truncation, the back edge of the seq-contiguity pass. Unlike + // the front and interior edges, a deleted tail leaves a shorter but + // still-contiguous run, so verify_entries alone verifies it clean — + // the run *is* the stored rows, and nothing in them says how far the + // seq should have reached. + let chain = build_chain(5); + assert!(verify_entries(&chain[..4], 1, None).is_ok()); // seq 5 deleted + + // Closing the back edge therefore needs an externally recorded head to + // compare against: that comparison lives in verify_full_chain and is + // exercised end-to-end (record head, delete tail, re-verify against the + // recorded seq -> TruncatedTail) by full_chain_report_and_tail_truncation. + } + + // ---- truncation detection against the database -------------------------- + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verify_detects_deleted_genesis_prefix() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + for action in [ + AuditAction::EventCreated, + AuditAction::ChannelCreated, + AuditAction::MemberAdded, + ] { + svc.log(new_entry(c, action)).await.unwrap(); + } + + sqlx::query("DELETE FROM audit_log WHERE community_id = $1 AND seq = 1") + .bind(c) + .execute(&pool) + .await + .unwrap(); + + let r = svc.verify_chain(CommunityId::from_uuid(c), 1, 3).await; + assert!(matches!( + r, + Err(AuditError::MissingGenesis { found_seq: 2 }) + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verify_anchors_mid_chain_segments() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + for action in [ + AuditAction::EventCreated, + AuditAction::ChannelCreated, + AuditAction::MemberAdded, + ] { + svc.log(new_entry(c, action)).await.unwrap(); + } + + // A mid-chain segment verifies while its anchor entry exists… + assert!(svc + .verify_chain(CommunityId::from_uuid(c), 2, 3) + .await + .unwrap()); + + // …and fails once the entry in front of it is gone. + sqlx::query("DELETE FROM audit_log WHERE community_id = $1 AND seq = 1") + .bind(c) + .execute(&pool) + .await + .unwrap(); + let r = svc.verify_chain(CommunityId::from_uuid(c), 2, 3).await; + assert!(matches!( + r, + Err(AuditError::MissingAnchor { anchor_seq: 1 }) + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verify_detects_interior_deletion() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + for action in [ + AuditAction::EventCreated, + AuditAction::ChannelCreated, + AuditAction::MemberAdded, + ] { + svc.log(new_entry(c, action)).await.unwrap(); + } + + sqlx::query("DELETE FROM audit_log WHERE community_id = $1 AND seq = 2") + .bind(c) + .execute(&pool) + .await + .unwrap(); + + let r = svc.verify_chain(CommunityId::from_uuid(c), 1, 3).await; + assert!(matches!( + r, + Err(AuditError::SequenceGap { + expected_seq: 2, + found_seq: 3 + }) + )); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn full_chain_report_and_tail_truncation() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + let mut last_hash = Vec::new(); + for action in [ + AuditAction::EventCreated, + AuditAction::ChannelCreated, + AuditAction::MemberAdded, + ] { + last_hash = svc.log(new_entry(c, action)).await.unwrap().hash; + } + + // The report covers the whole chain and exposes the head anchor. + let report = svc + .verify_full_chain(CommunityId::from_uuid(c), None) + .await + .unwrap(); + assert_eq!(report.entries_verified, 3); + assert_eq!(report.head_seq, 3); + assert_eq!( + report.head_hash.as_deref(), + Some(hex::encode(&last_hash).as_str()) + ); + + // With the recorded head, the same chain still verifies. + assert!(svc + .verify_full_chain(CommunityId::from_uuid(c), Some(3)) + .await + .is_ok()); + + // Delete the newest entry. Without an external anchor the shortened + // chain still verifies — the head is defined by what is stored — which + // is exactly why the recorded head must be passed back in. + sqlx::query("DELETE FROM audit_log WHERE community_id = $1 AND seq = 3") + .bind(c) + .execute(&pool) + .await + .unwrap(); + let shortened = svc + .verify_full_chain(CommunityId::from_uuid(c), None) + .await + .unwrap(); + assert_eq!(shortened.head_seq, 2); + + let r = svc + .verify_full_chain(CommunityId::from_uuid(c), Some(3)) + .await; + assert!(matches!( + r, + Err(AuditError::TruncatedTail { + head_seq: 2, + expected_seq: 3 + }) + )); + } } diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4dd1142801..6eb7dd1da5 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -93,6 +93,12 @@ run_unit_tests() { run_test_step "buzz-db unit tests" \ cargo test -p buzz-db --lib -- --nocapture + # buzz-audit chain-walk tests: pure in-memory hash-chain verification + # (verify_entries) plus error-text sanitization. The Postgres-backed + # buzz-audit tests are #[ignore]d, so --lib keeps this step infra-free. + run_test_step "buzz-audit unit tests" \ + cargo test -p buzz-audit --lib -- --nocapture + # Multi-tenant conformance gate: independent replay checker + golden # fixtures (buzz-conformance). Pure in-process trace replay, no infra. run_test_step "buzz-conformance tests" \