Skip to content
Open
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
97 changes: 96 additions & 1 deletion crates/buzz-audit/src/hash.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use chrono::{DateTime, SubsecRound, Utc};
use sha2::{Digest, Sha256};

use crate::entry::AuditEntry;
Expand All @@ -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<Utc>) -> DateTime<Utc> {
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.
Expand All @@ -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) => {
Expand Down Expand Up @@ -111,13 +138,81 @@ 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<Utc> {
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<Utc>) -> chrono::DateTime<Utc> {
ts.trunc_subsecs(6)
}

#[test]
fn deterministic() {
let entry = sample_entry();
assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap());
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
Expand Down
27 changes: 25 additions & 2 deletions crates/buzz-audit/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc> {
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
Expand Down Expand Up @@ -100,7 +109,7 @@ impl AuditService {
};
let seq = prev_seq + 1;

let created_at: DateTime<Utc> = Utc::now();
let created_at: DateTime<Utc> = log_timestamp();

let mut audit_entry = AuditEntry {
community_id,
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down