Skip to main content

citadel/
audit.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8
9use citadel_core::{
10    AUDIT_ENTRY_MAGIC, AUDIT_HEADER_SIZE, AUDIT_LOG_MAGIC, AUDIT_LOG_VERSION, KEY_SIZE, MAC_SIZE,
11};
12
13type HmacSha256 = Hmac<Sha256>;
14
15#[derive(Debug, Clone)]
16pub struct AuditConfig {
17    pub enabled: bool,
18    pub max_file_size: u64,
19    pub max_rotated_files: u32,
20}
21
22impl Default for AuditConfig {
23    fn default() -> Self {
24        Self {
25            enabled: true,
26            max_file_size: 10 * 1024 * 1024,
27            max_rotated_files: 3,
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[repr(u16)]
34pub enum AuditEventType {
35    DatabaseCreated = 1,
36    DatabaseOpened = 2,
37    DatabaseClosed = 3,
38    PassphraseChanged = 4,
39    KeyBackupExported = 5,
40    BackupCreated = 6,
41    CompactionPerformed = 7,
42    IntegrityCheckPerformed = 8,
43}
44
45impl AuditEventType {
46    fn from_u16(v: u16) -> Option<Self> {
47        match v {
48            1 => Some(Self::DatabaseCreated),
49            2 => Some(Self::DatabaseOpened),
50            3 => Some(Self::DatabaseClosed),
51            4 => Some(Self::PassphraseChanged),
52            5 => Some(Self::KeyBackupExported),
53            6 => Some(Self::BackupCreated),
54            7 => Some(Self::CompactionPerformed),
55            8 => Some(Self::IntegrityCheckPerformed),
56            _ => None,
57        }
58    }
59}
60
61#[derive(Debug, Clone)]
62pub struct AuditEntry {
63    pub timestamp: u64,
64    pub sequence_no: u64,
65    pub event_type: AuditEventType,
66    pub detail: Vec<u8>,
67    pub hmac: [u8; MAC_SIZE],
68}
69
70/// Result of verifying an audit log's HMAC chain.
71#[derive(Debug)]
72pub struct AuditVerifyResult {
73    pub entries_verified: u64,
74    pub chain_valid: bool,
75    pub chain_break_at: Option<u64>,
76}
77
78/// Audit log file header (64 bytes).
79struct AuditHeader {
80    magic: u32,
81    version: u32,
82    file_id: u64,
83    created_at: u64,
84    entry_count: u64,
85    last_hmac: [u8; MAC_SIZE],
86}
87
88impl AuditHeader {
89    fn serialize(&self) -> [u8; AUDIT_HEADER_SIZE] {
90        let mut buf = [0u8; AUDIT_HEADER_SIZE];
91        buf[0..4].copy_from_slice(&self.magic.to_le_bytes());
92        buf[4..8].copy_from_slice(&self.version.to_le_bytes());
93        buf[8..16].copy_from_slice(&self.file_id.to_le_bytes());
94        buf[16..24].copy_from_slice(&self.created_at.to_le_bytes());
95        buf[24..32].copy_from_slice(&self.entry_count.to_le_bytes());
96        buf[32..64].copy_from_slice(&self.last_hmac);
97        buf
98    }
99
100    fn deserialize(buf: &[u8; AUDIT_HEADER_SIZE]) -> citadel_core::Result<Self> {
101        let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
102        if magic != AUDIT_LOG_MAGIC {
103            return Err(citadel_core::Error::InvalidMagic {
104                expected: AUDIT_LOG_MAGIC,
105                found: magic,
106            });
107        }
108        let version = u32::from_le_bytes(buf[4..8].try_into().unwrap());
109        if version != AUDIT_LOG_VERSION {
110            return Err(citadel_core::Error::UnsupportedVersion(version));
111        }
112        let mut last_hmac = [0u8; MAC_SIZE];
113        last_hmac.copy_from_slice(&buf[32..64]);
114        Ok(Self {
115            magic,
116            version,
117            file_id: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
118            created_at: u64::from_le_bytes(buf[16..24].try_into().unwrap()),
119            entry_count: u64::from_le_bytes(buf[24..32].try_into().unwrap()),
120            last_hmac,
121        })
122    }
123}
124
125fn now_nanos() -> u64 {
126    SystemTime::now()
127        .duration_since(UNIX_EPOCH)
128        .unwrap_or_default()
129        .as_nanos() as u64
130}
131
132fn compute_entry_hmac(
133    audit_key: &[u8; KEY_SIZE],
134    prev_hmac: &[u8; MAC_SIZE],
135    entry_data: &[u8],
136) -> [u8; MAC_SIZE] {
137    let mut mac = HmacSha256::new_from_slice(audit_key).expect("HMAC key size is always valid");
138    mac.update(prev_hmac);
139    mac.update(entry_data);
140    let result = mac.finalize().into_bytes();
141    let mut out = [0u8; MAC_SIZE];
142    out.copy_from_slice(&result);
143    out
144}
145
146fn serialize_entry_data(
147    timestamp: u64,
148    sequence_no: u64,
149    event_type: AuditEventType,
150    detail: &[u8],
151) -> Vec<u8> {
152    let detail_len = detail.len() as u16;
153    let entry_len = 4 + 8 + 8 + 2 + 2 + detail.len() + MAC_SIZE;
154    let mut buf = Vec::with_capacity(entry_len);
155    buf.extend_from_slice(&(entry_len as u32).to_le_bytes());
156    buf.extend_from_slice(&timestamp.to_le_bytes());
157    buf.extend_from_slice(&sequence_no.to_le_bytes());
158    buf.extend_from_slice(&(event_type as u16).to_le_bytes());
159    buf.extend_from_slice(&detail_len.to_le_bytes());
160    buf.extend_from_slice(detail);
161    buf
162}
163
164/// Internal audit log writer.
165pub(crate) struct AuditLog {
166    file: File,
167    audit_key: [u8; KEY_SIZE],
168    prev_hmac: [u8; MAC_SIZE],
169    sequence_no: u64,
170    entry_count: u64,
171    config: AuditConfig,
172    path: PathBuf,
173    file_id: u64,
174}
175
176impl AuditLog {
177    pub(crate) fn audit_key(&self) -> &[u8; KEY_SIZE] {
178        &self.audit_key
179    }
180
181    pub(crate) fn create(
182        path: &Path,
183        file_id: u64,
184        audit_key: [u8; KEY_SIZE],
185        config: AuditConfig,
186    ) -> citadel_core::Result<Self> {
187        let header = AuditHeader {
188            magic: AUDIT_LOG_MAGIC,
189            version: AUDIT_LOG_VERSION,
190            file_id,
191            created_at: now_nanos(),
192            entry_count: 0,
193            last_hmac: [0u8; MAC_SIZE],
194        };
195
196        let mut file = OpenOptions::new()
197            .read(true)
198            .write(true)
199            .create_new(true)
200            .open(path)?;
201
202        file.write_all(&header.serialize())?;
203        file.sync_data()?;
204
205        Ok(Self {
206            file,
207            audit_key,
208            prev_hmac: [0u8; MAC_SIZE],
209            sequence_no: 0,
210            entry_count: 0,
211            config,
212            path: path.to_path_buf(),
213            file_id,
214        })
215    }
216
217    /// Open an existing audit log file, seeking to the end for appending.
218    pub(crate) fn open_existing(
219        path: &Path,
220        file_id: u64,
221        audit_key: [u8; KEY_SIZE],
222        config: AuditConfig,
223    ) -> citadel_core::Result<Self> {
224        let mut file = OpenOptions::new().read(true).write(true).open(path)?;
225
226        let mut header_buf = [0u8; AUDIT_HEADER_SIZE];
227        file.read_exact(&mut header_buf)?;
228        let header = AuditHeader::deserialize(&header_buf)?;
229
230        if header.file_id != file_id {
231            return Err(citadel_core::Error::KeyFileMismatch);
232        }
233
234        let mut prev_hmac = [0u8; MAC_SIZE];
235        let mut sequence_no = 0u64;
236        let mut entry_count = 0u64;
237
238        loop {
239            let mut magic_buf = [0u8; 4];
240            match file.read_exact(&mut magic_buf) {
241                Ok(()) => {}
242                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
243                Err(e) => return Err(e.into()),
244            }
245            if u32::from_le_bytes(magic_buf) != AUDIT_ENTRY_MAGIC {
246                break;
247            }
248
249            let mut len_buf = [0u8; 4];
250            match file.read_exact(&mut len_buf) {
251                Ok(()) => {}
252                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
253                Err(e) => return Err(e.into()),
254            }
255
256            let entry_len = u32::from_le_bytes(len_buf) as usize;
257            if entry_len < 56 {
258                break;
259            }
260
261            let remaining = entry_len - 4;
262            let mut entry_buf = vec![0u8; remaining];
263            match file.read_exact(&mut entry_buf) {
264                Ok(()) => {}
265                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
266                Err(e) => return Err(e.into()),
267            }
268
269            sequence_no = u64::from_le_bytes(entry_buf[8..16].try_into().unwrap());
270            prev_hmac.copy_from_slice(&entry_buf[remaining - MAC_SIZE..]);
271            entry_count += 1;
272        }
273
274        file.seek(SeekFrom::End(0))?;
275
276        Ok(Self {
277            file,
278            audit_key,
279            prev_hmac,
280            sequence_no,
281            entry_count,
282            config,
283            path: path.to_path_buf(),
284            file_id,
285        })
286    }
287
288    pub(crate) fn log(
289        &mut self,
290        event_type: AuditEventType,
291        detail: &[u8],
292    ) -> citadel_core::Result<()> {
293        self.rotate_if_needed()?;
294
295        self.sequence_no += 1;
296        let timestamp = now_nanos();
297        let entry_data = serialize_entry_data(timestamp, self.sequence_no, event_type, detail);
298        let hmac = compute_entry_hmac(&self.audit_key, &self.prev_hmac, &entry_data);
299
300        self.file.write_all(&AUDIT_ENTRY_MAGIC.to_le_bytes())?;
301        self.file.write_all(&entry_data)?;
302        self.file.write_all(&hmac)?;
303        self.file.sync_data()?;
304
305        self.prev_hmac = hmac;
306        self.entry_count += 1;
307
308        self.update_header()?;
309
310        Ok(())
311    }
312
313    fn update_header(&mut self) -> citadel_core::Result<()> {
314        let pos = self.file.stream_position()?;
315
316        self.file.seek(SeekFrom::Start(24))?;
317        self.file.write_all(&self.entry_count.to_le_bytes())?;
318        self.file.write_all(&self.prev_hmac)?;
319        self.file.seek(SeekFrom::Start(pos))?;
320        Ok(())
321    }
322
323    fn rotate_if_needed(&mut self) -> citadel_core::Result<()> {
324        let file_size = self.file.seek(SeekFrom::End(0))?;
325        if file_size < self.config.max_file_size {
326            return Ok(());
327        }
328
329        self.file.sync_data()?;
330
331        // Shift rotated files: .N -> delete, .N-1 -> .N, ..., current -> .1
332        for i in (1..=self.config.max_rotated_files).rev() {
333            let src = if i == 1 {
334                self.path.clone()
335            } else {
336                rotated_path(&self.path, i - 1)
337            };
338            let dst = rotated_path(&self.path, i);
339
340            if i == self.config.max_rotated_files {
341                let _ = fs::remove_file(&dst);
342            }
343            if src.exists() {
344                let _ = fs::rename(&src, &dst);
345            }
346        }
347
348        let header = AuditHeader {
349            magic: AUDIT_LOG_MAGIC,
350            version: AUDIT_LOG_VERSION,
351            file_id: self.file_id,
352            created_at: now_nanos(),
353            entry_count: 0,
354            last_hmac: self.prev_hmac,
355        };
356
357        let mut file = OpenOptions::new()
358            .read(true)
359            .write(true)
360            .create_new(true)
361            .open(&self.path)?;
362
363        file.write_all(&header.serialize())?;
364        file.sync_data()?;
365
366        self.file = file;
367        self.entry_count = 0;
368
369        Ok(())
370    }
371}
372
373fn rotated_path(base: &Path, index: u32) -> PathBuf {
374    let mut name = base.as_os_str().to_os_string();
375    name.push(format!(".{index}"));
376    PathBuf::from(name)
377}
378
379pub(crate) fn resolve_audit_path(data_path: &Path) -> PathBuf {
380    let mut name = data_path.as_os_str().to_os_string();
381    name.push(".citadel-audit");
382    PathBuf::from(name)
383}
384
385/// Read all entries from an audit log file (no key needed).
386pub fn read_audit_log(path: &Path) -> citadel_core::Result<Vec<AuditEntry>> {
387    let mut file = File::open(path)?;
388
389    let mut header_buf = [0u8; AUDIT_HEADER_SIZE];
390    file.read_exact(&mut header_buf)?;
391    let _header = AuditHeader::deserialize(&header_buf)?;
392
393    let mut entries = Vec::new();
394
395    loop {
396        let mut magic_buf = [0u8; 4];
397        match file.read_exact(&mut magic_buf) {
398            Ok(()) => {}
399            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
400            Err(e) => return Err(e.into()),
401        }
402        if u32::from_le_bytes(magic_buf) != AUDIT_ENTRY_MAGIC {
403            break;
404        }
405
406        let mut len_buf = [0u8; 4];
407        match file.read_exact(&mut len_buf) {
408            Ok(()) => {}
409            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
410            Err(e) => return Err(e.into()),
411        }
412
413        let entry_len = u32::from_le_bytes(len_buf) as usize;
414        if entry_len < 56 {
415            break;
416        }
417
418        let remaining = entry_len - 4;
419        let mut entry_buf = vec![0u8; remaining];
420        match file.read_exact(&mut entry_buf) {
421            Ok(()) => {}
422            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
423            Err(e) => return Err(e.into()),
424        }
425
426        let timestamp = u64::from_le_bytes(entry_buf[0..8].try_into().unwrap());
427        let sequence_no = u64::from_le_bytes(entry_buf[8..16].try_into().unwrap());
428        let event_type_raw = u16::from_le_bytes(entry_buf[16..18].try_into().unwrap());
429        let detail_len = u16::from_le_bytes(entry_buf[18..20].try_into().unwrap()) as usize;
430
431        let event_type = match AuditEventType::from_u16(event_type_raw) {
432            Some(et) => et,
433            None => break,
434        };
435
436        if 20 + detail_len + MAC_SIZE != remaining {
437            break;
438        }
439
440        let detail = entry_buf[20..20 + detail_len].to_vec();
441        let mut hmac = [0u8; MAC_SIZE];
442        hmac.copy_from_slice(&entry_buf[remaining - MAC_SIZE..]);
443
444        entries.push(AuditEntry {
445            timestamp,
446            sequence_no,
447            event_type,
448            detail,
449            hmac,
450        });
451    }
452
453    Ok(entries)
454}
455
456/// Verify the HMAC chain of an audit log file.
457pub fn verify_audit_log(
458    path: &Path,
459    audit_key: &[u8; KEY_SIZE],
460) -> citadel_core::Result<AuditVerifyResult> {
461    let mut file = File::open(path)?;
462
463    let mut header_buf = [0u8; AUDIT_HEADER_SIZE];
464    file.read_exact(&mut header_buf)?;
465    let _header = AuditHeader::deserialize(&header_buf)?;
466
467    let mut prev_hmac = [0u8; MAC_SIZE];
468    let mut entries_verified = 0u64;
469
470    loop {
471        let mut magic_buf = [0u8; 4];
472        match file.read_exact(&mut magic_buf) {
473            Ok(()) => {}
474            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
475            Err(e) => return Err(e.into()),
476        }
477        if u32::from_le_bytes(magic_buf) != AUDIT_ENTRY_MAGIC {
478            break;
479        }
480
481        let mut len_buf = [0u8; 4];
482        match file.read_exact(&mut len_buf) {
483            Ok(()) => {}
484            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
485            Err(e) => return Err(e.into()),
486        }
487
488        let entry_len = u32::from_le_bytes(len_buf) as usize;
489        if entry_len < 56 {
490            break;
491        }
492
493        let remaining = entry_len - 4;
494        let mut entry_buf = vec![0u8; remaining];
495        match file.read_exact(&mut entry_buf) {
496            Ok(()) => {}
497            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
498            Err(e) => return Err(e.into()),
499        }
500
501        let sequence_no = u64::from_le_bytes(entry_buf[8..16].try_into().unwrap());
502
503        let data_len = remaining - MAC_SIZE;
504        let mut entry_data = Vec::with_capacity(4 + data_len);
505        entry_data.extend_from_slice(&len_buf);
506        entry_data.extend_from_slice(&entry_buf[..data_len]);
507
508        let stored_hmac = &entry_buf[remaining - MAC_SIZE..];
509        let expected_hmac = compute_entry_hmac(audit_key, &prev_hmac, &entry_data);
510
511        if stored_hmac != expected_hmac {
512            return Ok(AuditVerifyResult {
513                entries_verified,
514                chain_valid: false,
515                chain_break_at: Some(sequence_no),
516            });
517        }
518
519        prev_hmac.copy_from_slice(stored_hmac);
520        entries_verified += 1;
521    }
522
523    Ok(AuditVerifyResult {
524        entries_verified,
525        chain_valid: true,
526        chain_break_at: None,
527    })
528}
529
530/// Scan a corrupted audit log, recovering entries past damaged regions
531/// by scanning for per-entry sentinel markers.
532pub fn scan_corrupted_audit_log(path: &Path) -> citadel_core::Result<ScanResult> {
533    let data = fs::read(path)?;
534
535    if data.len() < AUDIT_HEADER_SIZE {
536        return Err(citadel_core::Error::Io(std::io::Error::new(
537            std::io::ErrorKind::InvalidData,
538            "audit file too small for header",
539        )));
540    }
541    let header_buf: [u8; AUDIT_HEADER_SIZE] = data[..AUDIT_HEADER_SIZE].try_into().unwrap();
542    let _header = AuditHeader::deserialize(&header_buf)?;
543
544    let magic_bytes = AUDIT_ENTRY_MAGIC.to_le_bytes();
545    let mut entries = Vec::new();
546    let mut corruption_offsets = Vec::new();
547    let mut offset = AUDIT_HEADER_SIZE;
548    let mut in_corruption = false;
549
550    while offset + 4 <= data.len() {
551        if data[offset..offset + 4] != magic_bytes {
552            if !in_corruption {
553                corruption_offsets.push(offset as u64);
554                in_corruption = true;
555            }
556            offset += 1;
557            continue;
558        }
559
560        if offset + 8 > data.len() {
561            break;
562        }
563        let entry_len =
564            u32::from_le_bytes(data[offset + 4..offset + 8].try_into().unwrap()) as usize;
565        if entry_len < 56 || offset + 4 + entry_len > data.len() {
566            if !in_corruption {
567                corruption_offsets.push(offset as u64);
568                in_corruption = true;
569            }
570            offset += 1;
571            continue;
572        }
573
574        let entry_start = offset + 8;
575        let remaining = entry_len - 4;
576
577        let event_type_raw =
578            u16::from_le_bytes(data[entry_start + 16..entry_start + 18].try_into().unwrap());
579        let detail_len =
580            u16::from_le_bytes(data[entry_start + 18..entry_start + 20].try_into().unwrap())
581                as usize;
582
583        if AuditEventType::from_u16(event_type_raw).is_none()
584            || 20 + detail_len + MAC_SIZE != remaining
585        {
586            if !in_corruption {
587                corruption_offsets.push(offset as u64);
588                in_corruption = true;
589            }
590            offset += 1;
591            continue;
592        }
593
594        let timestamp = u64::from_le_bytes(data[entry_start..entry_start + 8].try_into().unwrap());
595        let sequence_no =
596            u64::from_le_bytes(data[entry_start + 8..entry_start + 16].try_into().unwrap());
597        let event_type = AuditEventType::from_u16(event_type_raw).unwrap();
598        let detail = data[entry_start + 20..entry_start + 20 + detail_len].to_vec();
599        let mut hmac = [0u8; MAC_SIZE];
600        hmac.copy_from_slice(&data[entry_start + remaining - MAC_SIZE..entry_start + remaining]);
601
602        entries.push(AuditEntry {
603            timestamp,
604            sequence_no,
605            event_type,
606            detail,
607            hmac,
608        });
609
610        in_corruption = false;
611        offset = offset + 4 + entry_len;
612    }
613
614    Ok(ScanResult {
615        entries,
616        corruption_offsets,
617    })
618}
619
620#[derive(Debug)]
621pub struct ScanResult {
622    pub entries: Vec<AuditEntry>,
623    pub corruption_offsets: Vec<u64>,
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn header_serialize_deserialize_roundtrip() {
632        let header = AuditHeader {
633            magic: AUDIT_LOG_MAGIC,
634            version: AUDIT_LOG_VERSION,
635            file_id: 0xDEAD_BEEF,
636            created_at: 1234567890,
637            entry_count: 42,
638            last_hmac: [0xAB; MAC_SIZE],
639        };
640        let buf = header.serialize();
641        let h2 = AuditHeader::deserialize(&buf).unwrap();
642        assert_eq!(h2.magic, AUDIT_LOG_MAGIC);
643        assert_eq!(h2.version, AUDIT_LOG_VERSION);
644        assert_eq!(h2.file_id, 0xDEAD_BEEF);
645        assert_eq!(h2.created_at, 1234567890);
646        assert_eq!(h2.entry_count, 42);
647        assert_eq!(h2.last_hmac, [0xAB; MAC_SIZE]);
648    }
649
650    #[test]
651    fn header_invalid_magic_rejected() {
652        let mut buf = [0u8; AUDIT_HEADER_SIZE];
653        buf[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
654        let result = AuditHeader::deserialize(&buf);
655        assert!(matches!(
656            result,
657            Err(citadel_core::Error::InvalidMagic { .. })
658        ));
659    }
660
661    #[test]
662    fn entry_serialization_roundtrip() {
663        let data = serialize_entry_data(999, 1, AuditEventType::DatabaseCreated, &[0x01, 0x02]);
664        assert_eq!(data.len(), 4 + 8 + 8 + 2 + 2 + 2);
665        let entry_len = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
666        assert_eq!(entry_len, 26 + MAC_SIZE);
667    }
668
669    #[test]
670    fn hmac_chain_deterministic() {
671        let key = [0x42u8; KEY_SIZE];
672        let prev = [0u8; MAC_SIZE];
673        let data = b"test data";
674        let h1 = compute_entry_hmac(&key, &prev, data);
675        let h2 = compute_entry_hmac(&key, &prev, data);
676        assert_eq!(h1, h2);
677    }
678
679    #[test]
680    fn hmac_chain_changes_with_prev() {
681        let key = [0x42u8; KEY_SIZE];
682        let prev1 = [0u8; MAC_SIZE];
683        let prev2 = [0x01u8; MAC_SIZE];
684        let data = b"test data";
685        let h1 = compute_entry_hmac(&key, &prev1, data);
686        let h2 = compute_entry_hmac(&key, &prev2, data);
687        assert_ne!(h1, h2);
688    }
689
690    #[test]
691    fn event_type_roundtrip() {
692        for code in 1..=8u16 {
693            let et = AuditEventType::from_u16(code).unwrap();
694            assert_eq!(et as u16, code);
695        }
696        assert!(AuditEventType::from_u16(0).is_none());
697        assert!(AuditEventType::from_u16(9).is_none());
698    }
699
700    #[test]
701    fn create_and_log_entry() {
702        let dir = tempfile::tempdir().unwrap();
703        let path = dir.path().join("test.citadel-audit");
704        let key = [0x42u8; KEY_SIZE];
705
706        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
707        log.log(AuditEventType::DatabaseCreated, &[0x00, 0x00])
708            .unwrap();
709        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
710        drop(log);
711
712        let entries = read_audit_log(&path).unwrap();
713        assert_eq!(entries.len(), 2);
714        assert_eq!(entries[0].event_type, AuditEventType::DatabaseCreated);
715        assert_eq!(entries[0].sequence_no, 1);
716        assert_eq!(entries[0].detail, vec![0x00, 0x00]);
717        assert_eq!(entries[1].event_type, AuditEventType::DatabaseOpened);
718        assert_eq!(entries[1].sequence_no, 2);
719    }
720
721    #[test]
722    fn verify_valid_chain() {
723        let dir = tempfile::tempdir().unwrap();
724        let path = dir.path().join("test.citadel-audit");
725        let key = [0x42u8; KEY_SIZE];
726
727        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
728        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
729        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
730        log.log(AuditEventType::PassphraseChanged, &[]).unwrap();
731        drop(log);
732
733        let result = verify_audit_log(&path, &key).unwrap();
734        assert!(result.chain_valid);
735        assert_eq!(result.entries_verified, 3);
736        assert!(result.chain_break_at.is_none());
737    }
738
739    #[test]
740    fn verify_tamper_detected() {
741        let dir = tempfile::tempdir().unwrap();
742        let path = dir.path().join("test.citadel-audit");
743        let key = [0x42u8; KEY_SIZE];
744
745        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
746        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
747        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
748        drop(log);
749
750        let mut data = fs::read(&path).unwrap();
751        data[AUDIT_HEADER_SIZE + 4 + 5] ^= 0x01;
752        fs::write(&path, &data).unwrap();
753
754        let result = verify_audit_log(&path, &key).unwrap();
755        assert!(!result.chain_valid);
756        assert_eq!(result.chain_break_at, Some(1));
757    }
758
759    #[test]
760    fn verify_wrong_key_fails() {
761        let dir = tempfile::tempdir().unwrap();
762        let path = dir.path().join("test.citadel-audit");
763        let key = [0x42u8; KEY_SIZE];
764
765        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
766        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
767        drop(log);
768
769        let wrong_key = [0xFF; KEY_SIZE];
770        let result = verify_audit_log(&path, &wrong_key).unwrap();
771        assert!(!result.chain_valid);
772    }
773
774    #[test]
775    fn open_existing_appends() {
776        let dir = tempfile::tempdir().unwrap();
777        let path = dir.path().join("test.citadel-audit");
778        let key = [0x42u8; KEY_SIZE];
779
780        let mut log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
781        log.log(AuditEventType::DatabaseCreated, &[]).unwrap();
782        drop(log);
783
784        let mut log = AuditLog::open_existing(&path, 123, key, AuditConfig::default()).unwrap();
785        log.log(AuditEventType::DatabaseOpened, &[]).unwrap();
786        drop(log);
787
788        let entries = read_audit_log(&path).unwrap();
789        assert_eq!(entries.len(), 2);
790        assert_eq!(entries[0].sequence_no, 1);
791        assert_eq!(entries[1].sequence_no, 2);
792
793        let result = verify_audit_log(&path, &key).unwrap();
794        assert!(result.chain_valid);
795        assert_eq!(result.entries_verified, 2);
796    }
797
798    #[test]
799    fn rotation_triggers() {
800        let dir = tempfile::tempdir().unwrap();
801        let path = dir.path().join("test.citadel-audit");
802        let key = [0x42u8; KEY_SIZE];
803
804        let config = AuditConfig {
805            enabled: true,
806            max_file_size: 200,
807            max_rotated_files: 2,
808        };
809
810        let mut log = AuditLog::create(&path, 123, key, config).unwrap();
811        for _ in 0..10 {
812            log.log(AuditEventType::DatabaseOpened, &[0u8; 50]).unwrap();
813        }
814        drop(log);
815
816        let rotated = rotated_path(&path, 1);
817        assert!(rotated.exists());
818        assert!(path.exists());
819    }
820
821    #[test]
822    fn file_format_magic() {
823        let dir = tempfile::tempdir().unwrap();
824        let path = dir.path().join("test.citadel-audit");
825        let key = [0x42u8; KEY_SIZE];
826
827        let log = AuditLog::create(&path, 123, key, AuditConfig::default()).unwrap();
828        drop(log);
829
830        let data = fs::read(&path).unwrap();
831        let magic = u32::from_le_bytes(data[0..4].try_into().unwrap());
832        assert_eq!(magic, 0x4155_4454);
833    }
834}