Skip to main content

krabka_audit/
chain.rs

1//! Per-broker audit hash-chain primitives.
2//!
3//! The chain binds each record to all prior records: `new_head =
4//! SHA256(prev_head ‖ seq_be ‖ value)`. The writer stamps `(seq, prev_head)`
5//! on each record. The verifier recomputes the chain and checks continuity.
6//! The writer and the verifier must use these functions. Never reimplement the
7//! formula.
8
9use sha2::{Digest, Sha256};
10
11/// Chain head before the writer writes the first record.
12pub const GENESIS_HEAD: [u8; 32] = [0u8; 32];
13
14/// Canonical chain hash. `new_head = SHA256(prev ‖ seq.to_be_bytes() ‖ value)`.
15#[must_use]
16pub fn chain_hash(prev: &[u8; 32], seq: u64, value: &[u8]) -> [u8; 32] {
17    let mut h = Sha256::new();
18    h.update(prev);
19    h.update(seq.to_be_bytes());
20    h.update(value);
21    h.finalize().into()
22}
23
24/// The chain position a record is stamped with.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct ChainLink {
27    /// Sequence number of the record.
28    pub seq: u64,
29    /// Chain head as it was before the record.
30    pub prev_head: [u8; 32],
31}
32
33/// Running per-broker chain state.
34#[derive(Debug, Clone, krabka_macros::FieldDefaults)]
35pub struct ChainState {
36    next_seq: u64,
37    #[default(GENESIS_HEAD)]
38    head: [u8; 32],
39}
40
41impl ChainState {
42    #[must_use]
43    pub fn new() -> Self { Self::default() }
44
45    /// Resume a chain from a recovered position after a restart or a spool replay.
46    #[must_use]
47    pub fn resume(next_seq: u64, head: [u8; 32]) -> Self { Self { next_seq, head } }
48
49    /// Advance the chain for a record that holds `value`.
50    ///
51    /// Returns the [`ChainLink`] to stamp on that record. `prev_head` is
52    /// the head as it was *before* this record. The method then updates the
53    /// head with this record.
54    #[tracing::instrument(level = "debug", skip_all, fields(seq = self.next_seq, bytes = value.len()))]
55    pub fn extend(&mut self, value: &[u8]) -> ChainLink {
56        let seq = self.next_seq;
57        let prev = self.head;
58        self.head = chain_hash(&prev, seq, value);
59        self.next_seq += 1;
60        ChainLink {
61            seq,
62            prev_head: prev,
63        }
64    }
65
66    #[must_use]
67    pub fn head(&self) -> [u8; 32] { self.head }
68
69    #[must_use]
70    pub fn next_seq(&self) -> u64 { self.next_seq }
71}
72
73/// Encode `bytes` as lowercase hex.
74#[must_use]
75pub fn to_hex(bytes: &[u8]) -> String { hex::encode(bytes) }
76
77/// Parse exactly 32 bytes of lowercase or uppercase hex.
78#[must_use]
79pub fn from_hex32(s: &str) -> Option<[u8; 32]> {
80    let mut out = [0u8; 32];
81    hex::decode_to_slice(s, &mut out).ok()?;
82    Some(out)
83}
84
85#[cfg(test)]
86mod tests {
87    use {super::*, assert2::check};
88
89    #[test]
90    fn genesis_is_zero() {
91        check!(GENESIS_HEAD == [0u8; 32]);
92    }
93
94    #[test]
95    fn chain_hash_is_deterministic_and_order_sensitive() {
96        let a = chain_hash(&GENESIS_HEAD, 0, b"alpha");
97        let b = chain_hash(&GENESIS_HEAD, 0, b"alpha");
98        check!(a == b);
99        // different seq => different hash
100        check!(chain_hash(&GENESIS_HEAD, 1, b"alpha") != a);
101        // different value => different hash
102        check!(chain_hash(&GENESIS_HEAD, 0, b"beta") != a);
103        // different prev => different hash
104        check!(chain_hash(&a, 0, b"alpha") != a);
105    }
106
107    #[test]
108    fn chain_state_advances_and_reports_prev() {
109        let mut c = ChainState::new();
110        check!(c.next_seq() == 0);
111        let link0 = c.extend(b"r0");
112        check!(
113            link0
114                == ChainLink {
115                    seq: 0,
116                    prev_head: GENESIS_HEAD
117                }
118        );
119        check!((c.next_seq(), c.head()) == (1, chain_hash(&GENESIS_HEAD, 0, b"r0")));
120
121        let link1 = c.extend(b"r1");
122        check!(
123            link1
124                == ChainLink {
125                    seq: 1,
126                    prev_head: chain_hash(&GENESIS_HEAD, 0, b"r0"),
127                }
128        );
129        check!(c.head() == chain_hash(&link1.prev_head, 1, b"r1"));
130    }
131
132    #[test]
133    fn resume_sets_next_seq_and_head_and_continues() {
134        let head = chain_hash(&GENESIS_HEAD, 4, b"r4");
135        let mut c = ChainState::resume(5, head);
136        check!((c.next_seq(), c.head()) == (5, head));
137        let link = c.extend(b"r5");
138        check!(
139            link == ChainLink {
140                seq: 5,
141                prev_head: head
142            }
143        );
144        check!(c.head() == chain_hash(&head, 5, b"r5"));
145    }
146
147    #[yare::parameterized(
148        round_trip = {
149            to_hex(&chain_hash(&GENESIS_HEAD, 7, b"x")),
150            Some(chain_hash(&GENESIS_HEAD, 7, b"x")),
151        },
152        non_hex = { "zz".to_owned(), None },
153        odd_length = { "abc".to_owned(), None },
154        uppercase = {
155            to_hex(&chain_hash(&GENESIS_HEAD, 7, b"x")).to_uppercase(),
156            Some(chain_hash(&GENESIS_HEAD, 7, b"x")),
157        },
158    )]
159    fn hex_round_trips(input: String, expected: Option<[u8; 32]>) {
160        check!(from_hex32(&input) == expected);
161    }
162}