1use hmac::{Hmac, Mac};
2use sha2::Sha256;
3
4use crate::error::{AvError, Result};
5
6type HmacSha256 = Hmac<Sha256>;
7
8const HMAC_KEY_ENV: &str = "AUDEX_HMAC_KEY";
9
10static WARNED_DEFAULT_KEY: std::sync::Once = std::sync::Once::new();
11
12pub fn is_using_default_key() -> bool {
14 std::env::var(HMAC_KEY_ENV).is_err()
15}
16
17pub fn warn_if_default_key() {
20 if is_using_default_key() {
21 WARNED_DEFAULT_KEY.call_once(|| {
22 tracing::warn!(
23 "AUDEX_HMAC_KEY is not set — audit log integrity uses a publicly known \
24 default key. Anyone with source access can forge a valid chain. \
25 Set AUDEX_HMAC_KEY to a secret value for tamper-evident logging."
26 );
27 });
28 }
29}
30
31const MIN_HMAC_KEY_LEN: usize = 16;
35
36pub(crate) fn get_hmac_key() -> Vec<u8> {
40 if let Ok(k) = std::env::var(HMAC_KEY_ENV) {
41 if k.len() < MIN_HMAC_KEY_LEN {
43 tracing::error!(
44 "AUDEX_HMAC_KEY is set but only {} bytes — minimum is {}. \
45 Falling back to keystore-derived key.",
46 k.len(),
47 MIN_HMAC_KEY_LEN
48 );
49 } else {
50 return k.into_bytes();
51 }
52 }
53
54 let enc_key = crate::keystore::get_or_create_key();
56 let mut mac = HmacSha256::new_from_slice(&enc_key).expect("HMAC accepts any key length");
57 mac.update(b"audex-hmac-key-derivation-v1");
58 mac.finalize().into_bytes().to_vec()
59}
60
61pub fn compute_chain_hmac(line: &str, previous_hash: &str) -> String {
64 let key = get_hmac_key();
65 let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length");
66 mac.update(previous_hash.as_bytes());
67 mac.update(line.as_bytes());
68 hex::encode(mac.finalize().into_bytes())
69}
70
71pub fn sign_line(line: &str, previous_hash: &str) -> String {
74 let hash = compute_chain_hmac(line, previous_hash);
75 format!("{}\t#HMAC:{}", line, hash)
76}
77
78pub fn parse_line(line: &str) -> (&str, Option<&str>) {
88 if let Some(pos) = line.rfind("\t#HMAC:") {
89 let content = &line[..pos];
90 let hash = &line[pos + 7..];
91 if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
93 (content, Some(hash))
94 } else {
95 (content, None)
98 }
99 } else {
100 (line, None)
101 }
102}
103
104fn ct_eq(a: &str, b: &str) -> bool {
111 let a = a.as_bytes();
112 let b = b.as_bytes();
113 if a.len() != b.len() {
114 return false;
115 }
116 a.iter()
117 .zip(b.iter())
118 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
119 == 0
120}
121
122#[derive(Debug)]
124pub struct LineVerification {
125 pub line_number: usize,
126 pub valid: bool,
127 pub signed: bool,
128 pub error: Option<String>,
129}
130
131#[derive(Debug)]
133pub struct VerificationResult {
134 pub total_lines: usize,
135 pub signed_lines: usize,
136 pub unsigned_lines: usize,
137 pub valid_lines: usize,
138 pub tampered_lines: Vec<usize>,
139 pub errors: Vec<LineVerification>,
140}
141
142impl VerificationResult {
143 pub fn is_valid(&self) -> bool {
144 self.tampered_lines.is_empty() && self.errors.is_empty()
145 }
146}
147
148pub fn verify_audit_log(path: &std::path::Path) -> Result<VerificationResult> {
155 use std::io::BufRead;
156
157 if is_using_default_key() {
158 eprintln!(
159 " \x1b[33m⚠\x1b[0m AUDEX_HMAC_KEY is not set — using the hardcoded default key. \
160 Anyone with source access can forge a valid audit chain. \
161 Set AUDEX_HMAC_KEY to a secret value for tamper-evident logging."
162 );
163 }
164
165 if !path.exists() {
166 return Err(AvError::InvalidPolicy("Audit log not found".to_string()));
167 }
168
169 let file = std::fs::File::open(path)?;
170 let reader = std::io::BufReader::new(file);
171
172 let mut result = VerificationResult {
173 total_lines: 0,
174 signed_lines: 0,
175 unsigned_lines: 0,
176 valid_lines: 0,
177 tampered_lines: Vec::new(),
178 errors: Vec::new(),
179 };
180
181 let mut previous_hash = "genesis".to_string();
182
183 for line_result in reader.lines() {
184 let line = line_result?;
185 if line.trim().is_empty() {
186 continue;
187 }
188 result.total_lines += 1;
189 let line_num = result.total_lines;
190 let (content, hmac) = parse_line(&line);
191
192 match hmac {
193 Some(expected_hash) => {
194 result.signed_lines += 1;
195 let computed = compute_chain_hmac(content, &previous_hash);
196 if ct_eq(&computed, expected_hash) {
197 result.valid_lines += 1;
198 previous_hash = computed;
199 } else {
200 result.tampered_lines.push(line_num);
201 result.errors.push(LineVerification {
202 line_number: line_num,
203 valid: false,
204 signed: true,
205 error: Some("HMAC mismatch — line may have been tampered with".to_string()),
206 });
207 previous_hash = computed;
211 }
212 }
213 None => {
214 result.unsigned_lines += 1;
215 previous_hash = compute_chain_hmac(content, &previous_hash);
218 }
219 }
220 }
221
222 Ok(result)
223}
224
225pub fn last_chain_hash(path: &std::path::Path) -> String {
230 use std::io::BufRead;
231
232 if !path.exists() {
233 return "genesis".to_string();
234 }
235
236 let file = match std::fs::File::open(path) {
237 Ok(f) => f,
238 Err(_) => return "genesis".to_string(),
239 };
240 let reader = std::io::BufReader::new(file);
241
242 let mut previous_hash = "genesis".to_string();
243
244 for line_result in reader.lines() {
247 let line = match line_result {
248 Ok(l) => l,
249 Err(_) => break,
250 };
251 if line.trim().is_empty() {
252 continue;
253 }
254 let (content, _) = parse_line(&line);
255 previous_hash = compute_chain_hmac(content, &previous_hash);
256 }
257
258 previous_hash
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::io::Write;
265
266 #[test]
267 fn test_sign_and_parse_line() {
268 let line = r#"{"timestamp":"2026-04-05T10:00:00Z","session_id":"abc","provider":"aws","event":{"type":"session_created"}}"#;
269 let signed = sign_line(line, "genesis");
270
271 let (content, hmac) = parse_line(&signed);
272 assert_eq!(content, line);
273 assert!(hmac.is_some());
274 assert_eq!(hmac.unwrap().len(), 64); }
276
277 #[test]
278 fn test_chain_deterministic() {
279 let hash1 = compute_chain_hmac("line1", "genesis");
280 let hash2 = compute_chain_hmac("line1", "genesis");
281 assert_eq!(hash1, hash2);
282 }
283
284 #[test]
285 fn test_chain_depends_on_previous() {
286 let hash_a = compute_chain_hmac("line2", "hash_a");
287 let hash_b = compute_chain_hmac("line2", "hash_b");
288 assert_ne!(hash_a, hash_b);
289 }
290
291 #[test]
292 fn test_unsigned_line_parsing() {
293 let line = r#"{"timestamp":"2026-04-05T10:00:00Z","session_id":"abc"}"#;
294 let (content, hmac) = parse_line(line);
295 assert_eq!(content, line);
296 assert!(hmac.is_none());
297 }
298
299 #[test]
300 fn test_verify_valid_log() {
301 let dir = tempfile::tempdir().unwrap();
302 let path = dir.path().join("audit.jsonl");
303 let mut file = std::fs::File::create(&path).unwrap();
304
305 let line1 = r#"{"event":"one"}"#;
306 let signed1 = sign_line(line1, "genesis");
307 writeln!(file, "{}", signed1).unwrap();
308
309 let hash1 = compute_chain_hmac(line1, "genesis");
310 let line2 = r#"{"event":"two"}"#;
311 let signed2 = sign_line(line2, &hash1);
312 writeln!(file, "{}", signed2).unwrap();
313
314 let result = verify_audit_log(&path).unwrap();
315 assert_eq!(result.total_lines, 2);
316 assert_eq!(result.signed_lines, 2);
317 assert_eq!(result.valid_lines, 2);
318 assert!(result.tampered_lines.is_empty());
319 assert!(result.is_valid());
320 }
321
322 #[test]
323 fn test_verify_tampered_log() {
324 let dir = tempfile::tempdir().unwrap();
325 let path = dir.path().join("audit.jsonl");
326 let mut file = std::fs::File::create(&path).unwrap();
327
328 let line1 = r#"{"event":"one"}"#;
329 let signed1 = sign_line(line1, "genesis");
330 writeln!(file, "{}", signed1).unwrap();
331
332 let line2 = r#"{"event":"two"}"#;
334 writeln!(
335 file,
336 "{}\t#HMAC:0000000000000000000000000000000000000000000000000000000000000000",
337 line2
338 )
339 .unwrap();
340
341 let result = verify_audit_log(&path).unwrap();
342 assert_eq!(result.total_lines, 2);
343 assert_eq!(result.tampered_lines, vec![2]);
344 assert!(!result.is_valid());
345 }
346
347 #[test]
348 fn test_verify_mixed_signed_unsigned() {
349 let dir = tempfile::tempdir().unwrap();
350 let path = dir.path().join("audit.jsonl");
351 let mut file = std::fs::File::create(&path).unwrap();
352
353 writeln!(file, r#"{{"event":"legacy"}}"#).unwrap();
355
356 let prev_hash = compute_chain_hmac(r#"{"event":"legacy"}"#, "genesis");
358 let line2 = r#"{"event":"new"}"#;
359 let signed2 = sign_line(line2, &prev_hash);
360 writeln!(file, "{}", signed2).unwrap();
361
362 let result = verify_audit_log(&path).unwrap();
363 assert_eq!(result.total_lines, 2);
364 assert_eq!(result.unsigned_lines, 1);
365 assert_eq!(result.signed_lines, 1);
366 assert_eq!(result.valid_lines, 1);
367 assert!(result.is_valid());
368 }
369
370 #[test]
371 fn test_last_chain_hash_empty() {
372 let dir = tempfile::tempdir().unwrap();
373 let path = dir.path().join("nonexistent.jsonl");
374 assert_eq!(last_chain_hash(&path), "genesis");
375 }
376
377 #[test]
378 fn test_last_chain_hash_with_entries() {
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join("audit.jsonl");
381 let mut file = std::fs::File::create(&path).unwrap();
382
383 let line1 = r#"{"event":"one"}"#;
384 let signed1 = sign_line(line1, "genesis");
385 writeln!(file, "{}", signed1).unwrap();
386
387 let expected_hash = compute_chain_hmac(line1, "genesis");
388 assert_eq!(last_chain_hash(&path), expected_hash);
389 }
390
391 #[test]
392 fn test_parse_line_rejects_malformed_hex() {
393 let line = "{\"event\":\"x\"}\t#HMAC:not_valid_hex_at_all";
395 let (content, hmac) = parse_line(line);
396 assert_eq!(content, "{\"event\":\"x\"}");
397 assert!(
398 hmac.is_none(),
399 "malformed hex should be treated as unsigned"
400 );
401
402 let short = format!("{{\"e\":1}}\t#HMAC:{}", "ab".repeat(16));
404 let (_, hmac) = parse_line(&short);
405 assert!(hmac.is_none(), "short hex should be treated as unsigned");
406
407 let valid = format!("{{\"e\":1}}\t#HMAC:{}", "ab".repeat(32));
409 let (_, hmac) = parse_line(&valid);
410 assert!(hmac.is_some());
411 }
412
413 #[test]
414 fn test_ct_eq() {
415 assert!(ct_eq("abcdef", "abcdef"));
417 assert!(!ct_eq("abcdef", "abcdeg"));
418 assert!(!ct_eq("abc", "abcd"));
419 assert!(!ct_eq("", "a"));
420 assert!(ct_eq("", ""));
421 }
422}