Skip to main content

av_receipts/
chain.rs

1//! Session event-chain hashing.
2//!
3//! `h₀ = SHA-256("av-genesis" ‖ session_id)`;
4//! `hᵢ = SHA-256(hᵢ₋₁ ‖ JCS(eventᵢ))`.
5//!
6//! Ordering is driven by the per-session sequence number (events are fed in
7//! seq order) — never by wall-clock timestamps (D13.6). Any tamper, drop,
8//! reorder, or substitution changes the head hash and breaks verification.
9
10use crate::jcs::{canonicalize, JcsError};
11use sha2::{Digest, Sha256};
12
13/// Incrementally computed hash chain over a session's OCSF events.
14#[derive(Debug, Clone)]
15pub struct EventChain {
16    head: [u8; 32],
17    count: u64,
18}
19
20impl EventChain {
21    /// Start a chain for `session_id`.
22    pub fn new(session_id: &str) -> Self {
23        let mut h = Sha256::new();
24        h.update(b"av-genesis");
25        h.update(session_id.as_bytes());
26        Self {
27            head: h.finalize().into(),
28            count: 0,
29        }
30    }
31
32    /// Append an event (as JSON) to the chain.
33    pub fn append(&mut self, event: &serde_json::Value) -> Result<(), JcsError> {
34        let canon = canonicalize(event)?;
35        let mut h = Sha256::new();
36        h.update(self.head);
37        h.update(canon.as_bytes());
38        self.head = h.finalize().into();
39        self.count += 1;
40        Ok(())
41    }
42
43    /// Current head hash, hex-encoded.
44    pub fn head_hex(&self) -> String {
45        hex::encode(self.head)
46    }
47
48    /// Number of appended events.
49    pub fn count(&self) -> u64 {
50        self.count
51    }
52
53    /// Recompute a chain from scratch over `events` (offline verification).
54    pub fn compute(session_id: &str, events: &[serde_json::Value]) -> Result<Self, JcsError> {
55        let mut chain = Self::new(session_id);
56        for e in events {
57            chain.append(e)?;
58        }
59        Ok(chain)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    #![allow(
66        clippy::unwrap_used,
67        clippy::expect_used,
68        clippy::panic,
69        clippy::indexing_slicing
70    )]
71
72    use super::*;
73    use serde_json::json;
74
75    fn events() -> Vec<serde_json::Value> {
76        (0..5)
77            .map(|i| json!({"seq": i, "payload": format!("event-{i}")}))
78            .collect()
79    }
80
81    #[test]
82    fn deterministic() {
83        let a = EventChain::compute("sess", &events()).unwrap();
84        let b = EventChain::compute("sess", &events()).unwrap();
85        assert_eq!(a.head_hex(), b.head_hex());
86        assert_eq!(a.count(), 5);
87    }
88
89    #[test]
90    fn session_id_binds_the_genesis() {
91        let a = EventChain::compute("sess-1", &events()).unwrap();
92        let b = EventChain::compute("sess-2", &events()).unwrap();
93        assert_ne!(a.head_hex(), b.head_hex());
94    }
95
96    #[test]
97    fn tamper_any_event_changes_head() {
98        let baseline = EventChain::compute("s", &events()).unwrap().head_hex();
99        for i in 0..5 {
100            let mut evs = events();
101            evs[i]["payload"] = json!("tampered");
102            let h = EventChain::compute("s", &evs).unwrap().head_hex();
103            assert_ne!(h, baseline, "tamper at index {i} undetected");
104        }
105    }
106
107    #[test]
108    fn reorder_detected() {
109        let baseline = EventChain::compute("s", &events()).unwrap().head_hex();
110        let mut evs = events();
111        evs.swap(1, 3);
112        assert_ne!(EventChain::compute("s", &evs).unwrap().head_hex(), baseline);
113    }
114
115    #[test]
116    fn drop_detected() {
117        let baseline = EventChain::compute("s", &events()).unwrap().head_hex();
118        let mut evs = events();
119        evs.remove(2);
120        assert_ne!(EventChain::compute("s", &evs).unwrap().head_hex(), baseline);
121    }
122
123    #[test]
124    fn key_order_of_event_json_is_irrelevant() {
125        let a = vec![serde_json::from_str::<serde_json::Value>(r#"{"x":1,"y":2}"#).unwrap()];
126        let b = vec![serde_json::from_str::<serde_json::Value>(r#"{"y":2,"x":1}"#).unwrap()];
127        assert_eq!(
128            EventChain::compute("s", &a).unwrap().head_hex(),
129            EventChain::compute("s", &b).unwrap().head_hex(),
130            "JCS must make key order irrelevant"
131        );
132    }
133
134    #[test]
135    fn empty_chain_is_genesis_only() {
136        let c = EventChain::new("s");
137        assert_eq!(c.count(), 0);
138        assert_eq!(c.head_hex().len(), 64);
139    }
140}