scrt4 0.4.6

Hardware-bound secrets vault for AI coding agents. Secrets are injected into a subprocess and scrubbed from its output, so an agent can use a credential without ever seeing it. The vault key is derived from a FIDO2 authenticator via WebAuthn PRF and is never stored.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
// scrt4/src/audit.rs
//! Security audit logging for the scrt4 daemon
//!
//! Provides structured logging of all security-relevant events:
//! - Session lifecycle (start, end, timeout)
//! - Authentication attempts (success, failure)
//! - Secret access (list, reveal)
//! - Command execution (with sanitized command strings)
//!
//! Logs are written to a JSON Lines file for easy parsing and analysis.

use serde::Serialize;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

/// Event types for audit logging
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
// Some variants are defined ahead of the code that will emit them.
#[allow(dead_code)]
pub enum EventType {
    /// Session started (after successful unlock)
    SessionStart,
    /// Session ended (logout or clear)
    SessionEnd,
    /// Session expired due to TTL
    SessionTimeout,
    /// Authentication attempted
    AuthAttempt,
    /// Authentication succeeded
    AuthSuccess,
    /// Authentication failed
    AuthFailure,
    /// Secret names listed
    SecretList,
    /// Reveal challenge issued (Phase 1)
    RevealChallengeIssued,
    /// Reveal challenge completed successfully (Phase 2)
    RevealChallengeCompleted,
    /// Reveal challenge failed (wrong code, expired, etc.)
    RevealChallengeFailed,
    /// Bulk reveal challenge issued
    BulkRevealChallengeIssued,
    /// Bulk reveal challenge completed
    BulkRevealChallengeCompleted,
    /// Secrets added to active session
    SecretsAdded,
    /// Command executed with secrets
    CommandRun,
    /// Command execution failed
    CommandError,
    /// Session extended (timer reset)
    SessionExtend,
    /// Master key backup requested
    BackupKeyRequested,
    /// Key migration (old key -> new key)
    KeyMigration,
    /// Fresh encryption keys generated during setup
    KeysInitialized,
    /// WebAuthn credential registered
    WebauthnRegistered,
    /// WebAuthn authentication succeeded
    WebauthnAuthSuccess,
    /// WebAuthn authentication failed
    WebauthnAuthFailure,
    /// WebAuthn 2FA enabled for reveal operations
    WaEnabled,
    /// WebAuthn 2FA disabled for reveal operations
    WaDisabled,
    /// WebAuthn 2FA enabled for unlock operations
    WaUnlockEnabled,
    /// WebAuthn 2FA disabled for unlock operations
    WaUnlockDisabled,
    /// Daemon started
    DaemonStart,
    /// Daemon stopped
    DaemonStop,
    /// Connection from client
    ClientConnect,
    /// Client disconnected
    ClientDisconnect,
    /// Invalid request received
    InvalidRequest,
}

/// Result of an audited operation
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EventResult {
    Success,
    Failure,
    Pending,
}

/// A single audit event
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
    /// Unix timestamp in milliseconds
    pub timestamp: u64,
    /// ISO 8601 formatted timestamp
    pub timestamp_iso: String,
    /// Type of event
    pub event_type: EventType,
    /// Result of the operation
    pub result: EventResult,
    /// Optional command (sanitized - no secret values)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// Optional error message
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Number of secrets involved (for list/run operations)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub secret_count: Option<usize>,
    /// Name of secret (for reveal operations)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub secret_name: Option<String>,
    /// Session TTL in seconds (for session start)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ttl: Option<u64>,
    /// Client identifier (socket peer)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
    /// Exit code (for command execution)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
}

impl AuditEvent {
    /// Create a new audit event with current timestamp
    pub fn new(event_type: EventType, result: EventResult) -> Self {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default();
        let timestamp = now.as_millis() as u64;

        // Format ISO 8601 timestamp
        let secs = now.as_secs();
        let timestamp_iso = format_timestamp(secs);

        Self {
            timestamp,
            timestamp_iso,
            event_type,
            result,
            command: None,
            error: None,
            secret_count: None,
            secret_name: None,
            ttl: None,
            client_id: None,
            exit_code: None,
        }
    }

