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