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