    /// Set the command (will be sanitized)
    pub fn with_command(mut self, cmd: &str) -> Self {
        // Sanitize command - remove potential secret values
        // Keep only the structure, not any inline values
        let sanitized = sanitize_command(cmd);
        self.command = Some(sanitized);
        self
    }

    /// Set error message
    pub fn with_error(mut self, err: &str) -> Self {
        self.error = Some(err.to_string());
        self
    }

    /// Set secret count
    pub fn with_secret_count(mut self, count: usize) -> Self {
        self.secret_count = Some(count);
        self
    }

    /// Set secret name (for reveal operations)
    pub fn with_secret_name(mut self, name: &str) -> Self {
        self.secret_name = Some(name.to_string());
        self
    }

    /// Set TTL
    pub fn with_ttl(mut self, ttl: u64) -> Self {
        self.ttl = Some(ttl);
        self
    }

    /// Set client ID
    pub fn with_client(mut self, client: &str) -> Self {
        self.client_id = Some(client.to_string());
        self
    }

    /// Set exit code
    pub fn with_exit_code(mut self, code: i32) -> Self {
        self.exit_code = Some(code);
        self
    }
}

/// Sanitize command string to remove potential secret values
fn sanitize_command(cmd: &str) -> String {
    // Replace anything that looks like a value after common patterns
    let mut result = cmd.to_string();

    // Don't log long strings that might be secrets
    if result.len() > 500 {
        result = format!("{}...[truncated]", &result[..200]);
    }

    // Replace quoted strings longer than 20 chars (might be secrets)
    let re = regex::Regex::new(r#"["'][^"']{20,}["']"#).unwrap();
    result = re.replace_all(&result, "\"[REDACTED]\"").to_string();

    // Replace base64-looking strings (44+ chars, common for keys)
    let re = regex::Regex::new(r"[A-Za-z0-9+/=]{44,}").unwrap();
    result = re.replace_all(&result, "[REDACTED_B64]").to_string();

    // Replace hex strings (40+ chars without 0x prefix, or any with 0x prefix over 20 chars)
    let re = regex::Regex::new(r"0x[a-fA-F0-9]{20,}").unwrap();
    result = re.replace_all(&result, "[REDACTED_HEX]").to_string();

    // Also catch hex strings without 0x prefix (64+ chars)
    let re = regex::Regex::new(r"[a-fA-F0-9]{64,}").unwrap();
    result = re.replace_all(&result, "[REDACTED_HEX]").to_string();

    result
}

/// Format Unix timestamp as ISO 8601
fn format_timestamp(secs: u64) -> String {
    // Simple ISO 8601 formatting without external crate
    let days_since_epoch = secs / 86400;
    let time_of_day = secs % 86400;

    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;
    let seconds = time_of_day % 60;

    // Approximate date calculation (doesn't account for leap seconds, but close enough for logging)
    let mut year = 1970;
    let mut remaining_days = days_since_epoch;

    loop {
        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
        if remaining_days < days_in_year {
            break;
        }
        remaining_days -= days_in_year;
        year += 1;
    }

    let days_in_months: [u64; 12] = if is_leap_year(year) {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };

    let mut month = 1;
    for days in days_in_months.iter() {
        if remaining_days < *days {
            break;
        }
        remaining_days -= *days;
        month += 1;
    }

    let day = remaining_days + 1;

    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, hours, minutes, seconds
    )
}

fn is_leap_year(year: u64) -> bool {
    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}

/// Global audit logger
static AUDIT_LOGGER: std::sync::OnceLock<AuditLogger> = std::sync::OnceLock::new();

/// Initialize the audit logger
pub fn init_audit_logger(log_dir: Option<PathBuf>) {
    let logger = AuditLogger::new(log_dir);
    AUDIT_LOGGER.set(logger).ok();
}

/// Log an audit event
pub fn log_event(event: AuditEvent) {
    if let Some(logger) = AUDIT_LOGGER.get() {
        logger.log(event);
    } else {
        // Fallback to tracing if logger not initialized
        tracing::info!(
            event_type = ?event.event_type,
            result = ?event.result,
            "Audit event (logger not initialized)"
        );
    }
}

/// Convenience function to log a simple event
pub fn log_simple(event_type: EventType, result: EventResult) {
    log_event(AuditEvent::new(event_type, result));
}

