use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::event::AuditEvent;
use super::key_store::KeyId;
pub const SCHEMA_VERSION: u32 = 2;
const CHAIN_DOMAIN: &[u8] = b"vtc-audit-chain/v1\0";
pub const GENESIS_HASH: [u8; 32] = [0u8; 32];
fn genesis_hash() -> [u8; 32] {
GENESIS_HASH
}
pub const EVENT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditEnvelope {
pub event_id: Uuid,
pub event_version: u32,
pub schema_version: u32,
pub timestamp: DateTime<Utc>,
pub audit_key_id: KeyId,
#[serde(with = "hash32_b64")]
pub actor_did_hash: [u8; 32],
pub actor_did_plain: Option<String>,
#[serde(with = "hash32_opt_b64")]
pub target_did_hash: Option<[u8; 32]>,
pub target_did_plain: Option<String>,
#[serde(with = "hash32_b64", default = "genesis_hash")]
pub prev_hash: [u8; 32],
#[serde(with = "hash32_b64", default = "genesis_hash")]
pub entry_hash: [u8; 32],
pub event: AuditEvent,
}
impl AuditEnvelope {
pub fn chain_digest(&self) -> [u8; 32] {
let mut h = Sha256::new();
h.update(CHAIN_DOMAIN);
h.update(self.prev_hash);
h.update(self.event_id.as_bytes());
h.update(self.event_version.to_be_bytes());
h.update(self.schema_version.to_be_bytes());
h.update(self.timestamp.to_rfc3339().as_bytes());
h.update(self.audit_key_id.0.as_bytes());
h.update(self.actor_did_hash);
match self.target_did_hash {
Some(t) => {
h.update([1u8]);
h.update(t);
}
None => h.update([0u8]),
}
let event_bytes = serde_json::to_vec(&self.event).expect("AuditEvent serializes");
h.update((event_bytes.len() as u64).to_be_bytes());
h.update(&event_bytes);
h.finalize().into()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainBreak {
TamperedEntry { index: usize, event_id: Uuid },
BrokenLink { index: usize, event_id: Uuid },
}
pub fn verify_chain(envelopes: &[AuditEnvelope]) -> Result<(), ChainBreak> {
let mut prev: Option<[u8; 32]> = None;
for (index, env) in envelopes.iter().enumerate() {
if env.schema_version < 2 {
continue;
}
if env.chain_digest() != env.entry_hash {
return Err(ChainBreak::TamperedEntry {
index,
event_id: env.event_id,
});
}
let expected_prev = prev.unwrap_or(GENESIS_HASH);
if env.prev_hash != expected_prev {
return Err(ChainBreak::BrokenLink {
index,
event_id: env.event_id,
});
}
prev = Some(env.entry_hash);
}
Ok(())
}
pub(crate) const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
mod hash32_b64 {
use super::B64;
use base64::Engine as _;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&B64.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(d)?;
let v = B64.decode(&s).map_err(serde::de::Error::custom)?;
v.try_into()
.map_err(|_| serde::de::Error::custom("expected 32-byte hash"))
}
}
mod hash32_opt_b64 {
use super::B64;
use base64::Engine as _;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &Option<[u8; 32]>, s: S) -> Result<S::Ok, S::Error> {
match bytes {
Some(b) => s.serialize_some(&B64.encode(b)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[u8; 32]>, D::Error> {
let opt: Option<String> = Option::deserialize(d)?;
match opt {
Some(s) => {
let v = B64.decode(&s).map_err(serde::de::Error::custom)?;
let arr = v
.try_into()
.map_err(|_| serde::de::Error::custom("expected 32-byte hash"))?;
Ok(Some(arr))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_envelope() -> AuditEnvelope {
AuditEnvelope {
event_id: Uuid::nil(),
event_version: EVENT_VERSION,
schema_version: SCHEMA_VERSION,
timestamp: DateTime::parse_from_rfc3339("2026-05-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
audit_key_id: KeyId::nil(),
actor_did_hash: [0xAB; 32],
actor_did_plain: Some("did:key:z6Mk".into()),
target_did_hash: Some([0xCD; 32]),
target_did_plain: Some("did:key:z6Mk2".into()),
prev_hash: GENESIS_HASH,
entry_hash: GENESIS_HASH,
event: AuditEvent::CommunityProfileUpdated(
crate::audit::event::CommunityProfileUpdatedData {
fields_changed: vec!["name".into()],
..Default::default()
},
),
}
}
fn chained_envelope(prev: [u8; 32], seed: u8) -> AuditEnvelope {
let mut e = sample_envelope();
e.event_id = Uuid::from_bytes([seed; 16]);
e.actor_did_hash = [seed; 32];
e.prev_hash = prev;
e.entry_hash = e.chain_digest();
e
}
#[test]
fn envelope_roundtrips_through_serde() {
let e = sample_envelope();
let s = serde_json::to_string(&e).unwrap();
let back: AuditEnvelope = serde_json::from_str(&s).unwrap();
assert_eq!(back, e);
}
#[test]
fn hashes_serialize_as_base64url_strings() {
let e = sample_envelope();
let v: serde_json::Value = serde_json::to_value(&e).unwrap();
let actor = v["actor_did_hash"].as_str().unwrap();
assert_eq!(actor.len(), 43);
assert_eq!(actor, "q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s");
let target = v["target_did_hash"].as_str().unwrap();
assert_eq!(target.len(), 43);
}
#[test]
fn null_target_hash_omits_or_nulls_in_json() {
let mut e = sample_envelope();
e.target_did_hash = None;
e.target_did_plain = None;
let v: serde_json::Value = serde_json::to_value(&e).unwrap();
assert!(v["target_did_hash"].is_null());
assert!(v["target_did_plain"].is_null());
}
#[test]
fn rtbf_redaction_preserves_hashes() {
let mut e = sample_envelope();
e.actor_did_plain = None;
e.target_did_plain = None;
let s = serde_json::to_string(&e).unwrap();
let back: AuditEnvelope = serde_json::from_str(&s).unwrap();
assert!(back.actor_did_plain.is_none());
assert!(back.target_did_plain.is_none());
assert_eq!(back.actor_did_hash, [0xAB; 32]);
assert_eq!(back.target_did_hash, Some([0xCD; 32]));
}
#[test]
fn rtbf_redaction_does_not_break_the_chain() {
let e = chained_envelope(GENESIS_HASH, 0x11);
let mut redacted = e.clone();
redacted.actor_did_plain = None;
redacted.target_did_plain = None;
assert_eq!(redacted.chain_digest(), e.entry_hash);
assert!(verify_chain(&[redacted]).is_ok());
}
#[test]
fn well_formed_chain_verifies() {
let a = chained_envelope(GENESIS_HASH, 0x01);
let b = chained_envelope(a.entry_hash, 0x02);
let c = chained_envelope(b.entry_hash, 0x03);
assert!(verify_chain(&[a, b, c]).is_ok());
}
#[test]
fn tampered_entry_is_detected() {
let a = chained_envelope(GENESIS_HASH, 0x01);
let mut b = chained_envelope(a.entry_hash, 0x02);
b.event =
AuditEvent::CommunityProfileUpdated(crate::audit::event::CommunityProfileUpdatedData {
fields_changed: vec!["logoUrl".into()],
..Default::default()
});
match verify_chain(&[a, b]) {
Err(ChainBreak::TamperedEntry { index, .. }) => assert_eq!(index, 1),
other => panic!("expected TamperedEntry, got {other:?}"),
}
}
#[test]
fn dropped_entry_breaks_the_link() {
let a = chained_envelope(GENESIS_HASH, 0x01);
let b = chained_envelope(a.entry_hash, 0x02);
let c = chained_envelope(b.entry_hash, 0x03);
match verify_chain(&[a, c]) {
Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 1),
other => panic!("expected BrokenLink, got {other:?}"),
}
}
#[test]
fn reordered_entries_break_the_link() {
let a = chained_envelope(GENESIS_HASH, 0x01);
let b = chained_envelope(a.entry_hash, 0x02);
match verify_chain(&[b, a]) {
Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 0),
other => panic!("expected BrokenLink, got {other:?}"),
}
}
#[test]
fn pre_v2_envelopes_are_skipped_then_chain_reanchors() {
let mut legacy = sample_envelope();
legacy.schema_version = 1;
legacy.prev_hash = GENESIS_HASH;
legacy.entry_hash = GENESIS_HASH;
let a = chained_envelope(GENESIS_HASH, 0x01);
let b = chained_envelope(a.entry_hash, 0x02);
assert!(verify_chain(&[legacy, a, b]).is_ok());
}
#[test]
fn pre_v2_envelope_deserializes_without_hash_fields() {
let mut v = serde_json::to_value(sample_envelope()).unwrap();
let obj = v.as_object_mut().unwrap();
obj.remove("prev_hash");
obj.remove("entry_hash");
let back: AuditEnvelope = serde_json::from_value(v).unwrap();
assert_eq!(back.prev_hash, GENESIS_HASH);
assert_eq!(back.entry_hash, GENESIS_HASH);
}
}