_hope_core/
audit_log.rs

1// TODO v1.5.0: Migrate AuditLog to use KeyStore trait instead of deprecated KeyPair
2#![allow(deprecated)]
3
4use crate::crypto::{hash_bytes, KeyPair};
5use crate::proof::{Action, IntegrityProof};
6use serde::{Deserialize, Serialize};
7use std::fs::{File, OpenOptions};
8use std::io::{BufReader, BufWriter};
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum AuditError {
14    #[error("IO error: {0}")]
15    IoError(#[from] std::io::Error),
16
17    #[error("Serialization error: {0}")]
18    SerializationError(#[from] serde_json::Error),
19
20    #[error(
21        "Chain integrity broken at index {index}: expected prev_hash {expected:?}, found {found:?}"
22    )]
23    BrokenChain {
24        index: usize,
25        expected: [u8; 32],
26        found: [u8; 32],
27    },
28
29    #[error("Invalid signature at index {0}")]
30    InvalidSignature(usize),
31
32    #[error("Crypto error: {0}")]
33    CryptoError(#[from] crate::crypto::CryptoError),
34}
35
36pub type Result<T> = std::result::Result<T, AuditError>;
37
38/// Decision made by the genome
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub enum Decision {
41    Approved,
42    Denied { reason: String },
43}
44
45/// A single entry in the audit log
46///
47/// Each entry is cryptographically linked to the previous entry,
48/// forming a blockchain-style tamper-evident chain.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct AuditEntry {
51    /// Sequential index (starts at 0)
52    pub index: u64,
53
54    /// Unix timestamp when entry was created
55    pub timestamp: u64,
56
57    /// Action that was requested
58    pub action: Action,
59
60    /// Cryptographic proof for this action
61    pub proof: IntegrityProof,
62
63    /// Decision made (approved or denied)
64    pub decision: Decision,
65
66    /// Hash of previous entry (blockchain linkage)
67    pub prev_hash: [u8; 32],
68
69    /// Hash of this entry (excluding signature)
70    pub current_hash: [u8; 32],
71
72    /// RSA signature of this entire entry
73    pub signature: Vec<u8>,
74}
75
76impl AuditEntry {
77    /// Compute the hash of this entry (for blockchain linkage)
78    pub fn compute_hash(&self) -> [u8; 32] {
79        let mut data = Vec::new();
80        data.extend_from_slice(&self.index.to_le_bytes());
81        data.extend_from_slice(&self.timestamp.to_le_bytes());
82        data.extend_from_slice(&self.action.hash());
83        data.extend_from_slice(&self.proof.action_hash);
84        data.extend_from_slice(&self.prev_hash);
85
86        hash_bytes(&data)
87    }
88
89    /// Get data to be signed
90    fn signing_data(&self) -> Vec<u8> {
91        serde_json::to_vec(&(
92            self.index,
93            self.timestamp,
94            &self.action,
95            &self.proof,
96            &self.decision,
97            &self.prev_hash,
98            &self.current_hash,
99        ))
100        .unwrap()
101    }
102}
103
104/// Blockchain-style audit log
105///
106/// Provides tamper-evident logging of all AI actions and decisions.
107/// Each entry is cryptographically linked to the previous one.
108pub struct AuditLog {
109    entries: Vec<AuditEntry>,
110    storage_path: Option<PathBuf>,
111    keypair: KeyPair,
112}
113
114impl AuditLog {
115    /// Create a new audit log
116    pub fn new(keypair: KeyPair) -> Result<Self> {
117        Ok(AuditLog {
118            entries: Vec::new(),
119            storage_path: None,
120            keypair,
121        })
122    }
123
124    /// Create a new audit log with file persistence
125    pub fn with_storage(keypair: KeyPair, path: impl AsRef<Path>) -> Result<Self> {
126        let path = path.as_ref().to_path_buf();
127
128        // Try to load existing log
129        let entries = if path.exists() {
130            Self::load_from_file(&path)?
131        } else {
132            Vec::new()
133        };
134
135        Ok(AuditLog {
136            entries,
137            storage_path: Some(path),
138            keypair,
139        })
140    }
141
142    /// Append a new entry to the audit log
143    pub fn append(
144        &mut self,
145        action: Action,
146        proof: IntegrityProof,
147        decision: Decision,
148    ) -> Result<()> {
149        // Get previous hash (or genesis hash)
150        let prev_hash = self
151            .entries
152            .last()
153            .map(|e| e.current_hash)
154            .unwrap_or([0u8; 32]); // Genesis block
155
156        let index = self.entries.len() as u64;
157        let timestamp = chrono::Utc::now().timestamp() as u64;
158
159        // Create entry (without hash and signature)
160        let mut entry = AuditEntry {
161            index,
162            timestamp,
163            action,
164            proof,
165            decision,
166            prev_hash,
167            current_hash: [0u8; 32],
168            signature: Vec::new(),
169        };
170
171        // Compute hash
172        entry.current_hash = entry.compute_hash();
173
174        // Sign entry
175        let signing_data = entry.signing_data();
176        entry.signature = self.keypair.sign(&signing_data)?;
177
178        // Append to log
179        self.entries.push(entry.clone());
180
181        // Persist if storage is configured
182        if let Some(path) = &self.storage_path {
183            self.append_to_file(path, &entry)?;
184        }
185
186        Ok(())
187    }
188
189    /// Verify the entire chain integrity
190    pub fn verify_chain(&self) -> Result<()> {
191        for i in 1..self.entries.len() {
192            let prev = &self.entries[i - 1];
193            let curr = &self.entries[i];
194
195            // Check linkage
196            if curr.prev_hash != prev.current_hash {
197                return Err(AuditError::BrokenChain {
198                    index: i,
199                    expected: prev.current_hash,
200                    found: curr.prev_hash,
201                });
202            }
203
204            // Check hash integrity
205            let expected_hash = curr.compute_hash();
206            if curr.current_hash != expected_hash {
207                return Err(AuditError::BrokenChain {
208                    index: i,
209                    expected: expected_hash,
210                    found: curr.current_hash,
211                });
212            }
213
214            // Check signature
215            let signing_data = curr.signing_data();
216            self.keypair
217                .verify(&signing_data, &curr.signature)
218                .map_err(|_| AuditError::InvalidSignature(i))?;
219        }
220
221        Ok(())
222    }
223
224    /// Get all entries
225    pub fn entries(&self) -> &[AuditEntry] {
226        &self.entries
227    }
228
229    /// Get entry count
230    pub fn len(&self) -> usize {
231        self.entries.len()
232    }
233
234    /// Check if log is empty
235    pub fn is_empty(&self) -> bool {
236        self.entries.is_empty()
237    }
238
239    /// Load entries from file
240    fn load_from_file(path: &Path) -> Result<Vec<AuditEntry>> {
241        let file = File::open(path)?;
242        let reader = BufReader::new(file);
243        let entries: Vec<AuditEntry> = serde_json::from_reader(reader)?;
244        Ok(entries)
245    }
246
247    /// Append entry to file (append-only)
248    fn append_to_file(&self, path: &Path, _entry: &AuditEntry) -> Result<()> {
249        // Write entire log (in production, use append-only format)
250        let file = OpenOptions::new()
251            .create(true)
252            .write(true)
253            .truncate(true)
254            .open(path)?;
255
256        let writer = BufWriter::new(file);
257        serde_json::to_writer_pretty(writer, &self.entries)?;
258
259        Ok(())
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::proof::{Action, ActionType, VerificationStatus};
267
268    fn create_test_proof() -> IntegrityProof {
269        IntegrityProof {
270            nonce: [1u8; 32],
271            timestamp: 1000,
272            ttl: 60,
273            action_hash: [2u8; 32],
274            action_type: ActionType::Delete,
275            capsule_hash: "test_hash".into(),
276            status: VerificationStatus::OK,
277            signature: vec![],
278        }
279    }
280
281    #[test]
282    fn test_audit_log_append() {
283        let keypair = KeyPair::generate().unwrap();
284        let mut log = AuditLog::new(keypair).unwrap();
285
286        let action = Action::delete("test.txt");
287        let proof = create_test_proof();
288        let decision = Decision::Approved;
289
290        log.append(action, proof, decision).unwrap();
291
292        assert_eq!(log.len(), 1);
293        assert_eq!(log.entries()[0].index, 0);
294    }
295
296    #[test]
297    fn test_audit_log_chain_linkage() {
298        let keypair = KeyPair::generate().unwrap();
299        let mut log = AuditLog::new(keypair).unwrap();
300
301        // Add first entry
302        log.append(
303            Action::delete("file1.txt"),
304            create_test_proof(),
305            Decision::Approved,
306        )
307        .unwrap();
308
309        // Add second entry
310        log.append(
311            Action::delete("file2.txt"),
312            create_test_proof(),
313            Decision::Approved,
314        )
315        .unwrap();
316
317        // Verify linkage
318        assert_eq!(log.entries()[1].prev_hash, log.entries()[0].current_hash);
319    }
320
321    #[test]
322    fn test_verify_chain_success() {
323        let keypair = KeyPair::generate().unwrap();
324        let mut log = AuditLog::new(keypair).unwrap();
325
326        // Add multiple entries
327        for i in 0..5 {
328            log.append(
329                Action::delete(format!("file{}.txt", i)),
330                create_test_proof(),
331                Decision::Approved,
332            )
333            .unwrap();
334        }
335
336        // Verify chain
337        assert!(log.verify_chain().is_ok());
338    }
339
340    #[test]
341    fn test_verify_chain_detects_tampering() {
342        let keypair = KeyPair::generate().unwrap();
343        let mut log = AuditLog::new(keypair).unwrap();
344
345        // Add entries
346        log.append(
347            Action::delete("file1.txt"),
348            create_test_proof(),
349            Decision::Approved,
350        )
351        .unwrap();
352        log.append(
353            Action::delete("file2.txt"),
354            create_test_proof(),
355            Decision::Approved,
356        )
357        .unwrap();
358        log.append(
359            Action::delete("file3.txt"),
360            create_test_proof(),
361            Decision::Approved,
362        )
363        .unwrap();
364
365        // Tamper with middle entry (simulate attack)
366        log.entries[1].current_hash[0] ^= 0xFF;
367
368        // Verification should fail
369        assert!(log.verify_chain().is_err());
370    }
371
372    #[test]
373    fn test_genesis_block() {
374        let keypair = KeyPair::generate().unwrap();
375        let mut log = AuditLog::new(keypair).unwrap();
376
377        log.append(
378            Action::delete("test.txt"),
379            create_test_proof(),
380            Decision::Approved,
381        )
382        .unwrap();
383
384        // First entry should have zero prev_hash (genesis)
385        assert_eq!(log.entries()[0].prev_hash, [0u8; 32]);
386    }
387}