Skip to main content

zeroclaw_runtime/security/
audit.rs

1//! Audit logging for security events
2//!
3//! Each audit entry is chained via a Merkle hash: `entry_hash = SHA-256(prev_hash || canonical_json)`.
4//! This makes the trail tamper-evident — modifying any entry invalidates all subsequent hashes.
5
6use 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
17/// Well-known seed for the genesis entry's `prev_hash`.
18const GENESIS_PREV_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
19
20/// Audit event types
21#[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/// Actor information (who performed the action)
34#[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/// Action information (what was done)
42#[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/// Execution result
51#[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/// Security context
60#[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/// Complete audit event with Merkle hash-chain fields.
68#[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    /// Owning agent's alias. `None` on system-level events (boot,
78    /// migration, scheduler ticks not bound to any specific agent) and
79    /// on legacy entries written before the field existed. Audit
80    /// storage stays at `<install>/audit/` (global, not per-agent), so
81    /// an agent delete does NOT remove its prior audit trail; this
82    /// field lets queries reconstruct per-agent activity after the
83    /// fact.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub agent_alias: Option<String>,
86
87    /// Monotonically increasing sequence number.
88    #[serde(default)]
89    pub sequence: u64,
90    /// SHA-256 hash of the previous entry (genesis uses `GENESIS_PREV_HASH`).
91    #[serde(default)]
92    pub prev_hash: String,
93    /// SHA-256 hash of (`prev_hash` || canonical JSON of this entry's content fields).
94    #[serde(default)]
95    pub entry_hash: String,
96
97    /// Optional HMAC-SHA256 signature over entry_hash (present only when sign_events enabled)
98    #[serde(skip_serializing_if = "Option::is_none", default)]
99    pub signature: Option<String>,
100}
101
102impl AuditEvent {
103    /// Create a new audit event
104    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    /// Set the actor
126    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    /// Set the owning agent's alias for multi-agent attribution.
141    /// Builder method so existing AuditEvent construction sites can
142    /// add the alias without an explicit field assignment. Pass the
143    /// alias bound at agent-loop entry.
144    #[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    /// Set the action
151    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    /// Set the result
168    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    /// Set security context
185    pub fn with_security(mut self, sandbox_backend: Option<String>) -> Self {
186        self.security.sandbox_backend = sandbox_backend;
187        self
188    }
189}
190
191/// Compute the SHA-256 entry hash: `H(prev_hash || content_json)`.
192///
193/// `content_json` is the canonical JSON of the event *without* the chain fields
194/// (`sequence`, `prev_hash`, `entry_hash`), so the hash covers only the payload.
195fn compute_entry_hash(prev_hash: &str, event: &AuditEvent) -> String {
196    // Build a canonical representation of the content fields only.
197    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
215/// Internal chain state tracked across writes.
216struct ChainState {
217    prev_hash: String,
218    sequence: u64,
219}
220
221/// Audit logger
222pub struct AuditLogger {
223    log_path: PathBuf,
224    config: AuditConfig,
225    #[allow(dead_code)] // WIP: buffered writes for batch flushing
226    buffer: Mutex<Vec<AuditEvent>>,
227    chain: Mutex<ChainState>,
228    /// Signing key (loaded once at construction time if sign_events enabled)
229    signing_key: Option<Vec<u8>>,
230}
231
232/// Structured command execution details for audit logging.
233#[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    /// Create a new audit logger.
246    ///
247    /// If the log file already exists, the chain state is recovered from the last
248    /// entry so that new writes continue the existing hash chain.
249    ///
250    /// If `config.sign_events` is true, requires `ZEROCLAW_AUDIT_SIGNING_KEY` env var
251    /// to be set with a hex-encoded 32-byte key. Fails if key is missing or invalid.
252    pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result<Self> {
253        // Load and validate signing key if sign_events enabled
254        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    /// Compute HMAC-SHA256 signature over entry_hash when sign_events enabled.
299    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    /// Log an event
322    pub fn log(&self, event: &AuditEvent) -> Result<()> {
323        if !self.config.enabled {
324            return Ok(());
325        }
326
327        // Check log size and rotate if needed
328        self.rotate_if_needed()?;
329
330        // Populate chain fields under the lock
331        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            // Compute signature if sign_events enabled
339            chained.signature = self.compute_signature(&chained.entry_hash)?;
340
341            state.prev_hash = chained.entry_hash.clone();
342            state.sequence += 1;
343        }
344
345        // Serialize and write
346        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    /// Log a command execution event.
359    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    /// Backward-compatible helper to log a command execution event.
374    #[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    /// Rotate log if it exceeds max size
397    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    /// Rotate the log file
408    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
421/// Recover chain state from an existing log file.
422///
423/// Returns the genesis state if the file does not exist or is empty.
424fn 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
455/// Verify the integrity of an audit log's Merkle hash chain.
456///
457/// Reads every entry from the log file and checks:
458/// - Each `entry_hash` matches the recomputed `SHA-256(prev_hash || content)`.
459/// - `prev_hash` links to the preceding entry (or the genesis seed for the first).
460/// - Sequence numbers are contiguous starting from 0.
461/// - If a record has a `signature` field and `ZEROCLAW_AUDIT_SIGNING_KEY` is available,
462///   verifies the HMAC-SHA256 signature over `entry_hash`.
463///
464/// Returns `Ok(entry_count)` on success, or an error describing the first violation.
465pub 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    // Attempt to load signing key from environment (optional)
473    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        // Check sequence continuity
486        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        // Check prev_hash linkage
496        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        // Recompute and verify entry_hash
507        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        // Verify signature if present and key is available
519        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        // If signature present but key not available, skip verification (backward compat)
546
547        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    /// Mutex to serialize tests that read/write ZEROCLAW_AUDIT_SIGNING_KEY env var.
562    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        // File should not exist since logging is disabled
630        assert!(!tmp.path().join("audit.log").exists());
631        Ok(())
632    }
633
634    // ── §8.1 Log rotation tests ─────────────────────────────
635
636    #[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, // Force rotation on first write
703            ..Default::default()
704        };
705        let logger = AuditLogger::new(config, tmp.path().to_path_buf())?;
706
707        // Write initial content that triggers rotation
708        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    // ── Merkle hash-chain tests ─────────────────────────────
723
724    #[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        // Write several events
758        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        // Tamper with the second entry (change the command text)
795        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        // Verification must fail
808        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        // Remove the second entry to create a sequence gap
840        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        // First logger writes 2 entries
863        {
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        // Second logger (simulating restart) continues the chain
882        {
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        // Full chain should verify (4 entries, sequences 0..3)
901        let count = verify_chain(&log_path)?;
902        assert_eq!(count, 4);
903        Ok(())
904    }
905
906    // ── HMAC signing tests ──────────────────────────────────
907
908    #[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                // SAFETY: test-only, single-threaded test runner.
915                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
916            } else {
917                // SAFETY: test-only, single-threaded test runner.
918                unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
919            }
920        }
921
922        let tmp = TempDir::new()?;
923        let test_key = "a".repeat(64); // 64 hex chars = 32 bytes
924        // SAFETY: test-only, single-threaded test runner.
925        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                // SAFETY: test-only, single-threaded test runner.
983                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
984            } else {
985                // SAFETY: test-only, single-threaded test runner.
986                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        // SAFETY: test-only, single-threaded test runner.
993        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        // Manually recompute HMAC to verify correctness
1010        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            // Only restore if it was a valid 64-char key
1028            if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1029                // SAFETY: test-only, single-threaded test runner.
1030                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1031            } else {
1032                // SAFETY: test-only, single-threaded test runner.
1033                unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1034            }
1035        }
1036
1037        // SAFETY: test-only, single-threaded test runner.
1038        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            // Only restore if it was a valid 64-char key
1067            if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1068                // SAFETY: test-only, single-threaded test runner.
1069                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1070            } else {
1071                // SAFETY: test-only, single-threaded test runner.
1072                unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1073            }
1074        }
1075
1076        // SAFETY: test-only, single-threaded test runner.
1077        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            // Only restore if it was a valid 64-char key
1106            if let Some(key) = old_key.as_ref().filter(|k| k.len() == 64) {
1107                // SAFETY: test-only, single-threaded test runner.
1108                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1109            } else {
1110                // SAFETY: test-only, single-threaded test runner.
1111                unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1112            }
1113        }
1114
1115        // 30 bytes = 60 hex chars (not 32 bytes)
1116        let short_key = "c".repeat(60);
1117        // SAFETY: test-only, single-threaded test runner.
1118        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                // SAFETY: test-only, single-threaded test runner.
1143                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1144            } else {
1145                // SAFETY: test-only, single-threaded test runner.
1146                unsafe { std::env::remove_var("ZEROCLAW_AUDIT_SIGNING_KEY") };
1147            }
1148        }
1149
1150        let _tmp = TempDir::new()?;
1151
1152        // Compute HMAC manually with key1
1153        let key1 = "d".repeat(64);
1154        let key1_bytes = hex::decode(&key1)?;
1155
1156        // Compute HMAC manually with key2
1157        let key2 = "e".repeat(64);
1158        let key2_bytes = hex::decode(&key2)?;
1159
1160        // Use a fixed entry_hash for testing
1161        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                // SAFETY: test-only, single-threaded test runner.
1189                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1190            } else {
1191                // SAFETY: test-only, single-threaded test runner.
1192                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        // SAFETY: test-only, single-threaded test runner.
1199        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        // Log two events
1209        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        // Different entry_hashes due to chaining, so signatures should differ
1226        assert_ne!(event1.entry_hash, event2.entry_hash);
1227        assert_ne!(event1.signature, event2.signature);
1228
1229        // Manually verify determinism by recomputing signature for event1
1230        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                // SAFETY: test-only, single-threaded test runner.
1248                unsafe { std::env::set_var("ZEROCLAW_AUDIT_SIGNING_KEY", key) };
1249            } else {
1250                // SAFETY: test-only, single-threaded test runner.
1251                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); // 64 hex chars = 32 bytes
1258
1259        // First logger with sign_events=false (unsigned records)
1260        {
1261            // SAFETY: test-only, single-threaded test runner.
1262            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        // Second logger with sign_events=true (signed records)
1281        {
1282            // SAFETY: test-only, single-threaded test runner.
1283            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        // Verify the full chain (4 records: 2 unsigned + 2 signed)
1302        // Set the key in env so verify_chain can check signatures
1303        // SAFETY: test-only, single-threaded test runner.
1304        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        // Verify that first 2 records have no signature, last 2 have signatures
1309        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}