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
11fn 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
18pub 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
29pub 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
36pub 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#[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#[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
74pub 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 previous_hash = expected_hash.to_string();
115 }
116 }
117 None => {
118 result.unsigned_lines += 1;
119 previous_hash = compute_chain_hmac(content, &previous_hash);
122 }
123 }
124 }
125
126 Ok(result)
127}
128
129pub 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); }
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 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 writeln!(file, r#"{{"event":"legacy"}}"#).unwrap();
247
248 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}