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";
9const DEFAULT_HMAC_KEY: &[u8] = b"audex-audit-integrity-v1";
10
11/// Get the HMAC key from env or use the default.
12fn get_hmac_key() -> Vec<u8> {
13    std::env::var(HMAC_KEY_ENV)
14        .map(|k| k.into_bytes())
15        .unwrap_or_else(|_| DEFAULT_HMAC_KEY.to_vec())
16}
17
18/// Compute the HMAC for an audit line, chained with the previous hash.
19/// Chain formula: HMAC(key, previous_hash || line_content)
20pub fn compute_chain_hmac(line: &str, previous_hash: &str) -> String {
21    let key = get_hmac_key();
22    let mut mac = HmacSha256::new_from_slice(&key)
23        .expect("HMAC accepts any key length");
24    mac.update(previous_hash.as_bytes());
25    mac.update(line.as_bytes());
26    hex::encode(mac.finalize().into_bytes())
27}
28
29/// Append integrity hash to an audit JSONL line.
30/// Format: original_json\t#HMAC:hexhash
31pub fn sign_line(line: &str, previous_hash: &str) -> String {
32    let hash = compute_chain_hmac(line, previous_hash);
33    format!("{}\t#HMAC:{}", line, hash)
34}
35
36/// Extract the content and HMAC from a signed line.
37/// Returns (content, hmac_hex) or (content, None) for unsigned lines.
38pub fn parse_line(line: &str) -> (&str, Option<&str>) {
39    if let Some(pos) = line.rfind("\t#HMAC:") {
40        let content = &line[..pos];
41        let hash = &line[pos + 7..];
42        (content, Some(hash))
43    } else {
44        (line, None)
45    }
46}
47
48/// Verification result for a single line.
49#[derive(Debug)]
50pub struct LineVerification {
51    pub line_number: usize,
52    pub valid: bool,
53    pub signed: bool,
54    pub error: Option<String>,
55}
56
57/// Verification result for the entire audit log.
58#[derive(Debug)]
59pub struct VerificationResult {
60    pub total_lines: usize,
61    pub signed_lines: usize,
62    pub unsigned_lines: usize,
63    pub valid_lines: usize,
64    pub tampered_lines: Vec<usize>,
65    pub errors: Vec<LineVerification>,
66}
67
68impl VerificationResult {
69    pub fn is_valid(&self) -> bool {
70        self.tampered_lines.is_empty() && self.errors.is_empty()
71    }
72}
73
74/// Verify the integrity of an audit log file.
75pub fn verify_audit_log(path: &std::path::Path) -> Result<VerificationResult> {
76    if !path.exists() {
77        return Err(AvError::InvalidPolicy("Audit log not found".to_string()));
78    }
79
80    let content = std::fs::read_to_string(path)?;
81    let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
82
83    let mut result = VerificationResult {
84        total_lines: lines.len(),
85        signed_lines: 0,
86        unsigned_lines: 0,
87        valid_lines: 0,
88        tampered_lines: Vec::new(),
89        errors: Vec::new(),
90    };
91
92    let mut previous_hash = "genesis".to_string();
93
94    for (i, line) in lines.iter().enumerate() {
95        let line_num = i + 1;
96        let (content, hmac) = parse_line(line);
97
98        match hmac {
99            Some(expected_hash) => {
100                result.signed_lines += 1;
101                let computed = compute_chain_hmac(content, &previous_hash);
102                if computed == expected_hash {
103                    result.valid_lines += 1;
104                    previous_hash = computed;
105                } else {
106                    result.tampered_lines.push(line_num);
107                    result.errors.push(LineVerification {
108                        line_number: line_num,
109                        valid: false,
110                        signed: true,
111                        error: Some("HMAC mismatch — line may have been tampered with".to_string()),
112                    });
113                    // Continue chain with expected hash to detect if only one line is bad
114                    previous_hash = expected_hash.to_string();
115                }
116            }
117            None => {
118                result.unsigned_lines += 1;
119                // Unsigned lines (legacy) — skip chain validation but note them
120                // Use content hash as chain input so future signed lines still chain
121                previous_hash = compute_chain_hmac(content, &previous_hash);
122            }
123        }
124    }
125
126    Ok(result)
127}
128
129/// Read the last HMAC hash from an audit log file for chaining.
130/// Returns "genesis" if the file is empty or has no signed lines.
131pub fn last_chain_hash(path: &std::path::Path) -> String {
132    if !path.exists() {
133        return "genesis".to_string();
134    }
135
136    let content = match std::fs::read_to_string(path) {
137        Ok(c) => c,
138        Err(_) => return "genesis".to_string(),
139    };
140
141    let mut previous_hash = "genesis".to_string();
142
143    for line in content.lines().filter(|l| !l.trim().is_empty()) {
144        let (content, hmac) = parse_line(line);
145        match hmac {
146            Some(hash) => {
147                previous_hash = hash.to_string();
148            }
149            None => {
150                previous_hash = compute_chain_hmac(content, &previous_hash);
151            }
152        }
153    }
154
155    previous_hash
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::io::Write;
162
163    #[test]
164    fn test_sign_and_parse_line() {
165        let line = r#"{"timestamp":"2026-04-05T10:00:00Z","session_id":"abc","provider":"aws","event":{"type":"session_created"}}"#;
166        let signed = sign_line(line, "genesis");
167
168        let (content, hmac) = parse_line(&signed);
169        assert_eq!(content, line);
170        assert!(hmac.is_some());
171        assert_eq!(hmac.unwrap().len(), 64); // SHA256 hex
172    }
173
174    #[test]
175    fn test_chain_deterministic() {
176        let hash1 = compute_chain_hmac("line1", "genesis");
177        let hash2 = compute_chain_hmac("line1", "genesis");
178        assert_eq!(hash1, hash2);
179    }
180
181    #[test]
182    fn test_chain_depends_on_previous() {
183        let hash_a = compute_chain_hmac("line2", "hash_a");
184        let hash_b = compute_chain_hmac("line2", "hash_b");
185        assert_ne!(hash_a, hash_b);
186    }
187
188    #[test]
189    fn test_unsigned_line_parsing() {
190        let line = r#"{"timestamp":"2026-04-05T10:00:00Z","session_id":"abc"}"#;
191        let (content, hmac) = parse_line(line);
192        assert_eq!(content, line);
193        assert!(hmac.is_none());
194    }
195
196    #[test]
197    fn test_verify_valid_log() {
198        let dir = tempfile::tempdir().unwrap();
199        let path = dir.path().join("audit.jsonl");
200        let mut file = std::fs::File::create(&path).unwrap();
201
202        let line1 = r#"{"event":"one"}"#;
203        let signed1 = sign_line(line1, "genesis");
204        writeln!(file, "{}", signed1).unwrap();
205
206        let hash1 = compute_chain_hmac(line1, "genesis");
207        let line2 = r#"{"event":"two"}"#;
208        let signed2 = sign_line(line2, &hash1);
209        writeln!(file, "{}", signed2).unwrap();
210
211        let result = verify_audit_log(&path).unwrap();
212        assert_eq!(result.total_lines, 2);
213        assert_eq!(result.signed_lines, 2);
214        assert_eq!(result.valid_lines, 2);
215        assert!(result.tampered_lines.is_empty());
216        assert!(result.is_valid());
217    }
218
219    #[test]
220    fn test_verify_tampered_log() {
221        let dir = tempfile::tempdir().unwrap();
222        let path = dir.path().join("audit.jsonl");
223        let mut file = std::fs::File::create(&path).unwrap();
224
225        let line1 = r#"{"event":"one"}"#;
226        let signed1 = sign_line(line1, "genesis");
227        writeln!(file, "{}", signed1).unwrap();
228
229        // Write a tampered second line (wrong HMAC)
230        let line2 = r#"{"event":"two"}"#;
231        writeln!(file, "{}\t#HMAC:0000000000000000000000000000000000000000000000000000000000000000", line2).unwrap();
232
233        let result = verify_audit_log(&path).unwrap();
234        assert_eq!(result.total_lines, 2);
235        assert_eq!(result.tampered_lines, vec![2]);
236        assert!(!result.is_valid());
237    }
238
239    #[test]
240    fn test_verify_mixed_signed_unsigned() {
241        let dir = tempfile::tempdir().unwrap();
242        let path = dir.path().join("audit.jsonl");
243        let mut file = std::fs::File::create(&path).unwrap();
244
245        // Unsigned legacy line
246        writeln!(file, r#"{{"event":"legacy"}}"#).unwrap();
247
248        // Signed line after legacy
249        let prev_hash = compute_chain_hmac(r#"{"event":"legacy"}"#, "genesis");
250        let line2 = r#"{"event":"new"}"#;
251        let signed2 = sign_line(line2, &prev_hash);
252        writeln!(file, "{}", signed2).unwrap();
253
254        let result = verify_audit_log(&path).unwrap();
255        assert_eq!(result.total_lines, 2);
256        assert_eq!(result.unsigned_lines, 1);
257        assert_eq!(result.signed_lines, 1);
258        assert_eq!(result.valid_lines, 1);
259        assert!(result.is_valid());
260    }
261
262    #[test]
263    fn test_last_chain_hash_empty() {
264        let dir = tempfile::tempdir().unwrap();
265        let path = dir.path().join("nonexistent.jsonl");
266        assert_eq!(last_chain_hash(&path), "genesis");
267    }
268
269    #[test]
270    fn test_last_chain_hash_with_entries() {
271        let dir = tempfile::tempdir().unwrap();
272        let path = dir.path().join("audit.jsonl");
273        let mut file = std::fs::File::create(&path).unwrap();
274
275        let line1 = r#"{"event":"one"}"#;
276        let signed1 = sign_line(line1, "genesis");
277        writeln!(file, "{}", signed1).unwrap();
278
279        let expected_hash = compute_chain_hmac(line1, "genesis");
280        assert_eq!(last_chain_hash(&path), expected_hash);
281    }
282}