Skip to main content

icebox/core/
audit.rs

1use sha1::{Digest, Sha1};
2
3use serde::Serialize;
4
5use crate::core::safety::DecisionRecord;
6
7fn digest(bytes: &[u8]) -> String {
8    let mut h = Sha1::new();
9    h.update(bytes);
10    hex::encode(h.finalize())
11}
12
13/// A tamper-evident audit entry: a decision record chained to its predecessor
14/// by a SHA-1 hash over (prev_hash || canonical record).
15#[derive(Debug, Clone, Serialize)]
16pub struct AuditEntry {
17    pub seq: u64,
18    pub prev_hash: String,
19    pub hash: String,
20    #[serde(flatten)]
21    pub record: DecisionRecord,
22}
23
24impl AuditEntry {
25    pub fn new(seq: u64, prev_hash: &str, record: DecisionRecord) -> Self {
26        let payload = format!("{prev_hash}|{}", serde_json::to_string(&record).unwrap_or_default());
27        let hash = digest(payload.as_bytes());
28        AuditEntry {
29            seq,
30            prev_hash: prev_hash.to_string(),
31            hash,
32            record,
33        }
34    }
35
36    fn chain_bytes(&self) -> Vec<u8> {
37        format!(
38            "{}|{}",
39            self.prev_hash,
40            serde_json::to_string(&self.record).unwrap_or_default()
41        )
42        .into_bytes()
43    }
44}
45
46/// The append-only, hash-chained audit ledger of a Governed Execution Environment.
47/// Every decision (allow, deny, require-approval) is linked to the one before it,
48/// so any retrospective modification of a record breaks the chain at verify().
49#[derive(Debug, Clone, Default, Serialize)]
50pub struct HashChain {
51    entries: Vec<AuditEntry>,
52    head: String,
53}
54
55impl HashChain {
56    pub fn new() -> Self {
57        HashChain {
58            entries: Vec::new(),
59            head: "0".repeat(40),
60        }
61    }
62
63    pub fn append(&mut self, record: DecisionRecord) -> u64 {
64        let seq = (self.entries.len() as u64) + 1;
65        let entry = AuditEntry::new(seq, &self.head, record);
66        self.head = entry.hash.clone();
67        self.entries.push(entry);
68        seq
69    }
70
71    pub fn len(&self) -> usize {
72        self.entries.len()
73    }
74
75    pub fn is_empty(&self) -> bool {
76        self.entries.is_empty()
77    }
78
79    pub fn records(&self) -> Vec<DecisionRecord> {
80        self.entries.iter().map(|e| e.record.clone()).collect()
81    }
82
83    pub fn last_record(&self) -> Option<&DecisionRecord> {
84        self.entries.last().map(|e| &e.record)
85    }
86
87    pub fn entries(&self) -> &[AuditEntry] {
88        &self.entries
89    }
90
91    pub fn recent(&self, n: usize) -> Vec<DecisionRecord> {
92        let end = self.entries.len();
93        let start = end.saturating_sub(n);
94        self.entries[start..].iter().map(|e| e.record.clone()).collect()
95    }
96
97    /// Hex-encoded SHA-1 hash of the latest entry in the chain.
98    pub fn tip_hex(&self) -> String {
99        self.head.clone()
100    }
101
102    /// Recompute every link from the genesis hash; returns false on the first
103    /// entry whose hash does not match its claimed predecessor (tamper proof).
104    pub fn verify(&self) -> bool {
105        let mut prev = "0".repeat(40);
106        for e in &self.entries {
107            if e.seq == 0 || e.prev_hash != prev {
108                return false;
109            }
110            let expected = digest(&e.chain_bytes());
111            if e.hash != expected {
112                return false;
113            }
114            prev = e.hash.clone();
115        }
116        true
117    }
118}
119
120impl From<&HashChain> for Vec<DecisionRecord> {
121    fn from(c: &HashChain) -> Self {
122        c.records()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::core::module::{Capability, Intent};
130    use crate::core::safety::{PolicyContext, PolicyDecision, RiskLevel};
131
132    fn fake_record(seq: u64) -> DecisionRecord {
133        DecisionRecord {
134            at: seq,
135            target: format!("10.0.0.{}", seq),
136            module: "test".into(),
137            capabilities: vec![Capability::NetworkScan],
138            intents: vec![Intent::Read],
139            impact: RiskLevel::Low,
140            context: PolicyContext::Cli,
141            decision: PolicyDecision::Allow,
142        }
143    }
144
145    #[test]
146    fn empty_chain_verifies() {
147        let c = HashChain::new();
148        assert!(c.verify());
149        assert!(c.is_empty());
150        assert_eq!(c.len(), 0);
151    }
152
153    #[test]
154    fn single_entry_verifies() {
155        let mut c = HashChain::new();
156        c.append(fake_record(1));
157        assert!(c.verify());
158        assert_eq!(c.len(), 1);
159        assert_eq!(c.records().len(), 1);
160    }
161
162    #[test]
163    fn chain_of_three_verifies() {
164        let mut c = HashChain::new();
165        for i in 1..=3 {
166            c.append(fake_record(i));
167        }
168        assert!(c.verify());
169        assert_eq!(c.len(), 3);
170        assert_eq!(c.recent(2).len(), 2);
171        assert_eq!(c.recent(10).len(), 3);
172    }
173
174    #[test]
175    fn tampered_entry_fails_verify() {
176        let mut c = HashChain::new();
177        c.append(fake_record(1));
178        c.append(fake_record(2));
179        assert!(c.verify());
180        if let Some(entry) = c.entries.last_mut() {
181            entry.record.target = "tampered".into();
182        }
183        assert!(!c.verify(), "tampered chain must fail verification");
184    }
185
186    #[test]
187    fn broken_prev_hash_fails_verify() {
188        let mut c = HashChain::new();
189        c.append(fake_record(1));
190        assert!(c.verify());
191        if let Some(entry) = c.entries.last_mut() {
192            entry.prev_hash = "badbadbad".into();
193        }
194        assert!(!c.verify());
195    }
196}