agentguard_core/decision/
chain.rs1use 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
16pub const HASH_LEN: usize = 32;
18
19#[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#[derive(Clone)]
47pub struct HashChain {
48 inner: Arc<HashChainInner>,
49}
50
51struct HashChainInner {
52 root: Vec<u8>,
53 head: Mutex<[u8; HASH_LEN]>,
54 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 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 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 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 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 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 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 pub fn set_head(&self, head: [u8; HASH_LEN]) {
193 *self.inner.head.lock() = head;
194 }
195
196 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 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 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 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 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 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 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 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 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 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 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 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 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 let c2 = HashChain::new(key);
421 c2.set_head(h1);
422 assert_eq!(c2.head(), h1);
423
424 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 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 let _ = c_b.append(&canonical_json(&json!({"y": 2})).unwrap());
443 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 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 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 #[cfg(all(test, not(miri)))]
544 mod proptests {
545 use super::*;
546 use proptest::prelude::*;
547
548 proptest! {
549 #[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 #[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#[derive(Debug, thiserror::Error)]
584pub enum ChainLoadError {
585 #[error("read audit log: {0}")]
587 Io(#[from] std::io::Error),
588 #[error("audit log tail corrupt: {0}")]
595 Corrupt(String),
596}