From 77e87e1922be64c4716ce76617444b368bf4b623 Mon Sep 17 00:00:00 2001 From: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:09:31 -0700 Subject: [PATCH] fix(buzz-audit): truncate created_at to microseconds before hashing `Utc::now()` returns nanosecond precision but Postgres TIMESTAMPTZ stores only microseconds. `verify_chain` re-reads the truncated value and recomputes the digest, so the hashes never matched -- every chain failed verification on untampered data, making tamper detection useless. Fix: call `.trunc_subsecs(6)` in `log_inner` so the value hashed at write time is identical to what Postgres returns on read. Adds three Postgres-free regression tests to `hash.rs` documenting the invariant, the root cause, and the round-trip behaviour. Fixes #2637 Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com> --- crates/buzz-audit/src/hash.rs | 91 ++++++++++++++++++++++++++++++++ crates/buzz-audit/src/service.rs | 8 ++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index a272d4a024..a7530fae5b 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -174,4 +174,95 @@ mod tests { let b = serde_json::json!({"a": 2, "m": 3, "z": 1}); assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap()); } + + // Regression tests for #2637: created_at must be truncated to microsecond + // precision before hashing so the digest survives the round-trip through a + // TIMESTAMPTZ column (Postgres discards sub-microsecond digits on write). + + /// The trap: `to_rfc3339()` emits 9 fractional digits for nanosecond + /// timestamps and 6 for microsecond ones -- different byte strings, different + /// hashes. This test documents the invariant that the compute_hash function + /// depends on. + #[test] + fn nanosecond_and_microsecond_timestamps_hash_differently() { + use chrono::SubsecRound as _; + + // A timestamp with sub-microsecond digits. + let ns_ts: DateTime = + chrono::DateTime::parse_from_rfc3339("2026-07-24T12:00:00.123456789Z") + .unwrap() + .with_timezone(&Utc); + // The same timestamp truncated to µs -- what Postgres returns after storing it. + let us_ts = ns_ts.trunc_subsecs(6); + + assert_ne!(ns_ts, us_ts, "test setup: timestamps must differ"); + + let mut ns_entry = sample_entry(); + ns_entry.created_at = ns_ts; + let mut us_entry = sample_entry(); + us_entry.created_at = us_ts; + + assert_ne!( + compute_hash(&ns_entry).unwrap(), + compute_hash(&us_entry).unwrap(), + "nanosecond and microsecond timestamps must produce different hashes" + ); + } + + /// The fix: hashing a µs-truncated timestamp produces a digest that can be + /// reproduced after a simulated Postgres round-trip (which also truncates to + /// µs). Before the fix, the hash was computed over the nanosecond value and + /// could never be reproduced from the stored row. + #[test] + fn hash_survives_microsecond_precision_round_trip() { + use chrono::SubsecRound as _; + + // A timestamp with sub-microsecond digits, as returned by Utc::now(). + let raw: DateTime = + chrono::DateTime::parse_from_rfc3339("2026-07-24T12:00:00.123456789Z") + .unwrap() + .with_timezone(&Utc); + + // Truncate before hashing (the fix in log_inner). + let stored = raw.trunc_subsecs(6); + let mut entry = sample_entry(); + entry.created_at = stored; + let hash_at_write = compute_hash(&entry).unwrap(); + + // Simulate what verify_chain gets back from Postgres: the µs-truncated value. + let reread_from_db = stored; // TIMESTAMPTZ returns the same µs value + entry.created_at = reread_from_db; + let hash_at_verify = compute_hash(&entry).unwrap(); + + assert_eq!( + hash_at_write, hash_at_verify, + "hash computed before write must equal hash recomputed from stored row" + ); + } + + /// Guard against regressions that re-introduce nanosecond hashing: a timestamp + /// with non-zero sub-microsecond digits must round-trip only when truncated. + #[test] + fn untruncated_nanosecond_timestamp_fails_round_trip() { + use chrono::SubsecRound as _; + + let raw: DateTime = + chrono::DateTime::parse_from_rfc3339("2026-07-24T12:00:00.123456789Z") + .unwrap() + .with_timezone(&Utc); + + // Hash WITHOUT truncation (the old, broken behaviour). + let mut entry = sample_entry(); + entry.created_at = raw; + let hash_at_write = compute_hash(&entry).unwrap(); + + // Postgres discards sub-µs digits -- simulate what verify_chain reads back. + entry.created_at = raw.trunc_subsecs(6); + let hash_at_verify = compute_hash(&entry).unwrap(); + + assert_ne!( + hash_at_write, hash_at_verify, + "without truncation the hash cannot survive the Postgres round-trip" + ); + } } diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 913131a591..6147594c1a 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, SubsecRound as _, Utc}; use futures_util::FutureExt as _; use sqlx::{Acquire, PgPool, Row}; use tracing::{debug, instrument, warn}; @@ -100,7 +100,11 @@ impl AuditService { }; let seq = prev_seq + 1; - let created_at: DateTime = Utc::now(); + // Truncate to microsecond precision to match what Postgres stores in a + // TIMESTAMPTZ column (1 µs resolution). Hashing the raw nanosecond + // value would produce a digest the verifier can never reproduce after + // the sub-microsecond digits are discarded on write. See #2637. + let created_at: DateTime = Utc::now().trunc_subsecs(6); let mut audit_entry = AuditEntry { community_id,