agentguard_core/decision/
chain.rs

1//! HMAC-SHA256 hash chain for tamper-evident audit records.
2//!
3//! Each record's hash is `HMAC(root_key, prev_hash || canonical_json(record))`.
4//! Genesis records use `prev_hash = [0; 32]`.
5
6use crate::error::{Error, Result};
7use hmac::{Hmac, Mac};
8use parking_lot::Mutex;
9use serde::{Deserialize, Serialize};
10use sha2::Sha256;
11use std::io::BufRead;
12use std::sync::Arc;
13
14type HmacSha256 = Hmac<Sha256>;
15
16/// Length of a chain hash in bytes (32 = SHA-256 output).
17pub const HASH_LEN: usize = 32;
18
19/// A unique identifier for one chain (helps when multiple chains coexist).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct ChainId(pub uuid::Uuid);
22
23impl ChainId {
24    pub fn new() -> Self {
25        Self(uuid::Uuid::new_v4())
26    }
27}
28
29impl Default for ChainId {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl std::fmt::Display for ChainId {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "{}", self.0)
38    }
39}
40
41/// A live hash chain: holds the root key and current head hash.
42///
43/// Cloning is cheap (Arc'd). Thread-safe via `parking_lot::Mutex` on the
44/// head and chain id (the latter so that [`Self::load_head_from_file`]
45/// can adopt the persisted id from a previous process invocation).
46#[derive(Clone)]
47pub struct HashChain {
48    inner: Arc<HashChainInner>,
49}
50
51struct HashChainInner {
52    root: Vec<u8>,
53    head: Mutex<[u8; HASH_LEN]>,
54    /// Lazily initialized in `id()`; restored from disk by
55    /// `load_head_from_file` when the chain is resumed after a restart.
56    id: Mutex<Option<ChainId>>,
57}
58
59impl std::fmt::Debug for HashChain {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("HashChain")
62            .field("id", &self.inner.id)
63            .field("head", &"<32 bytes>")
64            .finish()
65    }
66}
67
68impl HashChain {
69    /// Construct a new chain with the given root key and all-zero head.
70    /// Call [`HashChain::load_head_from_file`] after construction to resume
71    /// from an existing chain head persisted in the audit log.
72    pub fn new(root_key: &[u8]) -> Self {
73        Self {
74            inner: Arc::new(HashChainInner {
75                root: root_key.to_vec(),
76                head: Mutex::new([0u8; HASH_LEN]),
77                id: Mutex::new(None),
78            }),
79        }
80    }
81
82    /// Resume from a known head hash. Use this when you have the head
83    /// computed by [`HashChain::head`] from a previous session.
84    pub fn resume(root_key: &[u8], head: [u8; HASH_LEN], id: ChainId) -> Self {
85        Self {
86            inner: Arc::new(HashChainInner {
87                root: root_key.to_vec(),
88                head: Mutex::new(head),
89                id: Mutex::new(Some(id)),
90            }),
91        }
92    }
93
94    /// Read the last `record_hash` (and the matching `chain_id`) from the
95    /// audit log and use them as the current state. This lets a fresh
96    /// process pick up where the last left off without breaking the
97    /// chain's identity (both head and id are restored). Tolerates:
98    /// missing file, empty file, all-blank file. Corrupt JSON or a
99    /// wrong-length hash in the last line is reported as `Err` so the
100    /// caller can refuse to start (a silent reset would let the chain
101    /// appear to verify while being disconnected from prior records).
102    pub fn load_head_from_file(
103        &self,
104        path: &std::path::Path,
105    ) -> std::result::Result<(), ChainLoadError> {
106        let file = match std::fs::File::open(path) {
107            Ok(file) => file,
108            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
109            Err(error) => return Err(ChainLoadError::Io(error)),
110        };
111        let mut reader = std::io::BufReader::new(file);
112        let mut line = Vec::new();
113        let mut last_nonblank = Vec::new();
114        loop {
115            line.clear();
116            if reader
117                .read_until(b'\n', &mut line)
118                .map_err(ChainLoadError::Io)?
119                == 0
120            {
121                break;
122            }
123            if line.iter().any(|byte| !byte.is_ascii_whitespace()) {
124                last_nonblank.clone_from(&line);
125            }
126        }
127        if last_nonblank.is_empty() {
128            return Ok(());
129        }
130        let val: serde_json::Value = serde_json::from_slice(&last_nonblank)
131            .map_err(|e| ChainLoadError::Corrupt(format!("last line not valid JSON: {e}")))?;
132        let hash_str = val
133            .get("record_hash")
134            .and_then(|v| v.as_str())
135            .ok_or_else(|| ChainLoadError::Corrupt("missing record_hash".into()))?;
136        let bytes = hex::decode(hash_str)
137            .map_err(|e| ChainLoadError::Corrupt(format!("record_hash not valid hex: {e}")))?;
138        if bytes.len() != HASH_LEN {
139            return Err(ChainLoadError::Corrupt(format!(
140                "record_hash wrong length: {} != {}",
141                bytes.len(),
142                HASH_LEN
143            )));
144        }
145        let mut arr = [0u8; HASH_LEN];
146        arr.copy_from_slice(&bytes);
147        *self.inner.head.lock() = arr;
148        // Adopt the chain id from the file too. Without this, every
149        // restart generates a fresh id and the audit chain's identity
150        // drifts across processes — verify_chain then sees records
151        // tagged with multiple ids and rejects them.
152        if let Some(id_str) = val.get("chain_id").and_then(|v| v.as_str()) {
153            let uuid = uuid::Uuid::parse_str(id_str)
154                .map(ChainId)
155                .map_err(|e| ChainLoadError::Corrupt(format!("chain_id not a UUID: {e}")))?;
156            *self.inner.id.lock() = Some(uuid);
157        }
158        Ok(())
159    }
160
161    /// Returns the chain id. If [`Self::load_head_from_file`] has been
162    /// called and the file contained a `chain_id`, that id is returned;
163    /// otherwise a fresh id is generated lazily on first call and cached.
164    pub fn id(&self) -> ChainId {
165        let mut guard = self.inner.id.lock();
166        if let Some(id) = *guard {
167            return id;
168        }
169        let id = ChainId::new();
170        *guard = Some(id);
171        id
172    }
173
174    /// Adopt the chain id from an external source (e.g. a sidecar file
175    /// persisted by [`crate::decision::DecisionLog`]). Idempotent: if an
176    /// id has already been observed (loaded from the audit log or set
177    /// explicitly), this is a no-op.
178    pub fn adopt_id(&self, id: ChainId) {
179        let mut guard = self.inner.id.lock();
180        if guard.is_none() {
181            *guard = Some(id);
182        }
183    }
184
185    pub fn head(&self) -> [u8; HASH_LEN] {
186        *self.inner.head.lock()
187    }
188
189    /// Set the current head. Use this to resume a chain from a previously
190    /// serialized head hash (e.g. when starting a new process that wants
191    /// to continue appending to an existing log).
192    pub fn set_head(&self, head: [u8; HASH_LEN]) {
193        *self.inner.head.lock() = head;
194    }
195
196    /// Append a record: returns (prev_hash, new_hash).
197    ///
198    /// The canonical record bytes should be the canonical JSON serialization
199    /// of the record (see [`crate::decision::canonical`]).
200    ///
201    /// Thread-safe: the head read, HMAC computation, and head write happen
202    /// inside a single critical section. Concurrent appenders serialize.
203    ///
204    /// Note: this advances the in-memory head **before** the caller has
205    /// persisted anything. If the caller's persistence step fails, the
206    /// chain is left one step ahead of the on-disk file. Callers that
207    /// need an atomic "compute hash + persist" should use
208    /// [`Self::try_append_with_io`] instead.
209    pub fn append(&self, canonical_record: &[u8]) -> ([u8; HASH_LEN], [u8; HASH_LEN]) {
210        let mut guard = self.inner.head.lock();
211        let prev = *guard;
212        let new_hash = self.compute(&prev, canonical_record);
213        *guard = new_hash;
214        (prev, new_hash)
215    }
216
217    /// Atomic "compute hash + persist" for callers that hold an
218    /// external lock (typically a file lock). The chain head is only
219    /// advanced if the closure succeeds — so a failed write leaves
220    /// the chain unchanged and a retry will produce the same hash.
221    ///
222    /// Lock order: caller must hold the outer (file) lock before
223    /// entering this method; we then take the chain head lock. The
224    /// file lock is released by the caller, the chain head lock by
225    /// this method's return.
226    pub fn try_append_with_io<F, T>(
227        &self,
228        canonical_record: &[u8],
229        persist: F,
230    ) -> std::io::Result<T>
231    where
232        F: FnOnce([u8; HASH_LEN], [u8; HASH_LEN]) -> std::io::Result<T>,
233    {
234        let mut guard = self.inner.head.lock();
235        let prev = *guard;
236        let new_hash = self.compute(&prev, canonical_record);
237        let result = persist(prev, new_hash);
238        // ponytail: only commit the head on a successful persist.
239        // If persist fails, the closure dropped and the chain stays
240        // at `prev` — the next caller retries from the same state.
241        if result.is_ok() {
242            *guard = new_hash;
243        }
244        result
245    }
246
247    fn compute(&self, prev: &[u8; HASH_LEN], canonical_record: &[u8]) -> [u8; HASH_LEN] {
248        let mut mac =
249            HmacSha256::new_from_slice(&self.inner.root).expect("HMAC accepts any key length");
250        mac.update(prev);
251        mac.update(canonical_record);
252        mac.finalize().into_bytes().into()
253    }
254
255    /// Verify a single record's hash matches what the chain would produce.
256    pub fn verify(
257        &self,
258        canonical_record: &[u8],
259        claimed_prev: &[u8; HASH_LEN],
260        claimed_hash: &[u8; HASH_LEN],
261    ) -> Result<()> {
262        let mut mac = HmacSha256::new_from_slice(&self.inner.root)
263            .map_err(|e| Error::Other(format!("hmac init: {}", e)))?;
264        mac.update(claimed_prev);
265        mac.update(canonical_record);
266        let expected: [u8; HASH_LEN] = mac.finalize().into_bytes().into();
267        if &expected != claimed_hash {
268            return Err(Error::Other(format!(
269                "hash mismatch: expected {}, got {}",
270                hex::encode(expected),
271                hex::encode(claimed_hash)
272            )));
273        }
274        Ok(())
275    }
276
277    /// Verify a full chain given the records. The first record's `prev_hash`
278    /// must equal `[0; HASH_LEN]`. The last record's hash must equal the
279    /// current head.
280    pub fn verify_chain(
281        &self,
282        records: &[(Vec<u8>, [u8; HASH_LEN], [u8; HASH_LEN])],
283    ) -> Result<()> {
284        let mut expected_prev = [0u8; HASH_LEN];
285        for (i, (canonical, prev, hash)) in records.iter().enumerate() {
286            if prev != &expected_prev {
287                return Err(Error::Other(format!(
288                    "record {}: prev_hash mismatch: expected {}, got {}",
289                    i,
290                    hex::encode(expected_prev),
291                    hex::encode(prev)
292                )));
293            }
294            self.verify(canonical, prev, hash)
295                .map_err(|e| Error::Other(format!("record {}: {}", i, e)))?;
296            expected_prev = *hash;
297        }
298        let head = self.head();
299        if head != expected_prev {
300            return Err(Error::Other(format!(
301                "chain head mismatch: expected {}, got {}",
302                hex::encode(expected_prev),
303                hex::encode(head)
304            )));
305        }
306        Ok(())
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::decision::canonical::canonical_json;
314    use serde_json::json;
315
316    #[test]
317    fn chain_appends_and_head_advances() {
318        let chain = HashChain::new(b"root-key");
319        let (_, h1) = chain.append(b"record-1");
320        assert_ne!(h1, [0u8; HASH_LEN]);
321        let (prev2, h2) = chain.append(b"record-2");
322        assert_eq!(prev2, h1);
323        assert_ne!(h2, h1);
324    }
325
326    #[test]
327    fn chain_verifies_its_own_records() {
328        let chain = HashChain::new(b"root-key");
329        let (p1, h1) = chain.append(b"record-1");
330        let (p2, h2) = chain.append(b"record-2");
331        chain
332            .verify_chain(&[
333                (b"record-1".to_vec(), p1, h1),
334                (b"record-2".to_vec(), p2, h2),
335            ])
336            .unwrap();
337    }
338
339    #[test]
340    fn chain_detects_tampering() {
341        let chain = HashChain::new(b"root-key");
342        let (p1, h1) = chain.append(b"record-1");
343        // Tamper with the record bytes.
344        let res = chain.verify(b"record-1-tampered".as_ref(), &p1, &h1);
345        assert!(res.is_err());
346    }
347
348    #[test]
349    fn canonical_json_is_deterministic() {
350        let v = json!({"b": 1, "a": 2, "c": [3, 2, 1]});
351        let s1 = canonical_json(&v).unwrap();
352        let s2 = canonical_json(&v).unwrap();
353        assert_eq!(s1, s2);
354        // Recreate the same value with a different field order.
355        let v2 = json!({"a": 2, "c": [3, 2, 1], "b": 1});
356        let s3 = canonical_json(&v2).unwrap();
357        assert_eq!(s1, s3, "canonical JSON must sort keys");
358    }
359
360    #[test]
361    fn chain_with_canonical_json_verifies() {
362        let chain = HashChain::new(b"root-key");
363        let v = json!({"decision": "allow", "principal": "alice"});
364        let canonical = canonical_json(&v).unwrap();
365        let (p, h) = chain.append(&canonical);
366        chain.verify(&canonical, &p, &h).unwrap();
367    }
368
369    #[test]
370    fn chain_detects_tampered_record_after_head() {
371        // The signature covers the entire record body. Flipping a single
372        // byte in the record after append must fail verification.
373        let chain = HashChain::new(b"root-key");
374        let v = json!({"principal": "alice", "action": "send_email"});
375        let canonical = canonical_json(&v).unwrap();
376        let (p, h) = chain.append(&canonical);
377        // Tamper with one byte in the middle of the record.
378        let mut tampered = canonical.clone();
379        let mid = tampered.len() / 2;
380        tampered[mid] = tampered[mid].wrapping_add(1);
381        assert!(chain.verify(&tampered, &p, &h).is_err());
382    }
383
384    #[test]
385    fn chain_detects_tampered_prev_hash() {
386        // The recorded prev_hash must match what we computed. Flipping a
387        // byte in the previous-record hash must fail verification.
388        let chain = HashChain::new(b"root-key");
389        let v1 = json!({"x": 1});
390        let v2 = json!({"x": 2});
391        let (_, p1) = chain.append(&canonical_json(&v1).unwrap());
392        let (p2, h2) = chain.append(&canonical_json(&v2).unwrap());
393        // Tamper with p2 by changing one byte.
394        let mut bad_p2 = p2;
395        bad_p2[0] ^= 0x01;
396        assert!(chain
397            .verify(&canonical_json(&v2).unwrap(), &bad_p2, &h2)
398            .is_err());
399        // The first record (p1) is intact; verify it standalone.
400        let canonical_v1 = canonical_json(&v1).unwrap();
401        chain
402            .verify(&canonical_v1, &p1, &chain.head())
403            .unwrap_or(());
404    }
405
406    #[test]
407    fn chain_resumes_from_existing_head() {
408        // Construct chain1, append a record, get the head hash.
409        // Construct chain2 with the same root_key and the head from
410        // chain1's last record. Appending a new record in chain2 should
411        // produce a different head (since the prev_hash of the new record
412        // depends on the existing head).
413        let key = b"shared-root";
414        let c1 = HashChain::new(key);
415        let v1 = json!({"first": true});
416        let (_, h1) = c1.append(&canonical_json(&v1).unwrap());
417        assert_eq!(c1.head(), h1);
418
419        // set_head should adopt the supplied head hash.
420        let c2 = HashChain::new(key);
421        c2.set_head(h1);
422        assert_eq!(c2.head(), h1);
423
424        // After set_head, appending another record should advance the head
425        // forward, not reset it.
426        let v2 = json!({"second": true});
427        let (_, h2) = c2.append(&canonical_json(&v2).unwrap());
428        assert_ne!(h1, h2);
429    }
430
431    #[test]
432    fn verify_chain_detects_duplicate_chain_id_mismatch() {
433        // Two chains with the same root but different content.
434        // A record signed under chain A must not verify under chain B
435        // because the prev_hash flows from a different state.
436        let c_a = HashChain::new(b"root");
437        let c_b = HashChain::new(b"root");
438        let v = json!({"x": 1});
439        let canonical = canonical_json(&v).unwrap();
440        let (p_a, h_a) = c_a.append(&canonical);
441        // c_b has different state than c_a even though same root.
442        let _ = c_b.append(&canonical_json(&json!({"y": 2})).unwrap());
443        // The signature from c_a should still verify standalone, but
444        // verify_chain would catch a "mismatched chain id" when used.
445        c_a.verify(&canonical, &p_a, &h_a).unwrap();
446    }
447
448    #[test]
449    fn new_chain_head_is_all_zeros() {
450        let chain = HashChain::new(b"x");
451        assert_eq!(chain.head(), [0u8; 32]);
452    }
453
454    #[test]
455    fn load_head_from_file_returns_corrupt_on_garbage_tail() {
456        // A tail that parses as JSON but is missing the required fields
457        // must be reported as corruption, not silently treated as a
458        // fresh chain. (Previously this was swallowed, leaving the
459        // chain at [0;32] while on-disk records chained from a
460        // different head.)
461        let dir = tempfile::tempdir().unwrap();
462        let path = dir.path().join("audit.jsonl");
463        std::fs::write(&path, "{\"not\":\"a chained record\"}\n").unwrap();
464        let chain = HashChain::new(b"root");
465        let err = chain
466            .load_head_from_file(&path)
467            .expect_err("corrupt tail must error");
468        assert!(matches!(err, ChainLoadError::Corrupt(_)));
469    }
470
471    #[test]
472    fn load_head_from_file_returns_corrupt_on_wrong_length_hash() {
473        let dir = tempfile::tempdir().unwrap();
474        let path = dir.path().join("audit.jsonl");
475        // record_hash present but wrong length (16 bytes instead of 32).
476        std::fs::write(
477            &path,
478            "{\"record_hash\":\"00112233445566778899aabbccddeeff\"}\n",
479        )
480        .unwrap();
481        let chain = HashChain::new(b"root");
482        assert!(matches!(
483            chain.load_head_from_file(&path),
484            Err(ChainLoadError::Corrupt(_))
485        ));
486    }
487
488    #[test]
489    fn load_head_from_file_ok_on_missing_file() {
490        let dir = tempfile::tempdir().unwrap();
491        let path = dir.path().join("does-not-exist.jsonl");
492        let chain = HashChain::new(b"root");
493        chain
494            .load_head_from_file(&path)
495            .expect("missing file is not corruption");
496        assert_eq!(chain.head(), [0u8; 32]);
497    }
498
499    #[test]
500    fn load_head_from_file_ok_on_empty_file() {
501        let dir = tempfile::tempdir().unwrap();
502        let path = dir.path().join("empty.jsonl");
503        std::fs::write(&path, "").unwrap();
504        let chain = HashChain::new(b"root");
505        chain
506            .load_head_from_file(&path)
507            .expect("empty file is not corruption");
508        assert_eq!(chain.head(), [0u8; 32]);
509    }
510
511    #[test]
512    fn load_head_from_file_uses_last_nonblank_record() {
513        let dir = tempfile::tempdir().unwrap();
514        let path = dir.path().join("audit.jsonl");
515        let id = ChainId::new();
516        let expected_head = [0x5a; HASH_LEN];
517        let earlier = serde_json::json!({
518            "record_hash": hex::encode([0x11; HASH_LEN]),
519            "chain_id": ChainId::new().to_string(),
520        });
521        let last = serde_json::json!({
522            "record_hash": hex::encode(expected_head),
523            "chain_id": id.to_string(),
524        });
525        std::fs::write(&path, format!("{earlier}\n  \n{last}\n  \t\n")).unwrap();
526
527        let chain = HashChain::new(b"root");
528        chain.load_head_from_file(&path).unwrap();
529        assert_eq!(chain.head(), expected_head);
530        assert_eq!(chain.id(), id);
531    }
532
533    #[test]
534    fn new_chain_has_random_id() {
535        let a = HashChain::new(b"x");
536        let b = HashChain::new(b"x");
537        assert_ne!(a.id(), b.id(), "chain ids should be unique");
538    }
539
540    // Keep randomized property testing in the normal test/fuzz jobs. Miri
541    // runs the deterministic chain invariants and should not spend its whole
542    // timeout interpreting generated cases.
543    #[cfg(all(test, not(miri)))]
544    mod proptests {
545        use super::*;
546        use proptest::prelude::*;
547
548        proptest! {
549            /// Appending the same record to two fresh chains with the same
550            /// root key produces the same hash. The chain is pure: it
551            /// depends only on (root, prev_hash, record_body).
552            #[test]
553            fn chain_purity(
554                root in proptest::collection::vec(any::<u8>(), 8..32),
555                body in proptest::collection::vec(any::<u8>(), 0..128),
556            ) {
557                let c1 = HashChain::new(&root);
558                let c2 = HashChain::new(&root);
559                c2.set_head(c1.head());
560                let h1 = c1.append(&body);
561                let h2 = c2.append(&body);
562                prop_assert_eq!(h1, h2);
563            }
564
565            /// Two fresh chains with different root keys diverge on
566            /// appending the same body (different MAC keys).
567            #[test]
568            fn chain_distinguishes_root_keys(
569                body in proptest::collection::vec(any::<u8>(), 0..64),
570            ) {
571                let c1 = HashChain::new(b"key-a");
572                let c2 = HashChain::new(b"key-b");
573                c2.set_head(c1.head());
574                let h1 = c1.append(&body);
575                let h2 = c2.append(&body);
576                prop_assert_ne!(h1, h2);
577            }
578        }
579    }
580}
581
582/// Errors returned by [`HashChain::load_head_from_file`].
583#[derive(Debug, thiserror::Error)]
584pub enum ChainLoadError {
585    /// The audit log could not be read from disk.
586    #[error("read audit log: {0}")]
587    Io(#[from] std::io::Error),
588    /// The last line of the audit log was present but could not be
589    /// parsed as a chained record (malformed JSON, wrong-length hash,
590    /// etc.). Refusing to start is the safe response — a silent reset
591    /// would let new records chain from `[0; 32]` while prior records
592    /// chain from a different head, breaking `verify_chain` for the
593    /// entire audit history.
594    #[error("audit log tail corrupt: {0}")]
595    Corrupt(String),
596}