1use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use uuid::Uuid;
21
22use super::event::AuditEvent;
23use super::key_store::KeyId;
24
25pub const SCHEMA_VERSION: u32 = 2;
30
31const CHAIN_DOMAIN: &[u8] = b"vtc-audit-chain/v1\0";
35
36pub const GENESIS_HASH: [u8; 32] = [0u8; 32];
40
41fn genesis_hash() -> [u8; 32] {
45 GENESIS_HASH
46}
47
48pub const EVENT_VERSION: u32 = 1;
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct AuditEnvelope {
62 pub event_id: Uuid,
65
66 pub event_version: u32,
71
72 pub schema_version: u32,
75
76 pub timestamp: DateTime<Utc>,
79
80 pub audit_key_id: KeyId,
84
85 #[serde(with = "hash32_b64")]
89 pub actor_did_hash: [u8; 32],
90
91 pub actor_did_plain: Option<String>,
94
95 #[serde(with = "hash32_opt_b64")]
99 pub target_did_hash: Option<[u8; 32]>,
100
101 pub target_did_plain: Option<String>,
104
105 #[serde(with = "hash32_b64", default = "genesis_hash")]
111 pub prev_hash: [u8; 32],
112
113 #[serde(with = "hash32_b64", default = "genesis_hash")]
119 pub entry_hash: [u8; 32],
120
121 pub event: AuditEvent,
123}
124
125impl AuditEnvelope {
126 pub fn chain_digest(&self) -> [u8; 32] {
142 let mut h = Sha256::new();
143 h.update(CHAIN_DOMAIN);
144 h.update(self.prev_hash);
145 h.update(self.event_id.as_bytes());
146 h.update(self.event_version.to_be_bytes());
147 h.update(self.schema_version.to_be_bytes());
148 h.update(self.timestamp.to_rfc3339().as_bytes());
149 h.update(self.audit_key_id.0.as_bytes());
150 h.update(self.actor_did_hash);
151 match self.target_did_hash {
152 Some(t) => {
153 h.update([1u8]);
154 h.update(t);
155 }
156 None => h.update([0u8]),
157 }
158 let event_bytes = serde_json::to_vec(&self.event).expect("AuditEvent serializes");
159 h.update((event_bytes.len() as u64).to_be_bytes());
160 h.update(&event_bytes);
161 h.finalize().into()
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum ChainBreak {
168 TamperedEntry { index: usize, event_id: Uuid },
172 BrokenLink { index: usize, event_id: Uuid },
176}
177
178pub fn verify_chain(envelopes: &[AuditEnvelope]) -> Result<(), ChainBreak> {
187 let mut verifier = ChainVerifier::new();
188 for env in envelopes {
189 verifier.push(env)?;
190 }
191 Ok(())
192}
193
194#[derive(Debug, Clone)]
210pub struct ChainVerifier {
211 prev: Option<[u8; 32]>,
212 index: usize,
213 verified: usize,
214 skipped_legacy: usize,
215}
216
217impl Default for ChainVerifier {
218 fn default() -> Self {
219 Self::new()
220 }
221}
222
223impl ChainVerifier {
224 pub fn new() -> Self {
227 Self {
228 prev: None,
229 index: 0,
230 verified: 0,
231 skipped_legacy: 0,
232 }
233 }
234
235 pub fn resume(head: [u8; 32], index: usize) -> Self {
239 Self {
240 prev: Some(head),
241 index,
242 verified: 0,
243 skipped_legacy: 0,
244 }
245 }
246
247 pub fn push(&mut self, env: &AuditEnvelope) -> Result<(), ChainBreak> {
253 let index = self.index;
254 self.index += 1;
255
256 if env.schema_version < 2 {
257 self.skipped_legacy += 1;
260 return Ok(());
261 }
262 if env.chain_digest() != env.entry_hash {
263 return Err(ChainBreak::TamperedEntry {
264 index,
265 event_id: env.event_id,
266 });
267 }
268 let expected_prev = self.prev.unwrap_or(GENESIS_HASH);
269 if env.prev_hash != expected_prev {
270 return Err(ChainBreak::BrokenLink {
271 index,
272 event_id: env.event_id,
273 });
274 }
275 self.prev = Some(env.entry_hash);
276 self.verified += 1;
277 Ok(())
278 }
279
280 pub fn head(&self) -> Option<[u8; 32]> {
283 self.prev
284 }
285
286 pub fn index(&self) -> usize {
289 self.index
290 }
291
292 pub fn verified(&self) -> usize {
294 self.verified
295 }
296
297 pub fn skipped_legacy(&self) -> usize {
305 self.skipped_legacy
306 }
307}
308
309pub(crate) const B64: base64::engine::general_purpose::GeneralPurpose =
314 base64::engine::general_purpose::URL_SAFE_NO_PAD;
315
316pub(super) mod hash32_b64 {
317 use super::B64;
318 use base64::Engine as _;
319 use serde::{Deserialize, Deserializer, Serializer};
320
321 pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
322 s.serialize_str(&B64.encode(bytes))
323 }
324
325 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
326 let s = String::deserialize(d)?;
327 let v = B64.decode(&s).map_err(serde::de::Error::custom)?;
328 v.try_into()
329 .map_err(|_| serde::de::Error::custom("expected 32-byte hash"))
330 }
331}
332
333pub(super) mod hash32_opt_b64 {
334 use super::B64;
335 use base64::Engine as _;
336 use serde::{Deserialize, Deserializer, Serializer};
337
338 pub fn serialize<S: Serializer>(bytes: &Option<[u8; 32]>, s: S) -> Result<S::Ok, S::Error> {
339 match bytes {
340 Some(b) => s.serialize_some(&B64.encode(b)),
341 None => s.serialize_none(),
342 }
343 }
344
345 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[u8; 32]>, D::Error> {
346 let opt: Option<String> = Option::deserialize(d)?;
347 match opt {
348 Some(s) => {
349 let v = B64.decode(&s).map_err(serde::de::Error::custom)?;
350 let arr = v
351 .try_into()
352 .map_err(|_| serde::de::Error::custom("expected 32-byte hash"))?;
353 Ok(Some(arr))
354 }
355 None => Ok(None),
356 }
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn sample_envelope() -> AuditEnvelope {
365 AuditEnvelope {
366 event_id: Uuid::nil(),
367 event_version: EVENT_VERSION,
368 schema_version: SCHEMA_VERSION,
369 timestamp: DateTime::parse_from_rfc3339("2026-05-11T00:00:00Z")
370 .unwrap()
371 .with_timezone(&Utc),
372 audit_key_id: KeyId::nil(),
373 actor_did_hash: [0xAB; 32],
374 actor_did_plain: Some("did:key:z6Mk".into()),
375 target_did_hash: Some([0xCD; 32]),
376 target_did_plain: Some("did:key:z6Mk2".into()),
377 prev_hash: GENESIS_HASH,
378 entry_hash: GENESIS_HASH,
379 event: AuditEvent::CommunityProfileUpdated(
380 crate::audit::event::CommunityProfileUpdatedData {
381 fields_changed: vec!["name".into()],
382 ..Default::default()
383 },
384 ),
385 }
386 }
387
388 fn chained_envelope(prev: [u8; 32], seed: u8) -> AuditEnvelope {
391 let mut e = sample_envelope();
392 e.event_id = Uuid::from_bytes([seed; 16]);
393 e.actor_did_hash = [seed; 32];
394 e.prev_hash = prev;
395 e.entry_hash = e.chain_digest();
396 e
397 }
398
399 #[test]
400 fn envelope_roundtrips_through_serde() {
401 let e = sample_envelope();
402 let s = serde_json::to_string(&e).unwrap();
403 let back: AuditEnvelope = serde_json::from_str(&s).unwrap();
404 assert_eq!(back, e);
405 }
406
407 #[test]
408 fn hashes_serialize_as_base64url_strings() {
409 let e = sample_envelope();
410 let v: serde_json::Value = serde_json::to_value(&e).unwrap();
411 let actor = v["actor_did_hash"].as_str().unwrap();
413 assert_eq!(actor.len(), 43);
414 assert_eq!(actor, "q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s");
416 let target = v["target_did_hash"].as_str().unwrap();
417 assert_eq!(target.len(), 43);
418 }
419
420 #[test]
421 fn null_target_hash_omits_or_nulls_in_json() {
422 let mut e = sample_envelope();
423 e.target_did_hash = None;
424 e.target_did_plain = None;
425 let v: serde_json::Value = serde_json::to_value(&e).unwrap();
426 assert!(v["target_did_hash"].is_null());
427 assert!(v["target_did_plain"].is_null());
428 }
429
430 #[test]
431 fn rtbf_redaction_preserves_hashes() {
432 let mut e = sample_envelope();
433 e.actor_did_plain = None;
435 e.target_did_plain = None;
436 let s = serde_json::to_string(&e).unwrap();
437 let back: AuditEnvelope = serde_json::from_str(&s).unwrap();
438 assert!(back.actor_did_plain.is_none());
439 assert!(back.target_did_plain.is_none());
440 assert_eq!(back.actor_did_hash, [0xAB; 32]);
441 assert_eq!(back.target_did_hash, Some([0xCD; 32]));
442 }
443
444 #[test]
445 fn rtbf_redaction_does_not_break_the_chain() {
446 let e = chained_envelope(GENESIS_HASH, 0x11);
449 let mut redacted = e.clone();
450 redacted.actor_did_plain = None;
451 redacted.target_did_plain = None;
452 assert_eq!(redacted.chain_digest(), e.entry_hash);
453 assert!(verify_chain(&[redacted]).is_ok());
454 }
455
456 #[test]
457 fn well_formed_chain_verifies() {
458 let a = chained_envelope(GENESIS_HASH, 0x01);
459 let b = chained_envelope(a.entry_hash, 0x02);
460 let c = chained_envelope(b.entry_hash, 0x03);
461 assert!(verify_chain(&[a, b, c]).is_ok());
462 }
463
464 #[test]
465 fn tampered_entry_is_detected() {
466 let a = chained_envelope(GENESIS_HASH, 0x01);
467 let mut b = chained_envelope(a.entry_hash, 0x02);
468 b.event =
470 AuditEvent::CommunityProfileUpdated(crate::audit::event::CommunityProfileUpdatedData {
471 fields_changed: vec!["logoUrl".into()],
472 ..Default::default()
473 });
474 match verify_chain(&[a, b]) {
475 Err(ChainBreak::TamperedEntry { index, .. }) => assert_eq!(index, 1),
476 other => panic!("expected TamperedEntry, got {other:?}"),
477 }
478 }
479
480 #[test]
481 fn dropped_entry_breaks_the_link() {
482 let a = chained_envelope(GENESIS_HASH, 0x01);
483 let b = chained_envelope(a.entry_hash, 0x02);
484 let c = chained_envelope(b.entry_hash, 0x03);
485 match verify_chain(&[a, c]) {
487 Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 1),
488 other => panic!("expected BrokenLink, got {other:?}"),
489 }
490 }
491
492 #[test]
493 fn reordered_entries_break_the_link() {
494 let a = chained_envelope(GENESIS_HASH, 0x01);
495 let b = chained_envelope(a.entry_hash, 0x02);
496 match verify_chain(&[b, a]) {
497 Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 0),
498 other => panic!("expected BrokenLink, got {other:?}"),
499 }
500 }
501
502 #[test]
503 fn pre_v2_envelopes_are_skipped_then_chain_reanchors() {
504 let mut legacy = sample_envelope();
507 legacy.schema_version = 1;
508 legacy.prev_hash = GENESIS_HASH;
509 legacy.entry_hash = GENESIS_HASH;
510 let a = chained_envelope(GENESIS_HASH, 0x01);
511 let b = chained_envelope(a.entry_hash, 0x02);
512 assert!(verify_chain(&[legacy, a, b]).is_ok());
513 }
514
515 #[test]
516 fn incremental_verifier_matches_the_slice_form() {
517 let a = chained_envelope(GENESIS_HASH, 0x01);
518 let b = chained_envelope(a.entry_hash, 0x02);
519 let c = chained_envelope(b.entry_hash, 0x03);
520
521 let mut v = ChainVerifier::new();
522 for env in [&a, &b, &c] {
523 v.push(env).expect("chain verifies");
524 }
525 assert_eq!(v.verified(), 3);
526 assert_eq!(v.index(), 3);
527 assert_eq!(v.skipped_legacy(), 0);
528 assert_eq!(v.head(), Some(c.entry_hash));
529 assert!(verify_chain(&[a, b, c]).is_ok(), "slice form agrees");
530 }
531
532 #[test]
533 fn resumed_verifier_continues_across_a_page_boundary() {
534 let a = chained_envelope(GENESIS_HASH, 0x01);
537 let b = chained_envelope(a.entry_hash, 0x02);
538 let c = chained_envelope(b.entry_hash, 0x03);
539
540 let mut page1 = ChainVerifier::new();
541 page1.push(&a).expect("a verifies");
542 page1.push(&b).expect("b verifies");
543
544 let mut page2 = ChainVerifier::resume(page1.head().expect("head"), page1.index());
545 page2
546 .push(&c)
547 .expect("c continues the chain from page 1's head");
548 assert_eq!(page2.head(), Some(c.entry_hash));
549 assert_eq!(page2.index(), 3);
552 }
553
554 #[test]
555 fn resumed_verifier_rejects_a_page_that_does_not_follow() {
556 let a = chained_envelope(GENESIS_HASH, 0x01);
557 let b = chained_envelope(a.entry_hash, 0x02);
558 let c = chained_envelope(b.entry_hash, 0x03);
561
562 let mut page2 = ChainVerifier::resume(a.entry_hash, 1);
563 match page2.push(&c) {
564 Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 1),
565 other => panic!("expected BrokenLink, got {other:?}"),
566 }
567 }
568
569 #[test]
570 fn legacy_rows_are_counted_not_silently_dropped() {
571 let mut legacy = sample_envelope();
575 legacy.schema_version = 1;
576 legacy.prev_hash = GENESIS_HASH;
577 legacy.entry_hash = GENESIS_HASH;
578 let a = chained_envelope(GENESIS_HASH, 0x01);
579
580 let mut v = ChainVerifier::new();
581 v.push(&legacy)
582 .expect("legacy row is skipped, not an error");
583 v.push(&a).expect("chain anchors at genesis after the skip");
584 assert_eq!(v.skipped_legacy(), 1);
585 assert_eq!(v.verified(), 1);
586 assert_eq!(v.index(), 2, "index counts skipped rows too");
587 }
588
589 #[test]
590 fn pre_v2_envelope_deserializes_without_hash_fields() {
591 let mut v = serde_json::to_value(sample_envelope()).unwrap();
594 let obj = v.as_object_mut().unwrap();
595 obj.remove("prev_hash");
596 obj.remove("entry_hash");
597 let back: AuditEnvelope = serde_json::from_value(v).unwrap();
598 assert_eq!(back.prev_hash, GENESIS_HASH);
599 assert_eq!(back.entry_hash, GENESIS_HASH);
600 }
601}