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#[derive(Debug)]
72pub struct AuditVerifyResult {
73 pub entries_verified: u64,
74 pub chain_valid: bool,
75 pub chain_break_at: Option<u64>,
76}
77
78struct 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(×tamp.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
164pub(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 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 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
385pub 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
456pub 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
530pub 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}