Skip to main content

tryaudex_core/
integrity.rs

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
12/// Returns true if the HMAC key is the hardcoded default (publicly known).
13pub fn is_using_default_key() -> bool {
14    std::env::var(HMAC_KEY_ENV).is_err()
15}
16
17/// Emit a one-time warning if the default key is in use.
18/// Called on every audit write so the operator is aware.
19pub 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
31/// R6-M56: minimum length for an explicit AUDEX_HMAC_KEY. HMAC-SHA256
32/// is technically secure with any key length, but a 1-byte key has only
33/// 8 bits of entropy. 16 bytes (128 bits) is a reasonable floor.
34const MIN_HMAC_KEY_LEN: usize = 16;
35
36/// Get the HMAC key from env, or derive from the per-machine keystore
37/// encryption key. The hardcoded default is only used if the keystore
38/// key itself cannot be obtained (should not happen in practice).
39pub(crate) fn get_hmac_key() -> Vec<u8> {
40    if let Ok(k) = std::env::var(HMAC_KEY_ENV) {
41        // R6-M56: reject trivially short keys.
42        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    // Derive from the keystore encryption key (per-machine entropy)
55    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
61/// Compute the HMAC for an audit line, chained with the previous hash.
62/// Chain formula: HMAC(key, previous_hash || line_content)
63pub 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
71/// Append integrity hash to an audit JSONL line.
72/// Format: original_json\t#HMAC:hexhash
73pub 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
78/// Extract the content and HMAC from a signed line.
79/// Returns (content, hmac_hex) or (content, None) for unsigned lines.
80///
81/// R6-M55: validate that the hash suffix is well-formed hex of the
82/// expected length. Without this, a garbage suffix (e.g. from a
83/// corrupted file or an injected `\t#HMAC:` in the JSON body) would
84/// pass through as `Some("garbage")`, then fail the equality check
85/// in verify and be counted as "tampered" — which is technically
86/// correct but masks the real problem and pollutes error messages.
87pub 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        // SHA-256 hex is exactly 64 lowercase hex chars.
92        if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
93            (content, Some(hash))
94        } else {
95            // Malformed HMAC suffix — treat as unsigned rather than
96            // silently comparing garbage against a valid hash.
97            (content, None)
98        }
99    } else {
100        (line, None)
101    }
102}
103
104/// R6-M54: constant-time comparison of two hex-encoded HMAC strings.
105/// `==` on strings short-circuits on the first differing byte, leaking
106/// information about which prefix of the expected hash is correct.
107/// For audit-log integrity this is a low-severity finding (an attacker
108/// who can submit lines and observe timing already has log write access),
109/// but constant-time comparison is cheap and eliminates the class.
110fn 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/// Verification result for a single line.
123#[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/// Verification result for the entire audit log.
132#[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
148/// Verify the integrity of an audit log file.
149///
150/// R6-M53: stream the file line-by-line via BufRead instead of reading
151/// the entire log into memory. Audit logs grow without bound in long-
152/// running deployments; a 500 MB log would pin 500 MB of RAM during
153/// verification under the old approach.
154pub 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                    // Continue chain with the *recomputed* hash from actual content,
208                    // not the attacker-provided expected_hash. Using the attacker's
209                    // hash would let a forged chain splice from the tampered line onward.
210                    previous_hash = computed;
211                }
212            }
213            None => {
214                result.unsigned_lines += 1;
215                // Unsigned lines (legacy) — skip chain validation but note them
216                // Use content hash as chain input so future signed lines still chain
217                previous_hash = compute_chain_hmac(content, &previous_hash);
218            }
219        }
220    }
221
222    Ok(result)
223}
224
225/// Read the last HMAC hash from an audit log file for chaining.
226/// Returns "genesis" if the file is empty or has no signed lines.
227///
228/// R6-M53: stream line-by-line like verify_audit_log.
229pub 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    // Recompute the chain from content rather than trusting stored HMACs,
245    // so a tampered line cannot infect the chain going forward.
246    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); // SHA256 hex
275    }
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        // Write a tampered second line (wrong HMAC)
333        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        // Unsigned legacy line
354        writeln!(file, r#"{{"event":"legacy"}}"#).unwrap();
355
356        // Signed line after legacy
357        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        // R6-M55: garbage after \t#HMAC: should be treated as unsigned.
394        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        // Wrong length (32 chars instead of 64).
403        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        // Valid 64-char hex passes through.
408        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        // R6-M54: constant-time comparison.
416        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}