1use anyhow::{Result, bail};
7use chrono::{DateTime, Utc};
8use parking_lot::Mutex;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::fs::OpenOptions;
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use uuid::Uuid;
15use zeroclaw_config::schema::AuditConfig;
16
17const GENESIS_PREV_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum AuditEventType {
24 CommandExecution,
25 FileAccess,
26 ConfigChange,
27 AuthSuccess,
28 AuthFailure,
29 PolicyViolation,
30 SecurityEvent,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Actor {
36 pub channel: String,
37 pub user_id: Option<String>,
38 pub username: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Action {
44 pub command: Option<String>,
45 pub risk_level: Option<String>,
46 pub approved: bool,
47 pub allowed: bool,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ExecutionResult {
53 pub success: bool,
54 pub exit_code: Option<i32>,
55 pub duration_ms: Option<u64>,
56 pub error: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SecurityContext {
62 pub policy_violation: bool,
63 pub rate_limit_remaining: Option<u32>,
64 pub sandbox_backend: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AuditEvent {
70 pub timestamp: DateTime<Utc>,
71 pub event_id: String,
72 pub event_type: AuditEventType,
73 pub actor: Option<Actor>,
74 pub action: Option<Action>,
75 pub result: Option<ExecutionResult>,
76 pub security: SecurityContext,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub agent_alias: Option<String>,
86
87 #[serde(default)]
89 pub sequence: u64,
90 #[serde(default)]
92 pub prev_hash: String,
93 #[serde(default)]
95 pub entry_hash: String,
96
97 #[serde(skip_serializing_if = "Option::is_none", default)]
99 pub signature: Option<String>,
100}
101
102impl AuditEvent {
103 pub fn new(event_type: AuditEventType) -> Self {
105 Self {
106 timestamp: Utc::now(),
107 event_id: Uuid::new_v4().to_string(),
108 event_type,
109 actor: None,
110 action: None,
111 result: None,
112 security: SecurityContext {
113 policy_violation: false,
114 rate_limit_remaining: None,
115 sandbox_backend: None,
116 },
117 agent_alias: None,
118 sequence: 0,
119 prev_hash: String::new(),
120 entry_hash: String::new(),
121 signature: None,
122 }
123 }
124
125 pub fn with_actor(
127 mut self,
128 channel: String,
129 user_id: Option<String>,
130 username: Option<String>,
131 ) -> Self {
132 self.actor = Some(Actor {
133 channel,
134 user_id,
135 username,
136 });
137 self
138 }
139
140 #[must_use]
145 pub fn with_agent_alias(mut self, agent_alias: impl Into<String>) -> Self {
146 self.agent_alias = Some(agent_alias.into());
147 self
148 }
149
150 pub fn with_action(
152 mut self,
153 command: String,
154 risk_level: String,
155 approved: bool,
156 allowed: bool,
157 ) -> Self {
158 self.action = Some(Action {
159 command: Some(command),
160 risk_level: Some(risk_level),
161 approved,
162 allowed,
163 });
164 self
165 }
166
167 pub fn with_result(
169 mut self,
170 success: bool,
171 exit_code: Option<i32>,
172 duration_ms: u64,
173 error: Option<String>,
174 ) -> Self {
175 self.result = Some(ExecutionResult {
176 success,
177 exit_code,
178 duration_ms: Some(duration_ms),
179 error,
180 });
181 self
182 }
183
184 pub fn with_security(mut self, sandbox_backend: Option<String>) -> Self {
186 self.security.sandbox_backend = sandbox_backend;
187 self
188 }
189}
190
191fn compute_entry_hash(prev_hash: &str, event: &AuditEvent) -> String {
196 let content = serde_json::json!({
198 "timestamp": event.timestamp,
199 "event_id": event.event_id,
200 "event_type": event.event_type,
201 "actor": event.actor,
202 "action": event.action,
203 "result": event.result,
204 "security": event.security,
205 "sequence": event.sequence,
206 });
207 let content_json = serde_json::to_string(&content).expect("serialize canonical content");
208
209 let mut hasher = Sha256::new();
210 hasher.update(prev_hash.as_bytes());
211 hasher.update(content_json.as_bytes());
212 hex::encode(hasher.finalize())
213}
214
215struct ChainState {
217 prev_hash: String,
218 sequence: u64,
219}
220
221pub struct AuditLogger {
223 log_path: PathBuf,
224 config: AuditConfig,
225 #[allow(dead_code)] buffer: Mutex<Vec<AuditEvent>>,
227 chain: Mutex<ChainState>,
228 signing_key: Option<Vec<u8>>,
230}
231
232#[derive(Debug, Clone)]
234pub struct CommandExecutionLog<'a> {
235 pub channel: &'a str,
236 pub command: &'a str,
237 pub risk_level: &'a str,
238 pub approved: bool,
239 pub allowed: bool,
240 pub success: bool,
241 pub duration_ms: u64,
242}
243
244impl AuditLogger {
245 pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result<Self> {
253 let signing_key = if config.sign_events {
255 let key_hex = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").map_err(|_| {
256 ::zeroclaw_log::record!(
257 ERROR,
258 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
259 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
260 "audit log: sign_events=true but ZEROCLAW_AUDIT_SIGNING_KEY env var not set"
261 );
262 anyhow::Error::msg("sign_events enabled but ZEROCLAW_AUDIT_SIGNING_KEY not set")
263 })?;
264
265 let key_bytes = hex::decode(&key_hex).map_err(|_| {
266 ::zeroclaw_log::record!(
267 ERROR,
268 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
269 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
270 "audit log: ZEROCLAW_AUDIT_SIGNING_KEY env var must be hex-encoded"
271 );
272 anyhow::Error::msg("ZEROCLAW_AUDIT_SIGNING_KEY must be hex-encoded")
273 })?;
274
275 if key_bytes.len() != 32 {
276 bail!(
277 "ZEROCLAW_AUDIT_SIGNING_KEY must be 32 bytes (64 hex chars), got {}",
278 key_bytes.len()
279 );
280 }
281
282 Some(key_bytes)
283 } else {
284 None
285 };
286
287 let log_path = zeroclaw_dir.join(&config.log_path);
288 let chain_state = recover_chain_state(&log_path);
289 Ok(Self {
290 log_path,
291 config,
292 buffer: Mutex::new(Vec::new()),
293 chain: Mutex::new(chain_state),
294 signing_key,
295 })
296 }
297
298 fn compute_signature(&self, entry_hash: &str) -> Result<Option<String>> {
300 if let Some(ref key_bytes) = self.signing_key {
301 use hmac::{Hmac, Mac};
302 use sha2::Sha256;
303
304 let mut mac = Hmac::<Sha256>::new_from_slice(key_bytes).map_err(|_| {
305 ::zeroclaw_log::record!(
306 ERROR,
307 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
308 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
309 "audit log: HMAC-SHA256 init rejected key length"
310 );
311 anyhow::Error::msg("Invalid HMAC key length")
312 })?;
313 mac.update(entry_hash.as_bytes());
314
315 Ok(Some(hex::encode(mac.finalize().into_bytes())))
316 } else {
317 Ok(None)
318 }
319 }
320
321 pub fn log(&self, event: &AuditEvent) -> Result<()> {
323 if !self.config.enabled {
324 return Ok(());
325 }
326
327 self.rotate_if_needed()?;
329
330 let mut chained = event.clone();
332 {
333 let mut state = self.chain.lock();
334 chained.sequence = state.sequence;
335 chained.prev_hash = state.prev_hash.clone();
336 chained.entry_hash = compute_entry_hash(&state.prev_hash, &chained);
337
338 chained.signature = self.compute_signature(&chained.entry_hash)?;
340
341 state.prev_hash = chained.entry_hash.clone();
342 state.sequence += 1;
343 }
344
345 let line = serde_json::to_string(&chained)?;
347 let mut file = OpenOptions::new()
348 .create(true)
349 .append(true)
350 .open(&self.log_path)?;
351
352 writeln!(file, "{}", line)?;
353 file.sync_all()?;
354
355 Ok(())
356 }
357
358 pub fn log_command_event(&self, entry: CommandExecutionLog<'_>) -> Result<()> {
360 let event = AuditEvent::new(AuditEventType::CommandExecution)
361 .with_actor(entry.channel.to_string(), None, None)
362 .with_action(
363 entry.command.to_string(),
364 entry.risk_level.to_string(),
365 entry.approved,
366 entry.allowed,
367 )
368 .with_result(entry.success, None, entry.duration_ms, None);
369
370 self.log(&event)
371 }
372
373 #[allow(clippy::too_many_arguments)]
375 pub fn log_command(
376 &self,
377 channel: &str,
378 command: &str,
379 risk_level: &str,
380 approved: bool,
381 allowed: bool,
382 success: bool,
383 duration_ms: u64,
384 ) -> Result<()> {
385 self.log_command_event(CommandExecutionLog {
386 channel,
387 command,
388 risk_level,
389 approved,
390 allowed,
391 success,
392 duration_ms,
393 })
394 }
395
396 fn rotate_if_needed(&self) -> Result<()> {
398 if let Ok(metadata) = std::fs::metadata(&self.log_path) {
399 let current_size_mb = metadata.len() / (1024 * 1024);
400 if current_size_mb >= u64::from(self.config.max_size_mb) {
401 self.rotate()?;
402 }
403 }
404 Ok(())
405 }
406
407 fn rotate(&self) -> Result<()> {
409 for i in (1..10).rev() {
410 let old_name = format!("{}.{}.log", self.log_path.display().to_string(), i);
411 let new_name = format!("{}.{}.log", self.log_path.display().to_string(), i + 1);
412 let _ = std::fs::rename(&old_name, &new_name);
413 }
414
415 let rotated = format!("{}.1.log", self.log_path.display().to_string());
416 std::fs::rename(&self.log_path, &rotated)?;
417 Ok(())
418 }
419}
420
421fn recover_chain_state(log_path: &Path) -> ChainState {
425 let file = match std::fs::File::open(log_path) {
426 Ok(f) => f,
427 Err(_) => {
428 return ChainState {
429 prev_hash: GENESIS_PREV_HASH.to_string(),
430 sequence: 0,
431 };
432 }
433 };
434
435 let reader = BufReader::new(file);
436 let mut last_entry: Option<AuditEvent> = None;
437 for l in reader.lines().map_while(Result::ok) {
438 if let Ok(entry) = serde_json::from_str::<AuditEvent>(&l) {
439 last_entry = Some(entry);
440 }
441 }
442
443 match last_entry {
444 Some(entry) => ChainState {
445 prev_hash: entry.entry_hash,
446 sequence: entry.sequence + 1,
447 },
448 None => ChainState {
449 prev_hash: GENESIS_PREV_HASH.to_string(),
450 sequence: 0,
451 },
452 }
453}
454
455pub fn verify_chain(log_path: &Path) -> Result<u64> {
466 let file = std::fs::File::open(log_path)?;
467 let reader = BufReader::new(file);
468
469 let mut expected_prev_hash = GENESIS_PREV_HASH.to_string();
470 let mut expected_sequence: u64 = 0;
471
472 let signing_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY")
474 .ok()
475 .and_then(|key_hex| hex::decode(&key_hex).ok())
476 .filter(|key_bytes| key_bytes.len() == 32);
477
478 for (line_idx, line) in reader.lines().enumerate() {
479 let line = line?;
480 if line.trim().is_empty() {
481 continue;
482 }
483 let entry: AuditEvent = serde_json::from_str(&line)?;
484
485 if entry.sequence != expected_sequence {
487 bail!(
488 "sequence gap at line {}: expected {}, got {}",
489 line_idx + 1,
490 expected_sequence,
491 entry.sequence
492 );
493 }
494
495 if entry.prev_hash != expected_prev_hash {
497 bail!(
498 "prev_hash mismatch at line {} (sequence {}): expected {}, got {}",
499 line_idx + 1,
500 entry.sequence,
501 expected_prev_hash,
502 entry.prev_hash
503 );
504 }
505
506 let recomputed = compute_entry_hash(&entry.prev_hash, &entry);
508 if entry.entry_hash != recomputed {
509 bail!(
510 "entry_hash mismatch at line {} (sequence {}): expected {}, got {}",
511 line_idx + 1,
512 entry.sequence,
513 recomputed,
514 entry.entry_hash
515 );
516 }
517
518 if let Some(ref signature) = entry.signature
520 && let Some(ref key_bytes) = signing_key
521 {
522 use hmac::{Hmac, Mac};
523 use sha2::Sha256;
524
525 let mut mac = Hmac::<Sha256>::new_from_slice(key_bytes).map_err(|_| {
526 ::zeroclaw_log::record!(
527 ERROR,
528 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
529 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
530 "audit log: HMAC-SHA256 verify rejected key length"
531 );
532 anyhow::Error::msg("Invalid HMAC key length during verification")
533 })?;
534 mac.update(entry.entry_hash.as_bytes());
535 let expected_sig = hex::encode(mac.finalize().into_bytes());
536
537 if signature != &expected_sig {
538 bail!(
539 "signature verification failed at line {} (sequence {}): signature mismatch",
540 line_idx + 1,
541 entry.sequence
542 );
543 }
544 }
545 expected_prev_hash = entry.entry_hash.clone();
548 expected_sequence += 1;
549 }
550
551 Ok(expected_sequence)
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use scopeguard::defer;
558 use std::sync::Mutex;
559 use tempfile::TempDir;
560
561 static ENV_MUTEX: Mutex<()> = Mutex::new(());
563
564 #[test]
565 fn audit_event_new_creates_unique_id() {
566 let event1 = AuditEvent::new(AuditEventType::CommandExecution);
567 let event2 = AuditEvent::new(AuditEventType::CommandExecution);
568 assert_ne!(event1.event_id, event2.event_id);
569 }
570
571 #[test]
572 fn audit_event_with_actor() {
573 let event = AuditEvent::new(AuditEventType::CommandExecution).with_actor(
574 "telegram".to_string(),
575 Some("123".to_string()),
576 Some("@zeroclaw_user".to_string()),
577 );
578
579 assert!(event.actor.is_some());
580 let actor = event.actor.as_ref().unwrap();
581 assert_eq!(actor.channel, "telegram");
582 assert_eq!(actor.user_id, Some("123".to_string()));
583 assert_eq!(actor.username, Some("@zeroclaw_user".to_string()));
584 }
585
586 #[test]
587 fn audit_event_with_action() {
588 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
589 "ls -la".to_string(),
590 "low".to_string(),
591 false,
592 true,
593 );
594
595 assert!(event.action.is_some());
596 let action = event.action.as_ref().unwrap();
597 assert_eq!(action.command, Some("ls -la".to_string()));
598 assert_eq!(action.risk_level, Some("low".to_string()));
599 }
600
601 #[test]
602 fn audit_event_serializes_to_json() {
603 let event = AuditEvent::new(AuditEventType::CommandExecution)
604 .with_actor("telegram".to_string(), None, None)
605 .with_action("ls".to_string(), "low".to_string(), false, true)
606 .with_result(true, Some(0), 15, None);
607
608 let json = serde_json::to_string(&event);
609 assert!(json.is_ok());
610 let json = json.expect("serialize");
611 let parsed: AuditEvent = serde_json::from_str(json.as_str()).expect("parse");
612 assert!(parsed.actor.is_some());
613 assert!(parsed.action.is_some());
614 assert!(parsed.result.is_some());
615 }
616
617 #[test]
618 fn audit_logger_disabled_does_not_create_file() -> Result<()> {
619 let tmp = TempDir::new()?;
620 let config = AuditConfig {
621 enabled: false,
622 ..Default::default()
623 };
624 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
625 let event = AuditEvent::new(AuditEventType::CommandExecution);
626
627 logger.log(&event)?;
628
629 assert!(!tmp.path().join("audit.log").exists());
631 Ok(())
632 }
633
634 #[tokio::test]
637 async fn audit_logger_writes_event_when_enabled() -> Result<()> {
638 let tmp = TempDir::new()?;
639 let config = AuditConfig {
640 enabled: true,
641 max_size_mb: 10,
642 ..Default::default()
643 };
644 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
645 let event = AuditEvent::new(AuditEventType::CommandExecution)
646 .with_actor("cli".to_string(), None, None)
647 .with_action("ls".to_string(), "low".to_string(), false, true);
648
649 logger.log(&event)?;
650
651 let log_path = tmp.path().join("audit.log");
652 assert!(log_path.exists(), "audit log file must be created");
653
654 let content = tokio::fs::read_to_string(&log_path).await?;
655 assert!(!content.is_empty(), "audit log must not be empty");
656
657 let parsed: AuditEvent = serde_json::from_str(content.trim())?;
658 assert!(parsed.action.is_some());
659 Ok(())
660 }
661
662 #[tokio::test]
663 async fn audit_log_command_event_writes_structured_entry() -> Result<()> {
664 let tmp = TempDir::new()?;
665 let config = AuditConfig {
666 enabled: true,
667 max_size_mb: 10,
668 ..Default::default()
669 };
670 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
671
672 logger.log_command_event(CommandExecutionLog {
673 channel: "telegram",
674 command: "echo test",
675 risk_level: "low",
676 approved: false,
677 allowed: true,
678 success: true,
679 duration_ms: 42,
680 })?;
681
682 let log_path = tmp.path().join("audit.log");
683 let content = tokio::fs::read_to_string(&log_path).await?;
684 let parsed: AuditEvent = serde_json::from_str(content.trim())?;
685
686 let action = parsed.action.unwrap();
687 assert_eq!(action.command, Some("echo test".to_string()));
688 assert_eq!(action.risk_level, Some("low".to_string()));
689 assert!(action.allowed);
690
691 let result = parsed.result.unwrap();
692 assert!(result.success);
693 assert_eq!(result.duration_ms, Some(42));
694 Ok(())
695 }
696
697 #[test]
698 fn audit_rotation_creates_numbered_backup() -> Result<()> {
699 let tmp = TempDir::new()?;
700 let config = AuditConfig {
701 enabled: true,
702 max_size_mb: 0, ..Default::default()
704 };
705 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
706
707 let log_path = tmp.path().join("audit.log");
709 std::fs::write(&log_path, "initial content\n")?;
710
711 let event = AuditEvent::new(AuditEventType::CommandExecution);
712 logger.log(&event)?;
713
714 let rotated = format!("{}.1.log", log_path.display().to_string());
715 assert!(
716 std::path::Path::new(&rotated).exists(),
717 "rotation must create .1.log backup"
718 );
719 Ok(())
720 }
721
722 #[test]
725 fn merkle_chain_genesis_uses_well_known_seed() -> Result<()> {
726 let tmp = TempDir::new()?;
727 let config = AuditConfig {
728 enabled: true,
729 max_size_mb: 10,
730 ..Default::default()
731 };
732 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
733
734 let event = AuditEvent::new(AuditEventType::SecurityEvent);
735 logger.log(&event)?;
736
737 let log_path = tmp.path().join("audit.log");
738 let content = std::fs::read_to_string(&log_path)?;
739 let parsed: AuditEvent = serde_json::from_str(content.trim())?;
740
741 assert_eq!(parsed.sequence, 0);
742 assert_eq!(parsed.prev_hash, GENESIS_PREV_HASH);
743 assert!(!parsed.entry_hash.is_empty());
744 Ok(())
745 }
746
747 #[test]
748 fn merkle_chain_multiple_entries_verify() -> Result<()> {
749 let tmp = TempDir::new()?;
750 let config = AuditConfig {
751 enabled: true,
752 max_size_mb: 10,
753 ..Default::default()
754 };
755 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
756
757 for i in 0..5 {
759 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
760 format!("cmd-{}", i),
761 "low".to_string(),
762 false,
763 true,
764 );
765 logger.log(&event)?;
766 }
767
768 let log_path = tmp.path().join("audit.log");
769 let count = verify_chain(&log_path)?;
770 assert_eq!(count, 5);
771 Ok(())
772 }
773
774 #[test]
775 fn merkle_chain_detects_tampered_entry() -> Result<()> {
776 let tmp = TempDir::new()?;
777 let config = AuditConfig {
778 enabled: true,
779 max_size_mb: 10,
780 ..Default::default()
781 };
782 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
783
784 for i in 0..3 {
785 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
786 format!("cmd-{}", i),
787 "low".to_string(),
788 false,
789 true,
790 );
791 logger.log(&event)?;
792 }
793
794 let log_path = tmp.path().join("audit.log");
796 let content = std::fs::read_to_string(&log_path)?;
797 let lines: Vec<&str> = content.lines().collect();
798 assert_eq!(lines.len(), 3);
799
800 let mut entry: serde_json::Value = serde_json::from_str(lines[1])?;
801 entry["action"]["command"] = serde_json::Value::String("TAMPERED".to_string());
802 let tampered_line = serde_json::to_string(&entry)?;
803
804 let tampered_content = format!("{}\n{}\n{}\n", lines[0], tampered_line, lines[2]);
805 std::fs::write(&log_path, tampered_content)?;
806
807 let result = verify_chain(&log_path);
809 assert!(result.is_err());
810 let err_msg = result.unwrap_err().to_string();
811 assert!(
812 err_msg.contains("entry_hash mismatch"),
813 "expected entry_hash mismatch, got: {}",
814 err_msg
815 );
816 Ok(())
817 }
818
819 #[test]
820 fn merkle_chain_detects_sequence_gap() -> Result<()> {
821 let tmp = TempDir::new()?;
822 let config = AuditConfig {
823 enabled: true,
824 max_size_mb: 10,
825 ..Default::default()
826 };
827 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
828
829 for i in 0..3 {
830 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
831 format!("cmd-{}", i),
832 "low".to_string(),
833 false,
834 true,
835 );
836 logger.log(&event)?;
837 }
838
839 let log_path = tmp.path().join("audit.log");
841 let content = std::fs::read_to_string(&log_path)?;
842 let lines: Vec<&str> = content.lines().collect();
843 let gapped_content = format!("{}\n{}\n", lines[0], lines[2]);
844 std::fs::write(&log_path, gapped_content)?;
845
846 let result = verify_chain(&log_path);
847 assert!(result.is_err());
848 let err_msg = result.unwrap_err().to_string();
849 assert!(
850 err_msg.contains("sequence gap"),
851 "expected sequence gap, got: {}",
852 err_msg
853 );
854 Ok(())
855 }
856
857 #[test]
858 fn merkle_chain_recovery_continues_after_restart() -> Result<()> {
859 let tmp = TempDir::new()?;
860 let log_path = tmp.path().join("audit.log");
861
862 {
864 let config = AuditConfig {
865 enabled: true,
866 max_size_mb: 10,
867 ..Default::default()
868 };
869 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
870 for i in 0..2 {
871 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
872 format!("batch1-{}", i),
873 "low".to_string(),
874 false,
875 true,
876 );
877 logger.log(&event)?;
878 }
879 }
880
881 {
883 let config = AuditConfig {
884 enabled: true,
885 max_size_mb: 10,
886 ..Default::default()
887 };
888 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
889 for i in 0..2 {
890 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
891 format!("batch2-{}", i),
892 "low".to_string(),
893 false,
894 true,
895 );
896 logger.log(&event)?;
897 }
898 }
899
900 let count = verify_chain(&log_path)?;
902 assert_eq!(count, 4);
903 Ok(())
904 }
905
906 #[test]
909 fn signature_present_when_sign_events_enabled() -> Result<()> {
910 let _guard = ENV_MUTEX.lock().unwrap();
911 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
912 defer! {
913 if let Some(key) = old_key {
914 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
916 } else {
917 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
919 }
920 }
921
922 let tmp = TempDir::new()?;
923 let test_key = "a".repeat(64); unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", &test_key) };
926
927 let config = AuditConfig {
928 enabled: true,
929 sign_events: true,
930 ..Default::default()
931 };
932 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
933 let event = AuditEvent::new(AuditEventType::CommandExecution);
934
935 logger.log(&event)?;
936
937 let log_path = tmp.path().join("audit.log");
938 let content = std::fs::read_to_string(&log_path)?;
939 let parsed: AuditEvent = serde_json::from_str(content.trim())?;
940
941 assert!(
942 parsed.signature.is_some(),
943 "signature must be present when sign_events=true"
944 );
945 let sig = parsed.signature.unwrap();
946 assert_eq!(sig.len(), 64, "HMAC-SHA256 signature must be 64 hex chars");
947
948 Ok(())
949 }
950
951 #[test]
952 fn signature_absent_when_sign_events_disabled() -> Result<()> {
953 let _guard = ENV_MUTEX.lock().unwrap();
954 let tmp = TempDir::new()?;
955 let config = AuditConfig {
956 enabled: true,
957 sign_events: false,
958 ..Default::default()
959 };
960 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
961 let event = AuditEvent::new(AuditEventType::CommandExecution);
962
963 logger.log(&event)?;
964
965 let log_path = tmp.path().join("audit.log");
966 let content = std::fs::read_to_string(&log_path)?;
967 let parsed: AuditEvent = serde_json::from_str(content.trim())?;
968
969 assert!(
970 parsed.signature.is_none(),
971 "signature must be absent when sign_events=false"
972 );
973 Ok(())
974 }
975
976 #[test]
977 fn signature_computed_over_entry_hash() -> Result<()> {
978 let _guard = ENV_MUTEX.lock().unwrap();
979 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
980 defer! {
981 if let Some(key) = old_key {
982 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
984 } else {
985 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
987 }
988 }
989
990 let tmp = TempDir::new()?;
991 let test_key = "b".repeat(64);
992 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", &test_key) };
994
995 let config = AuditConfig {
996 enabled: true,
997 sign_events: true,
998 ..Default::default()
999 };
1000 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
1001 let event = AuditEvent::new(AuditEventType::CommandExecution);
1002
1003 logger.log(&event)?;
1004
1005 let log_path = tmp.path().join("audit.log");
1006 let content = std::fs::read_to_string(&log_path)?;
1007 let parsed: AuditEvent = serde_json::from_str(content.trim())?;
1008
1009 use hmac::{Hmac, Mac};
1011 use sha2::Sha256;
1012 let key_bytes = hex::decode(&test_key)?;
1013 let mut mac = Hmac::<Sha256>::new_from_slice(&key_bytes).unwrap();
1014 mac.update(parsed.entry_hash.as_bytes());
1015 let expected_sig = hex::encode(mac.finalize().into_bytes());
1016
1017 assert_eq!(parsed.signature, Some(expected_sig));
1018
1019 Ok(())
1020 }
1021
1022 #[test]
1023 fn constructor_fails_if_sign_events_but_no_key() -> Result<()> {
1024 let _guard = ENV_MUTEX.lock().unwrap();
1025 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
1026 defer! {
1027 if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1029 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1031 } else {
1032 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1034 }
1035 }
1036
1037 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1039
1040 let tmp = TempDir::new()?;
1041 let config = AuditConfig {
1042 enabled: true,
1043 sign_events: true,
1044 ..Default::default()
1045 };
1046
1047 let result = AuditLogger::new(config, tmp.path().to_path_buf());
1048 assert!(result.is_err());
1049 if let Err(e) = result {
1050 let err_msg = e.to_string();
1051 assert!(
1052 err_msg.contains("ZEROCLAW_AUDIT_SIGNING_KEY not set"),
1053 "error: {}",
1054 err_msg
1055 );
1056 }
1057
1058 Ok(())
1059 }
1060
1061 #[test]
1062 fn constructor_fails_if_signing_key_invalid_hex() -> Result<()> {
1063 let _guard = ENV_MUTEX.lock().unwrap();
1064 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
1065 defer! {
1066 if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1068 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1070 } else {
1071 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1073 }
1074 }
1075
1076 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", "not-valid-hex") };
1078
1079 let tmp = TempDir::new()?;
1080 let config = AuditConfig {
1081 enabled: true,
1082 sign_events: true,
1083 ..Default::default()
1084 };
1085
1086 let result = AuditLogger::new(config, tmp.path().to_path_buf());
1087 assert!(result.is_err());
1088 if let Err(e) = result {
1089 let err_msg = e.to_string();
1090 assert!(
1091 err_msg.contains("must be hex-encoded"),
1092 "error: {}",
1093 err_msg
1094 );
1095 }
1096
1097 Ok(())
1098 }
1099
1100 #[test]
1101 fn constructor_fails_if_signing_key_wrong_length() -> Result<()> {
1102 let _guard = ENV_MUTEX.lock().unwrap();
1103 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
1104 defer! {
1105 if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1107 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1109 } else {
1110 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1112 }
1113 }
1114
1115 let short_key = "c".repeat(60);
1117 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", &short_key) };
1119 let tmp = TempDir::new()?;
1120 let config = AuditConfig {
1121 enabled: true,
1122 sign_events: true,
1123 ..Default::default()
1124 };
1125
1126 let result = AuditLogger::new(config, tmp.path().to_path_buf());
1127 assert!(result.is_err());
1128 if let Err(e) = result {
1129 let err_msg = e.to_string();
1130 assert!(err_msg.contains("must be 32 bytes"), "error: {}", err_msg);
1131 }
1132
1133 Ok(())
1134 }
1135
1136 #[test]
1137 fn different_keys_produce_different_signatures() -> Result<()> {
1138 let _guard = ENV_MUTEX.lock().unwrap();
1139 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
1140 defer! {
1141 if let Some(key) = old_key {
1142 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1144 } else {
1145 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1147 }
1148 }
1149
1150 let _tmp = TempDir::new()?;
1151
1152 let key1 = "d".repeat(64);
1154 let key1_bytes = hex::decode(&key1)?;
1155
1156 let key2 = "e".repeat(64);
1158 let key2_bytes = hex::decode(&key2)?;
1159
1160 let test_entry_hash = "test_hash_value";
1162
1163 use hmac::{Hmac, Mac};
1164 use sha2::Sha256;
1165
1166 let mut mac1 = Hmac::<Sha256>::new_from_slice(&key1_bytes).unwrap();
1167 mac1.update(test_entry_hash.as_bytes());
1168 let sig1 = hex::encode(mac1.finalize().into_bytes());
1169
1170 let mut mac2 = Hmac::<Sha256>::new_from_slice(&key2_bytes).unwrap();
1171 mac2.update(test_entry_hash.as_bytes());
1172 let sig2 = hex::encode(mac2.finalize().into_bytes());
1173
1174 assert_ne!(
1175 sig1, sig2,
1176 "different keys must produce different signatures"
1177 );
1178
1179 Ok(())
1180 }
1181
1182 #[test]
1183 fn signature_deterministic_for_same_entry_hash() -> Result<()> {
1184 let _guard = ENV_MUTEX.lock().unwrap();
1185 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
1186 defer! {
1187 if let Some(key) = old_key {
1188 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1190 } else {
1191 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1193 }
1194 }
1195
1196 let tmp = TempDir::new()?;
1197 let test_key = "f".repeat(64);
1198 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", &test_key) };
1200
1201 let config = AuditConfig {
1202 enabled: true,
1203 sign_events: true,
1204 ..Default::default()
1205 };
1206 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
1207
1208 for _ in 0..2 {
1210 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
1211 "cmd".to_string(),
1212 "low".to_string(),
1213 false,
1214 true,
1215 );
1216 logger.log(&event)?;
1217 }
1218
1219 let log_path = tmp.path().join("audit.log");
1220 let content = std::fs::read_to_string(&log_path)?;
1221 let lines: Vec<&str> = content.lines().collect();
1222 let event1: AuditEvent = serde_json::from_str(lines[0])?;
1223 let event2: AuditEvent = serde_json::from_str(lines[1])?;
1224
1225 assert_ne!(event1.entry_hash, event2.entry_hash);
1227 assert_ne!(event1.signature, event2.signature);
1228
1229 use hmac::{Hmac, Mac};
1231 use sha2::Sha256;
1232 let key_bytes = hex::decode(&test_key)?;
1233 let mut mac = Hmac::<Sha256>::new_from_slice(&key_bytes).unwrap();
1234 mac.update(event1.entry_hash.as_bytes());
1235 let expected_sig1 = hex::encode(mac.finalize().into_bytes());
1236 assert_eq!(event1.signature, Some(expected_sig1));
1237
1238 Ok(())
1239 }
1240
1241 #[test]
1242 fn verify_chain_accepts_mixed_signed_and_unsigned_records() -> Result<()> {
1243 let _guard = ENV_MUTEX.lock().unwrap();
1244 let old_key = std::env::var("ZEROCLAW_AUDIT_SIGNING_KEY").ok();
1245 defer! {
1246 if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1247 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1249 } else {
1250 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1252 }
1253 }
1254
1255 let tmp = TempDir::new()?;
1256 let log_path = tmp.path().join("audit.log");
1257 let test_key = "a1".repeat(32); {
1261 unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1263 let config = AuditConfig {
1264 enabled: true,
1265 sign_events: false,
1266 ..Default::default()
1267 };
1268 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
1269 for i in 0..2 {
1270 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
1271 format!("unsigned-{}", i),
1272 "low".to_string(),
1273 false,
1274 true,
1275 );
1276 logger.log(&event)?;
1277 }
1278 }
1279
1280 {
1282 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", &test_key) };
1284 let config = AuditConfig {
1285 enabled: true,
1286 sign_events: true,
1287 ..Default::default()
1288 };
1289 let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
1290 for i in 0..2 {
1291 let event = AuditEvent::new(AuditEventType::CommandExecution).with_action(
1292 format!("signed-{}", i),
1293 "low".to_string(),
1294 false,
1295 true,
1296 );
1297 logger.log(&event)?;
1298 }
1299 }
1300
1301 unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", &test_key) };
1305 let count = verify_chain(&log_path)?;
1306 assert_eq!(count, 4, "should verify all 4 records");
1307
1308 let content = std::fs::read_to_string(&log_path)?;
1310 let lines: Vec<&str> = content.lines().collect();
1311 assert_eq!(lines.len(), 4);
1312
1313 let rec0: AuditEvent = serde_json::from_str(lines[0])?;
1314 let rec1: AuditEvent = serde_json::from_str(lines[1])?;
1315 let rec2: AuditEvent = serde_json::from_str(lines[2])?;
1316 let rec3: AuditEvent = serde_json::from_str(lines[3])?;
1317
1318 assert!(rec0.signature.is_none(), "first unsigned record");
1319 assert!(rec1.signature.is_none(), "second unsigned record");
1320 assert!(rec2.signature.is_some(), "first signed record");
1321 assert!(rec3.signature.is_some(), "second signed record");
1322
1323 Ok(())
1324 }
1325}