diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index a272d4a024..8d6091a00c 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,34 @@ 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` 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 /// error, never silently hashed as empty. @@ -21,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) => { @@ -111,6 +138,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 +156,63 @@ 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 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_eq!( + 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 {