Skip to main content

nklave_storage/
secure_log.rs

1//! Secure decision log with encryption and integrity chain
2//!
3//! This module extends the basic decision log with:
4//! - Optional AES-256-CTR encryption for confidentiality
5//! - HMAC-SHA256 integrity chain for tamper detection
6//!
7//! Each log entry is formatted as:
8//! `<sequence>|<hmac>|<encrypted_or_plain_data>`
9//!
10//! The HMAC chain ensures that any modification to past entries is detectable.
11
12use hmac::{Hmac, Mac};
13use nklave_core::state::integrity::DecisionRecord;
14use sha2::Sha256;
15use std::fs::{File, OpenOptions};
16use std::io::{BufRead, BufReader, BufWriter, Write};
17use std::path::{Path, PathBuf};
18use thiserror::Error;
19use tracing::debug;
20
21type HmacSha256 = Hmac<Sha256>;
22
23/// Configuration for the secure log
24#[derive(Debug, Clone)]
25pub struct SecureLogConfig {
26    /// Path to the log file
27    pub path: PathBuf,
28
29    /// Encryption key (32 bytes for AES-256). If None, no encryption.
30    pub encryption_key: Option<[u8; 32]>,
31
32    /// HMAC key for integrity chain (32 bytes)
33    pub hmac_key: [u8; 32],
34}
35
36impl SecureLogConfig {
37    /// Create a new config with encryption enabled
38    pub fn with_encryption(path: impl AsRef<Path>, encryption_key: [u8; 32], hmac_key: [u8; 32]) -> Self {
39        Self {
40            path: path.as_ref().to_path_buf(),
41            encryption_key: Some(encryption_key),
42            hmac_key,
43        }
44    }
45
46    /// Create a new config with integrity only (no encryption)
47    pub fn integrity_only(path: impl AsRef<Path>, hmac_key: [u8; 32]) -> Self {
48        Self {
49            path: path.as_ref().to_path_buf(),
50            encryption_key: None,
51            hmac_key,
52        }
53    }
54}
55
56/// Secure append-only decision log with encryption and integrity chain
57pub struct SecureDecisionLog {
58    config: SecureLogConfig,
59    writer: BufWriter<File>,
60    last_sequence: u64,
61    last_hmac: [u8; 32],
62}
63
64impl SecureDecisionLog {
65    /// Open or create a secure decision log
66    pub fn open(config: SecureLogConfig) -> Result<Self, SecureLogError> {
67        // Determine last sequence and HMAC by reading existing log
68        let (last_sequence, last_hmac) = if config.path.exists() {
69            Self::read_last_entry(&config)?
70        } else {
71            // Initial HMAC is HMAC of the HMAC key itself (genesis)
72            let mut mac = HmacSha256::new_from_slice(&config.hmac_key)
73                .map_err(|e| SecureLogError::Crypto(e.to_string()))?;
74            mac.update(b"nklave-secure-log-genesis");
75            let genesis_hmac: [u8; 32] = mac.finalize().into_bytes().into();
76            (0, genesis_hmac)
77        };
78
79        // Open for appending
80        let file = OpenOptions::new()
81            .create(true)
82            .append(true)
83            .open(&config.path)
84            .map_err(|e| SecureLogError::Io(e.to_string()))?;
85
86        let writer = BufWriter::new(file);
87
88        Ok(Self {
89            config,
90            writer,
91            last_sequence,
92            last_hmac,
93        })
94    }
95
96    /// Read the last entry from an existing log
97    fn read_last_entry(config: &SecureLogConfig) -> Result<(u64, [u8; 32]), SecureLogError> {
98        let file = File::open(&config.path).map_err(|e| SecureLogError::Io(e.to_string()))?;
99        let reader = BufReader::new(file);
100
101        let mut last_sequence = 0u64;
102        let mut last_hmac: [u8; 32] = {
103            let mut mac = HmacSha256::new_from_slice(&config.hmac_key)
104                .map_err(|e| SecureLogError::Crypto(e.to_string()))?;
105            mac.update(b"nklave-secure-log-genesis");
106            mac.finalize().into_bytes().into()
107        };
108
109        for line in reader.lines() {
110            let line = line.map_err(|e| SecureLogError::Io(e.to_string()))?;
111            if line.is_empty() {
112                continue;
113            }
114
115            let parts: Vec<&str> = line.splitn(3, '|').collect();
116            if parts.len() != 3 {
117                return Err(SecureLogError::Parse("Invalid line format".to_string()));
118            }
119
120            let seq: u64 = parts[0]
121                .parse()
122                .map_err(|_| SecureLogError::Parse("Invalid sequence number".to_string()))?;
123
124            let hmac_hex = parts[1];
125            let hmac_bytes = hex::decode(hmac_hex)
126                .map_err(|e| SecureLogError::Parse(format!("Invalid HMAC hex: {}", e)))?;
127
128            if hmac_bytes.len() != 32 {
129                return Err(SecureLogError::Parse("Invalid HMAC length".to_string()));
130            }
131
132            last_sequence = seq;
133            last_hmac.copy_from_slice(&hmac_bytes);
134        }
135
136        Ok((last_sequence, last_hmac))
137    }
138
139    /// Append a decision record to the log
140    pub fn append(&mut self, record: &DecisionRecord) -> Result<(), SecureLogError> {
141        // Verify sequence continuity
142        if record.sequence != self.last_sequence + 1 {
143            return Err(SecureLogError::SequenceGap {
144                expected: self.last_sequence + 1,
145                actual: record.sequence,
146            });
147        }
148
149        // Serialize the record
150        let json =
151            serde_json::to_string(record).map_err(|e| SecureLogError::Serialize(e.to_string()))?;
152
153        // Optionally encrypt
154        let data = if let Some(ref key) = self.config.encryption_key {
155            Self::encrypt_data(key, record.sequence, json.as_bytes())?
156        } else {
157            json
158        };
159
160        // Compute HMAC chain: HMAC(prev_hmac || sequence || data)
161        let mut mac = HmacSha256::new_from_slice(&self.config.hmac_key)
162            .map_err(|e| SecureLogError::Crypto(e.to_string()))?;
163        mac.update(&self.last_hmac);
164        mac.update(&record.sequence.to_be_bytes());
165        mac.update(data.as_bytes());
166        let new_hmac: [u8; 32] = mac.finalize().into_bytes().into();
167
168        // Write: sequence|hmac|data
169        let line = format!(
170            "{}|{}|{}",
171            record.sequence,
172            hex::encode(new_hmac),
173            data
174        );
175        writeln!(self.writer, "{}", line).map_err(|e| SecureLogError::Io(e.to_string()))?;
176
177        // Flush to ensure durability
178        self.writer
179            .flush()
180            .map_err(|e| SecureLogError::Io(e.to_string()))?;
181
182        self.last_sequence = record.sequence;
183        self.last_hmac = new_hmac;
184
185        Ok(())
186    }
187
188    /// Encrypt data using AES-256-CTR
189    fn encrypt_data(key: &[u8; 32], nonce_base: u64, plaintext: &[u8]) -> Result<String, SecureLogError> {
190        use aes::Aes256;
191        use aes::cipher::{KeyIvInit, StreamCipher};
192        use ctr::Ctr64BE;
193
194        // Create nonce from sequence number (padded to 16 bytes)
195        let mut nonce = [0u8; 16];
196        nonce[8..16].copy_from_slice(&nonce_base.to_be_bytes());
197
198        let mut cipher = Ctr64BE::<Aes256>::new(key.into(), &nonce.into());
199
200        let mut ciphertext = plaintext.to_vec();
201        cipher.apply_keystream(&mut ciphertext);
202
203        Ok(hex::encode(ciphertext))
204    }
205
206    /// Decrypt data using AES-256-CTR
207    fn decrypt_data(key: &[u8; 32], nonce_base: u64, ciphertext_hex: &str) -> Result<String, SecureLogError> {
208        use aes::Aes256;
209        use aes::cipher::{KeyIvInit, StreamCipher};
210        use ctr::Ctr64BE;
211
212        let ciphertext = hex::decode(ciphertext_hex)
213            .map_err(|e| SecureLogError::Parse(format!("Invalid ciphertext hex: {}", e)))?;
214
215        // Create nonce from sequence number (padded to 16 bytes)
216        let mut nonce = [0u8; 16];
217        nonce[8..16].copy_from_slice(&nonce_base.to_be_bytes());
218
219        let mut cipher = Ctr64BE::<Aes256>::new(key.into(), &nonce.into());
220
221        let mut plaintext = ciphertext;
222        cipher.apply_keystream(&mut plaintext);
223
224        String::from_utf8(plaintext)
225            .map_err(|e| SecureLogError::Parse(format!("Invalid UTF-8 after decryption: {}", e)))
226    }
227
228    /// Get the last recorded sequence number
229    pub fn last_sequence(&self) -> u64 {
230        self.last_sequence
231    }
232
233    /// Verify and replay all records from the log
234    ///
235    /// This method verifies the HMAC chain integrity while replaying.
236    /// Any tampering will be detected.
237    pub fn replay_and_verify(&self) -> Result<Vec<DecisionRecord>, SecureLogError> {
238        let file = File::open(&self.config.path).map_err(|e| SecureLogError::Io(e.to_string()))?;
239        let reader = BufReader::new(file);
240
241        let mut records = Vec::new();
242        let mut expected_hmac = {
243            let mut mac = HmacSha256::new_from_slice(&self.config.hmac_key)
244                .map_err(|e| SecureLogError::Crypto(e.to_string()))?;
245            mac.update(b"nklave-secure-log-genesis");
246            mac.finalize().into_bytes()
247        };
248
249        for (line_num, line) in reader.lines().enumerate() {
250            let line = line.map_err(|e| SecureLogError::Io(e.to_string()))?;
251            if line.is_empty() {
252                continue;
253            }
254
255            let parts: Vec<&str> = line.splitn(3, '|').collect();
256            if parts.len() != 3 {
257                return Err(SecureLogError::Parse(format!(
258                    "Invalid line format at line {}",
259                    line_num + 1
260                )));
261            }
262
263            let seq: u64 = parts[0]
264                .parse()
265                .map_err(|_| SecureLogError::Parse("Invalid sequence number".to_string()))?;
266
267            let hmac_hex = parts[1];
268            let stored_hmac = hex::decode(hmac_hex)
269                .map_err(|e| SecureLogError::Parse(format!("Invalid HMAC hex: {}", e)))?;
270
271            let data = parts[2];
272
273            // Verify HMAC chain
274            let mut mac = HmacSha256::new_from_slice(&self.config.hmac_key)
275                .map_err(|e| SecureLogError::Crypto(e.to_string()))?;
276            mac.update(&expected_hmac);
277            mac.update(&seq.to_be_bytes());
278            mac.update(data.as_bytes());
279            let computed_hmac = mac.finalize().into_bytes();
280
281            if computed_hmac.as_slice() != stored_hmac.as_slice() {
282                return Err(SecureLogError::IntegrityViolation {
283                    sequence: seq,
284                    expected: hex::encode(computed_hmac),
285                    actual: hex::encode(&stored_hmac),
286                });
287            }
288
289            // Decrypt if needed
290            let json = if self.config.encryption_key.is_some() {
291                Self::decrypt_data(self.config.encryption_key.as_ref().unwrap(), seq, data)?
292            } else {
293                data.to_string()
294            };
295
296            // Parse record
297            let record: DecisionRecord =
298                serde_json::from_str(&json).map_err(|e| SecureLogError::Parse(e.to_string()))?;
299
300            if record.sequence != seq {
301                return Err(SecureLogError::Parse(format!(
302                    "Sequence mismatch: header says {}, record says {}",
303                    seq, record.sequence
304                )));
305            }
306
307            records.push(record);
308            expected_hmac = computed_hmac;
309        }
310
311        debug!(
312            record_count = records.len(),
313            "Verified and replayed secure log"
314        );
315
316        Ok(records)
317    }
318
319    /// Replay records starting from a specific sequence (with verification)
320    pub fn replay_from(&self, start_sequence: u64) -> Result<Vec<DecisionRecord>, SecureLogError> {
321        let records = self.replay_and_verify()?;
322        Ok(records
323            .into_iter()
324            .filter(|r| r.sequence >= start_sequence)
325            .collect())
326    }
327
328    /// Sync the log to disk
329    pub fn sync(&mut self) -> Result<(), SecureLogError> {
330        self.writer
331            .flush()
332            .map_err(|e| SecureLogError::Io(e.to_string()))?;
333        self.writer
334            .get_ref()
335            .sync_all()
336            .map_err(|e| SecureLogError::Io(e.to_string()))?;
337        Ok(())
338    }
339
340    /// Check if encryption is enabled
341    pub fn is_encrypted(&self) -> bool {
342        self.config.encryption_key.is_some()
343    }
344}
345
346/// Errors related to the secure decision log
347#[derive(Debug, Error)]
348pub enum SecureLogError {
349    #[error("I/O error: {0}")]
350    Io(String),
351
352    #[error("Parse error: {0}")]
353    Parse(String),
354
355    #[error("Serialization error: {0}")]
356    Serialize(String),
357
358    #[error("Crypto error: {0}")]
359    Crypto(String),
360
361    #[error("Sequence gap: expected {expected}, got {actual}")]
362    SequenceGap { expected: u64, actual: u64 },
363
364    #[error("Integrity violation at sequence {sequence}: expected HMAC {expected}, got {actual}")]
365    IntegrityViolation {
366        sequence: u64,
367        expected: String,
368        actual: String,
369    },
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use nklave_core::policy::types::{PolicyDecision, SigningType};
376    use tempfile::TempDir;
377
378    fn make_record(seq: u64) -> DecisionRecord {
379        DecisionRecord {
380            sequence: seq,
381            timestamp: 1234567890,
382            validator_pubkey: [0u8; 48],
383            request_type: SigningType::BlockProposal,
384            decision: PolicyDecision::Allow,
385            signing_root: [seq as u8; 32],
386            prev_state_hash: [0u8; 32],
387            signing_context: None,
388        }
389    }
390
391    #[test]
392    fn test_secure_log_integrity_only() {
393        let dir = TempDir::new().unwrap();
394        let log_path = dir.path().join("secure.log");
395        let hmac_key = [1u8; 32];
396
397        let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
398        let mut log = SecureDecisionLog::open(config).unwrap();
399
400        assert_eq!(log.last_sequence(), 0);
401        assert!(!log.is_encrypted());
402
403        log.append(&make_record(1)).unwrap();
404        log.append(&make_record(2)).unwrap();
405        log.append(&make_record(3)).unwrap();
406
407        assert_eq!(log.last_sequence(), 3);
408    }
409
410    #[test]
411    fn test_secure_log_with_encryption() {
412        let dir = TempDir::new().unwrap();
413        let log_path = dir.path().join("encrypted.log");
414        let encryption_key = [2u8; 32];
415        let hmac_key = [3u8; 32];
416
417        let config = SecureLogConfig::with_encryption(&log_path, encryption_key, hmac_key);
418        let mut log = SecureDecisionLog::open(config).unwrap();
419
420        assert!(log.is_encrypted());
421
422        log.append(&make_record(1)).unwrap();
423        log.append(&make_record(2)).unwrap();
424
425        assert_eq!(log.last_sequence(), 2);
426    }
427
428    #[test]
429    fn test_secure_log_replay_and_verify() {
430        let dir = TempDir::new().unwrap();
431        let log_path = dir.path().join("verify.log");
432        let hmac_key = [4u8; 32];
433
434        {
435            let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
436            let mut log = SecureDecisionLog::open(config).unwrap();
437            log.append(&make_record(1)).unwrap();
438            log.append(&make_record(2)).unwrap();
439            log.append(&make_record(3)).unwrap();
440        }
441
442        // Reopen and verify
443        let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
444        let log = SecureDecisionLog::open(config).unwrap();
445
446        let records = log.replay_and_verify().unwrap();
447        assert_eq!(records.len(), 3);
448        assert_eq!(records[0].sequence, 1);
449        assert_eq!(records[1].sequence, 2);
450        assert_eq!(records[2].sequence, 3);
451    }
452
453    #[test]
454    fn test_encrypted_log_replay() {
455        let dir = TempDir::new().unwrap();
456        let log_path = dir.path().join("encrypted_replay.log");
457        let encryption_key = [5u8; 32];
458        let hmac_key = [6u8; 32];
459
460        {
461            let config = SecureLogConfig::with_encryption(&log_path, encryption_key, hmac_key);
462            let mut log = SecureDecisionLog::open(config).unwrap();
463            log.append(&make_record(1)).unwrap();
464            log.append(&make_record(2)).unwrap();
465        }
466
467        // Reopen and verify
468        let config = SecureLogConfig::with_encryption(&log_path, encryption_key, hmac_key);
469        let log = SecureDecisionLog::open(config).unwrap();
470
471        let records = log.replay_and_verify().unwrap();
472        assert_eq!(records.len(), 2);
473        assert_eq!(records[0].signing_root, [1u8; 32]);
474        assert_eq!(records[1].signing_root, [2u8; 32]);
475    }
476
477    #[test]
478    fn test_tamper_detection() {
479        let dir = TempDir::new().unwrap();
480        let log_path = dir.path().join("tamper.log");
481        let hmac_key = [7u8; 32];
482
483        {
484            let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
485            let mut log = SecureDecisionLog::open(config).unwrap();
486            log.append(&make_record(1)).unwrap();
487            log.append(&make_record(2)).unwrap();
488        }
489
490        // Tamper with the file - modify the JSON data portion
491        let contents = std::fs::read_to_string(&log_path).unwrap();
492        // Replace "sequence":1 with "sequence":9 to tamper with the data
493        let tampered = contents.replace("\"sequence\":1", "\"sequence\":9");
494        std::fs::write(&log_path, tampered).unwrap();
495
496        // Verification should fail
497        let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
498        let log = SecureDecisionLog::open(config).unwrap();
499        let result = log.replay_and_verify();
500
501        assert!(matches!(result, Err(SecureLogError::IntegrityViolation { .. })));
502    }
503
504    #[test]
505    fn test_wrong_hmac_key() {
506        let dir = TempDir::new().unwrap();
507        let log_path = dir.path().join("wrong_key.log");
508        let hmac_key1 = [8u8; 32];
509        let hmac_key2 = [9u8; 32]; // Different key
510
511        {
512            let config = SecureLogConfig::integrity_only(&log_path, hmac_key1);
513            let mut log = SecureDecisionLog::open(config).unwrap();
514            log.append(&make_record(1)).unwrap();
515        }
516
517        // Try to verify with wrong key
518        let config = SecureLogConfig::integrity_only(&log_path, hmac_key2);
519        let log = SecureDecisionLog::open(config).unwrap();
520        let result = log.replay_and_verify();
521
522        assert!(matches!(result, Err(SecureLogError::IntegrityViolation { .. })));
523    }
524
525    #[test]
526    fn test_sequence_gap_rejected() {
527        let dir = TempDir::new().unwrap();
528        let log_path = dir.path().join("gap.log");
529        let hmac_key = [10u8; 32];
530
531        let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
532        let mut log = SecureDecisionLog::open(config).unwrap();
533
534        log.append(&make_record(1)).unwrap();
535        let result = log.append(&make_record(5)); // Skip sequences
536
537        assert!(matches!(result, Err(SecureLogError::SequenceGap { .. })));
538    }
539
540    #[test]
541    fn test_replay_from_sequence() {
542        let dir = TempDir::new().unwrap();
543        let log_path = dir.path().join("replay_from.log");
544        let hmac_key = [11u8; 32];
545
546        {
547            let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
548            let mut log = SecureDecisionLog::open(config).unwrap();
549            for i in 1..=5 {
550                log.append(&make_record(i)).unwrap();
551            }
552        }
553
554        let config = SecureLogConfig::integrity_only(&log_path, hmac_key);
555        let log = SecureDecisionLog::open(config).unwrap();
556
557        let records = log.replay_from(3).unwrap();
558        assert_eq!(records.len(), 3);
559        assert_eq!(records[0].sequence, 3);
560        assert_eq!(records[2].sequence, 5);
561    }
562}