From ebfcb93147eb3224a581576a108e09aab1271d05 Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 24 Jul 2026 03:51:31 +0530 Subject: [PATCH 1/2] fix(audit): hash created_at at the precision Postgres stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `log_inner` stamped entries with `Utc::now()` (nanoseconds) and hashed the value via `to_rfc3339()`, whose sub-second digit count follows the value. `audit_log.created_at` is `TIMESTAMPTZ` — microsecond resolution — so `verify_chain` recomputed the digest over a different byte string than the one that was written and returned `HashMismatch` on untampered data. Every chain backed by a real database failed verification at its first entry, which also made a genuinely forged row indistinguishable from the baseline failure. Reduce `created_at` to the stored precision before hashing, so the in-memory entry and the row are byte-identical. `log_inner` is the only place that assigns it — every caller goes through `NewAuditEntry`, which carries no timestamp — so this is a single choke point. Tests: three Postgres-free tests in `hash.rs` pinning the invariant, the trap that caused it, and the round trip; plus a non-ignored `service.rs` test that the write path's timestamp carries no sub-microsecond digits, so a regression is caught by `just test-unit` rather than only by the `#[ignore]` chain tests. Rows written before this stay unverifiable — they always were — so no migration is needed, but an operator relying on an existing chain has to re-anchor. Fixes #2637 Signed-off-by: Shani Singh --- crates/buzz-audit/src/hash.rs | 72 ++++++++++++++++++++++++++++++++ crates/buzz-audit/src/service.rs | 27 +++++++++++- 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..2de6abcd6a 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, SubsecRound, Utc}; use sha2::{Digest, Sha256}; use crate::entry::AuditEntry; @@ -7,12 +8,30 @@ use crate::error::AuditError; /// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes. pub const GENESIS_HASH: [u8; 32] = [0u8; 32]; +/// Reduce a timestamp to the precision the audit store round-trips. +/// +/// `audit_log.created_at` is `TIMESTAMPTZ`, which Postgres keeps at microsecond +/// resolution. [`compute_hash`] covers `created_at.to_rfc3339()`, and that +/// string's sub-second digit count follows the value (chrono emits 0, 3, 6 or 9 +/// digits), so a timestamp carrying nanoseconds hashes to a digest that can +/// never be recomputed from the stored row — the entry is written with one +/// preimage and verified against another. +/// +/// Every `created_at` must therefore pass through here *before* it is hashed +/// and stored, so the in-memory entry and the row are byte-identical. +pub fn to_storage_precision(created_at: DateTime) -> DateTime { + created_at.trunc_subsecs(6) +} + /// SHA-256 over the entry's identity, chain, and context fields. /// /// Field order is fixed — changing it invalidates all existing chains. The /// `community_id` is hashed first so chain identity carries the tenant: an entry /// cannot be lifted out of one community's chain and re-verified inside another. /// +/// `created_at` must already be at [`to_storage_precision`] — see that function +/// for why sub-microsecond digits break verification. +/// /// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is /// stable across machines and Rust versions. A serialization failure is a hard /// error, never silently hashed as empty. @@ -111,6 +130,17 @@ mod tests { } } + /// A wall-clock instant carrying sub-microsecond digits, like `Utc::now()` + /// returns on Linux (`clock_gettime`, nanosecond resolution). + fn nanosecond_instant() -> chrono::DateTime { + chrono::DateTime::from_timestamp_nanos(1_700_000_000_123_456_789) + } + + /// What Postgres hands back for a `TIMESTAMPTZ`: microsecond resolution. + fn after_database_round_trip(ts: chrono::DateTime) -> chrono::DateTime { + ts.trunc_subsecs(6) + } + #[test] fn deterministic() { let entry = sample_entry(); @@ -118,6 +148,48 @@ mod tests { assert_eq!(compute_hash(&entry).unwrap().len(), 32); } + #[test] + fn storage_precision_drops_sub_microsecond_digits() { + let stored = to_storage_precision(nanosecond_instant()); + assert_eq!(stored.timestamp_subsec_nanos(), 123_456_000); + // Idempotent, so a stored value re-read from Postgres is unchanged. + assert_eq!(stored, after_database_round_trip(stored)); + } + + #[test] + fn nanosecond_timestamps_cannot_survive_a_database_round_trip() { + // The trap `to_storage_precision` exists to close. `compute_hash` + // covers an RFC-3339 string whose sub-second digit count follows the + // value, so hashing a nanosecond `created_at` produces a digest that + // cannot be recomputed once Postgres has truncated it to microseconds: + // every entry would then fail `verify_chain` with `HashMismatch`. + let ns = nanosecond_instant(); + let mut written = sample_entry(); + written.created_at = ns; + let mut read_back = sample_entry(); + read_back.created_at = after_database_round_trip(ns); + + assert_ne!( + compute_hash(&written).unwrap(), + compute_hash(&read_back).unwrap() + ); + } + + #[test] + fn storage_precision_timestamps_survive_a_database_round_trip() { + // The invariant the write path must hold: hash what will be stored, so + // recomputing from the row reproduces the digest. + let mut written = sample_entry(); + written.created_at = to_storage_precision(nanosecond_instant()); + let mut read_back = written.clone(); + read_back.created_at = after_database_round_trip(read_back.created_at); + + assert_eq!( + compute_hash(&written).unwrap(), + compute_hash(&read_back).unwrap() + ); + } + #[test] fn community_id_is_part_of_identity() { // The whole point: the same logical entry in two communities hashes diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 913131a591..fa0e0fb443 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -10,9 +10,18 @@ use crate::{ action::AuditAction, entry::{AuditEntry, NewAuditEntry}, error::AuditError, - hash::compute_hash, + hash::{compute_hash, to_storage_precision}, }; +/// The `created_at` stamped on a new entry. +/// +/// Reduced to the precision Postgres round-trips before it is hashed — see +/// [`to_storage_precision`]. Split out from [`AuditService::log_inner`] so the +/// invariant is testable without a database. +fn log_timestamp() -> DateTime { + to_storage_precision(Utc::now()) +} + /// Per-community advisory lock key. Derived in Postgres from the community UUID /// so two communities never serialize each other's audit writes (which would be /// both a throughput bottleneck and a cross-tenant timing oracle). The lock is @@ -100,7 +109,7 @@ impl AuditService { }; let seq = prev_seq + 1; - let created_at: DateTime = Utc::now(); + let created_at: DateTime = log_timestamp(); let mut audit_entry = AuditEntry { community_id, @@ -251,6 +260,7 @@ mod tests { use super::*; use crate::action::AuditAction; use crate::entry::NewAuditEntry; + use chrono::SubsecRound; use std::sync::OnceLock; use tokio::sync::Mutex; use uuid::Uuid; @@ -268,6 +278,19 @@ mod tests { PgPool::connect(&url).await.ok() } + /// Runs without Postgres, so a regression here is caught by `just + /// test-unit` rather than only by the `#[ignore]` chain tests below. + #[test] + fn log_timestamp_carries_no_sub_microsecond_digits() { + let ts = log_timestamp(); + assert_eq!( + ts, + ts.trunc_subsecs(6), + "created_at is hashed and then stored in a TIMESTAMPTZ column; \ + sub-microsecond digits make every entry fail verify_chain" + ); + } + /// A `community_id` known to exist in `communities` (FK target). Inserts a /// throwaway community row with a unique host and returns its id. async fn make_community(pool: &PgPool) -> Uuid { From 08bcce9b5856a3469cb2efd9d35156eb538fc41b Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Fri, 24 Jul 2026 06:48:19 +0530 Subject: [PATCH 2/2] fix(audit): normalize created_at inside compute_hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Truncating at the write path fixes the live break, but leaves the invariant as a convention: a future write path that stamps `created_at` without `to_storage_precision` reintroduces the write/read preimage split. Normalize at the single point that consumes the value instead, so the enforcement is structural. Truncation is idempotent and the write path already truncates, so no digest changes — verified by writing a chain with the previous commit and validating those same rows under this one. The `assert_ne!` test that pinned the trap moved down a level: it now asserts on the RFC-3339 preimage (`.123456789` vs `.123456`), which is the property that actually causes the split, and a new test asserts `compute_hash` yields the same digest either way. Signed-off-by: Shani Singh --- crates/buzz-audit/src/hash.rs | 43 +++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index 2de6abcd6a..8d6091a00c 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -29,8 +29,12 @@ pub fn to_storage_precision(created_at: DateTime) -> DateTime { /// `community_id` is hashed first so chain identity carries the tenant: an entry /// cannot be lifted out of one community's chain and re-verified inside another. /// -/// `created_at` must already be at [`to_storage_precision`] — see that function -/// for why sub-microsecond digits break verification. +/// `created_at` is normalized through [`to_storage_precision`] here rather than +/// hashed as given. Write paths truncate before storing so the row matches the +/// in-memory entry, but normalizing again at the single point that consumes the +/// value means no future caller can reintroduce the write/read preimage split +/// by forgetting to. Values already at storage precision are unaffected — +/// truncation is idempotent — so this does not change any digest. /// /// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is /// stable across machines and Rust versions. A serialization failure is a hard @@ -40,7 +44,11 @@ pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { // Tenant binding: community_id leads the hash. hasher.update(entry.community_id.as_bytes()); hasher.update(entry.seq.to_be_bytes()); - hasher.update(entry.created_at.to_rfc3339().as_bytes()); + hasher.update( + to_storage_precision(entry.created_at) + .to_rfc3339() + .as_bytes(), + ); hasher.update(entry.action.as_str().as_bytes()); match &entry.actor_pubkey { Some(pk) => { @@ -157,19 +165,34 @@ mod tests { } #[test] - fn nanosecond_timestamps_cannot_survive_a_database_round_trip() { - // The trap `to_storage_precision` exists to close. `compute_hash` - // covers an RFC-3339 string whose sub-second digit count follows the - // value, so hashing a nanosecond `created_at` produces a digest that - // cannot be recomputed once Postgres has truncated it to microseconds: - // every entry would then fail `verify_chain` with `HashMismatch`. + fn rfc3339_sub_second_width_follows_the_value() { + // The underlying trap, pinned on the preimage rather than the digest: + // chrono emits 0/3/6/9 fractional digits depending on the value, so a + // nanosecond timestamp and its microsecond truncation are *different + // strings*. Hashing the untruncated value therefore produces a digest + // that cannot be recomputed from the stored row — which is what made + // every entry fail `verify_chain` with `HashMismatch`. + let ns = nanosecond_instant(); + assert_eq!(ns.to_rfc3339(), "2023-11-14T22:13:20.123456789+00:00"); + assert_eq!( + after_database_round_trip(ns).to_rfc3339(), + "2023-11-14T22:13:20.123456+00:00" + ); + assert_ne!(ns.to_rfc3339(), after_database_round_trip(ns).to_rfc3339()); + } + + #[test] + fn compute_hash_normalizes_sub_microsecond_timestamps() { + // The enforcement point: even handed an untruncated `created_at`, + // `compute_hash` digests the storage-precision value, so a write path + // that forgot to truncate cannot split the write/read preimage. let ns = nanosecond_instant(); let mut written = sample_entry(); written.created_at = ns; let mut read_back = sample_entry(); read_back.created_at = after_database_round_trip(ns); - assert_ne!( + assert_eq!( compute_hash(&written).unwrap(), compute_hash(&read_back).unwrap() );