2026-07-25 04:58:39 +05:30
|
|
|
use chrono::{DateTime, SubsecRound, Utc};
|
2026-03-09 15:15:25 -04:00
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
|
|
|
|
|
use crate::entry::AuditEntry;
|
|
|
|
|
use crate::error::AuditError;
|
|
|
|
|
|
2026-06-29 12:39:02 -04:00
|
|
|
/// The 32-byte sentinel hashed in place of `prev_hash` for a community's first
|
|
|
|
|
/// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes.
|
|
|
|
|
pub const GENESIS_HASH: [u8; 32] = [0u8; 32];
|
2026-03-09 15:15:25 -04:00
|
|
|
|
2026-07-25 04:58:39 +05:30
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 12:39:02 -04:00
|
|
|
/// SHA-256 over the entry's identity, chain, and context fields.
|
2026-03-09 15:15:25 -04:00
|
|
|
///
|
2026-06-29 12:39:02 -04:00
|
|
|
/// 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.
|
2026-03-09 15:15:25 -04:00
|
|
|
///
|
2026-07-25 04:58:39 +05:30
|
|
|
/// `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.
|
|
|
|
|
///
|
2026-06-29 12:39:02 -04:00
|
|
|
/// `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.
|
|
|
|
|
pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> {
|
2026-03-09 15:15:25 -04:00
|
|
|
let mut hasher = Sha256::new();
|
2026-06-29 12:39:02 -04:00
|
|
|
// Tenant binding: community_id leads the hash.
|
|
|
|
|
hasher.update(entry.community_id.as_bytes());
|
2026-03-09 15:15:25 -04:00
|
|
|
hasher.update(entry.seq.to_be_bytes());
|
2026-07-25 04:58:39 +05:30
|
|
|
hasher.update(
|
|
|
|
|
to_storage_precision(entry.created_at)
|
|
|
|
|
.to_rfc3339()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
);
|
2026-03-09 15:15:25 -04:00
|
|
|
hasher.update(entry.action.as_str().as_bytes());
|
2026-06-29 12:39:02 -04:00
|
|
|
match &entry.actor_pubkey {
|
|
|
|
|
Some(pk) => {
|
|
|
|
|
hasher.update([1u8]); // presence tag — distinguishes Some(empty) from None
|
|
|
|
|
hasher.update(pk);
|
|
|
|
|
}
|
|
|
|
|
None => hasher.update([0u8]),
|
2026-03-09 15:15:25 -04:00
|
|
|
}
|
2026-06-29 12:39:02 -04:00
|
|
|
match &entry.object_id {
|
|
|
|
|
Some(id) => {
|
|
|
|
|
hasher.update([1u8]);
|
|
|
|
|
hasher.update(id.as_bytes());
|
|
|
|
|
}
|
|
|
|
|
None => hasher.update([0u8]),
|
|
|
|
|
}
|
|
|
|
|
hasher.update(canonical_json(&entry.detail)?.as_bytes());
|
|
|
|
|
match &entry.prev_hash {
|
|
|
|
|
Some(h) => hasher.update(h),
|
|
|
|
|
None => hasher.update(GENESIS_HASH),
|
|
|
|
|
}
|
|
|
|
|
Ok(hasher.finalize().into())
|
2026-03-09 15:15:25 -04:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 12:39:02 -04:00
|
|
|
/// Serialize a JSON value with sorted object keys for deterministic output.
|
2026-03-09 15:15:25 -04:00
|
|
|
///
|
2026-06-29 12:39:02 -04:00
|
|
|
/// Propagates any scalar serialization error rather than substituting a
|
|
|
|
|
/// placeholder — a hash must never silently stand in an empty value for a real
|
|
|
|
|
/// payload.
|
2026-03-09 15:15:25 -04:00
|
|
|
fn canonical_json(value: &serde_json::Value) -> Result<String, serde_json::Error> {
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
match value {
|
|
|
|
|
Value::Object(map) => {
|
|
|
|
|
let sorted: BTreeMap<&str, &Value> = map.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
|
|
|
|
let mut out = String::from("{");
|
|
|
|
|
let mut first = true;
|
|
|
|
|
for (k, v) in &sorted {
|
|
|
|
|
if !first {
|
|
|
|
|
out.push(',');
|
|
|
|
|
}
|
|
|
|
|
first = false;
|
|
|
|
|
out.push_str(&serde_json::to_string(k)?);
|
|
|
|
|
out.push(':');
|
|
|
|
|
out.push_str(&canonical_json(v)?);
|
|
|
|
|
}
|
|
|
|
|
out.push('}');
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
Value::Array(arr) => {
|
|
|
|
|
let mut out = String::from("[");
|
|
|
|
|
let mut first = true;
|
|
|
|
|
for v in arr {
|
|
|
|
|
if !first {
|
|
|
|
|
out.push(',');
|
|
|
|
|
}
|
|
|
|
|
first = false;
|
|
|
|
|
out.push_str(&canonical_json(v)?);
|
|
|
|
|
}
|
|
|
|
|
out.push(']');
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
other => serde_json::to_string(other),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::{action::AuditAction, entry::AuditEntry};
|
|
|
|
|
use chrono::Utc;
|
2026-06-29 12:39:02 -04:00
|
|
|
use uuid::Uuid;
|
2026-03-09 15:15:25 -04:00
|
|
|
|
|
|
|
|
fn sample_entry() -> AuditEntry {
|
|
|
|
|
AuditEntry {
|
2026-06-29 12:39:02 -04:00
|
|
|
community_id: Uuid::from_u128(1),
|
2026-03-09 15:15:25 -04:00
|
|
|
seq: 1,
|
2026-06-29 12:39:02 -04:00
|
|
|
hash: Vec::new(),
|
|
|
|
|
prev_hash: None,
|
|
|
|
|
action: AuditAction::EventCreated,
|
|
|
|
|
actor_pubkey: Some(vec![0xab; 32]),
|
|
|
|
|
object_id: Some("abc123".into()),
|
|
|
|
|
detail: serde_json::Value::Null,
|
|
|
|
|
created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
2026-03-09 15:15:25 -04:00
|
|
|
.unwrap()
|
|
|
|
|
.with_timezone(&Utc),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 04:58:39 +05:30
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 15:15:25 -04:00
|
|
|
#[test]
|
|
|
|
|
fn deterministic() {
|
|
|
|
|
let entry = sample_entry();
|
|
|
|
|
assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap());
|
2026-06-29 12:39:02 -04:00
|
|
|
assert_eq!(compute_hash(&entry).unwrap().len(), 32);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 04:58:39 +05:30
|
|
|
#[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()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 12:39:02 -04:00
|
|
|
#[test]
|
|
|
|
|
fn community_id_is_part_of_identity() {
|
|
|
|
|
// The whole point: the same logical entry in two communities hashes
|
|
|
|
|
// differently, so a row can't be replayed across chains.
|
|
|
|
|
let a = sample_entry();
|
|
|
|
|
let mut b = a.clone();
|
|
|
|
|
b.community_id = Uuid::from_u128(2);
|
|
|
|
|
assert_ne!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap());
|
2026-03-09 15:15:25 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn sensitive_to_each_field() {
|
|
|
|
|
let base = sample_entry();
|
|
|
|
|
let h0 = compute_hash(&base).unwrap();
|
|
|
|
|
|
|
|
|
|
let mut e = base.clone();
|
|
|
|
|
e.seq = 2;
|
|
|
|
|
assert_ne!(h0, compute_hash(&e).unwrap());
|
|
|
|
|
|
|
|
|
|
let mut e = base.clone();
|
2026-06-29 12:39:02 -04:00
|
|
|
e.action = AuditAction::EventDeleted;
|
2026-03-09 15:15:25 -04:00
|
|
|
assert_ne!(h0, compute_hash(&e).unwrap());
|
|
|
|
|
|
|
|
|
|
let mut e = base.clone();
|
2026-06-29 12:39:02 -04:00
|
|
|
e.actor_pubkey = Some(vec![0xcd; 32]);
|
2026-03-09 15:15:25 -04:00
|
|
|
assert_ne!(h0, compute_hash(&e).unwrap());
|
|
|
|
|
|
2026-06-29 12:39:02 -04:00
|
|
|
let mut e = base.clone();
|
|
|
|
|
e.object_id = Some("different".into());
|
2026-03-09 15:15:25 -04:00
|
|
|
assert_ne!(h0, compute_hash(&e).unwrap());
|
2026-06-29 12:39:02 -04:00
|
|
|
|
|
|
|
|
let mut e = base.clone();
|
|
|
|
|
e.detail = serde_json::json!({"key": "value"});
|
|
|
|
|
assert_ne!(h0, compute_hash(&e).unwrap());
|
|
|
|
|
|
|
|
|
|
let mut e = base.clone();
|
|
|
|
|
e.prev_hash = Some(vec![0xff; 32]);
|
|
|
|
|
assert_ne!(h0, compute_hash(&e).unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn presence_tag_distinguishes_none_from_empty() {
|
|
|
|
|
// Some(empty) must not collide with None — the presence tag prevents it.
|
|
|
|
|
let mut none = sample_entry();
|
|
|
|
|
none.actor_pubkey = None;
|
|
|
|
|
let mut empty = sample_entry();
|
|
|
|
|
empty.actor_pubkey = Some(Vec::new());
|
|
|
|
|
assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap());
|
2026-03-09 15:15:25 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn canonical_json_key_order_is_stable() {
|
|
|
|
|
let a = serde_json::json!({"z": 1, "a": 2, "m": 3});
|
|
|
|
|
let b = serde_json::json!({"a": 2, "m": 3, "z": 1});
|
|
|
|
|
assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap());
|
|
|
|
|
}
|
|
|
|
|
}
|