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).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
28pub 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
35pub 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#[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#[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
73pub 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 previous_hash = expected_hash.to_string();
114 }
115 }
116 None => {
117 result.unsigned_lines += 1;
118 previous_hash = compute_chain_hmac(content, &previous_hash);
121 }
122 }
123 }
124
125 Ok(result)
126}
127
128pub 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); }
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 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 writeln!(file, r#"{{"event":"legacy"}}"#).unwrap();
251
252 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}