/// The audit logger
pub struct AuditLogger {
    writer: Mutex<Option<BufWriter<File>>>,
    #[allow(dead_code)]
    log_path: PathBuf,
}

impl AuditLogger {
    /// Create a new audit logger.
    ///
    /// When `log_dir` is None, defaults to `<config_dir>/audit/`. The
    /// config dir respects SCRT4_DEV_MODE — see crate::keystore::config_dir.
    /// This keeps the dev daemon's audit log under ~/.scrt4-dev/audit/
    /// alongside the dev vault, instead of writing to the hardened
    /// distribution's audit dir (which was ISS014).
    pub fn new(log_dir: Option<PathBuf>) -> Self {
        let log_dir = log_dir.unwrap_or_else(|| {
            // Inline copy of keystore::config_dir to avoid a circular dep:
            // keystore is a sibling module that the audit logger should
            // not depend on. The logic must stay in sync.
            let dev_mode = std::env::var("SCRT4_DEV_MODE")
                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
                .unwrap_or(false);
            let config = if dev_mode { ".scrt4-dev" } else { ".scrt4" };
            dirs::home_dir().unwrap_or_else(std::env::temp_dir)
                .join(config).join("audit")
        });

        // Ensure directory exists
        std::fs::create_dir_all(&log_dir).ok();

        // Create log file with date in name
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default();
        let date_str = format_timestamp(now.as_secs()).split('T').next().unwrap_or("unknown").to_string();
        let log_path = log_dir.join(format!("audit-{}.jsonl", date_str));

        let writer = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&log_path)
            .ok()
            .map(|f| BufWriter::new(f));

        Self {
            writer: Mutex::new(writer),
            log_path,
        }
    }

    /// Log an event
    pub fn log(&self, event: AuditEvent) {
        // Also log to tracing for real-time visibility
        tracing::info!(
            event_type = ?event.event_type,
            result = ?event.result,
            "Audit: {:?}", event.event_type
        );

        // Write to file
        if let Ok(mut guard) = self.writer.lock() {
            if let Some(ref mut writer) = *guard {
                if let Ok(json) = serde_json::to_string(&event) {
                    writeln!(writer, "{}", json).ok();
                    writer.flush().ok();
                }
            }
        }
    }

    /// Get the log file path
    #[allow(dead_code)]
    pub fn log_path(&self) -> &PathBuf {
        &self.log_path
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_audit_event_creation() {
        let event = AuditEvent::new(EventType::SessionStart, EventResult::Success)
            .with_ttl(7200)
            .with_secret_count(5);

        assert!(event.timestamp > 0);
        assert!(matches!(event.event_type, EventType::SessionStart));
        assert!(matches!(event.result, EventResult::Success));
        assert_eq!(event.ttl, Some(7200));
        assert_eq!(event.secret_count, Some(5));
    }

    #[test]
    fn test_sanitize_command() {
        // Test long string redaction - base64 JWT
        let cmd = "curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9eyJzdWIiOiIxMjM0NTY3ODkwIn0'";
        let sanitized = sanitize_command(cmd);
        assert!(
            sanitized.contains("[REDACTED_B64]") || sanitized.contains("[REDACTED]"),
            "Expected redaction for base64, got: {}", sanitized
        );

        // Test hex string redaction (Ethereum private key format)
        let cmd = "send --key 0x1234567890abcdef1234567890abcdef12345678";
        let sanitized = sanitize_command(cmd);
        assert!(
            sanitized.contains("[REDACTED_HEX]"),
            "Expected [REDACTED_HEX], got: {}", sanitized
        );
    }

    #[test]
    fn test_format_timestamp() {
        // Unix epoch
        assert_eq!(format_timestamp(0), "1970-01-01T00:00:00Z");

        // Known date: 2024-01-15 12:30:45 UTC
        // (approximate, not accounting for leap seconds)
        let ts = format_timestamp(1705321845);
        assert!(ts.starts_with("2024-01-15"));
    }

    #[test]
    fn test_event_serialization() {
        let event = AuditEvent::new(EventType::CommandRun, EventResult::Success)
            .with_command("scrt run env")
            .with_exit_code(0);

        let json = serde_json::to_string(&event).unwrap();
        assert!(json.contains("command_run"));
        assert!(json.contains("success"));
        assert!(json.contains("\"exit_code\":0"));
    }
}