Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions crates/buzz-audit/src/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc> =
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<Utc> =
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<Utc> =
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"
);
}
}
8 changes: 6 additions & 2 deletions crates/buzz-audit/src/service.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -100,7 +100,11 @@ impl AuditService {
};
let seq = prev_seq + 1;

let created_at: DateTime<Utc> = 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> = Utc::now().trunc_subsecs(6);

let mut audit_entry = AuditEntry {
community_id,
Expand Down