fraiseql_auth/audit/chain.rs
1//! HMAC-chained tamper-evident audit log.
2//!
3//! Implements a cryptographic chain where each audit entry's `chain_hash` is
4//! `HMAC-SHA256(prev_chain_hash || entry_json)`. This makes every entry depend
5//! on all previous entries: retroactive modification, deletion, or insertion
6//! of any single entry breaks the chain from that point forward.
7//!
8//! # Chain verification
9//!
10//! The [`verify_chain`] function performs a streaming O(n) pass over a sequence
11//! of serialized entries, recomputing each hash and comparing it to the stored
12//! `chain_hash` field. Any mismatch is reported as a [`ChainVerifyError`] with
13//! the index of the first broken link.
14//!
15//! # Key management
16//!
17//! The chain seed (initial HMAC key) must be 32 bytes. In production it should
18//! be stored in Vault or read from an environment variable (never in plaintext
19//! config). Configure via `fraiseql.toml`:
20//!
21//! ```toml
22//! [fraiseql.security.audit_logging]
23//! tamper_evident = true
24//! chain_seed_env = "AUDIT_CHAIN_SEED" # 32-byte hex-encoded value
25//! ```
26
27use hmac::{Hmac, Mac};
28use sha2::Sha256;
29
30type HmacSha256 = Hmac<Sha256>;
31
32// ============================================================================
33// Core hashing
34// ============================================================================
35
36/// Compute an HMAC-SHA256 chain link.
37///
38/// The hash is `HMAC-SHA256(prev_hash || entry_json)` where `prev_hash` serves
39/// as the HMAC key. This ensures each hash depends on the entire prior chain.
40///
41/// # Returns
42///
43/// A 32-byte raw HMAC-SHA256 output.
44fn compute_chain_hash(prev_hash: &[u8; 32], entry_json: &str) -> [u8; 32] {
45 #[allow(clippy::unwrap_used)]
46 // Reason: HmacSha256::new_from_slice only fails on empty keys; 32-byte array is always valid
47 let mut mac = HmacSha256::new_from_slice(prev_hash)
48 .expect("HMAC-SHA256 accepts any non-empty key length");
49 mac.update(entry_json.as_bytes());
50 mac.finalize().into_bytes().into()
51}
52
53/// Hex-encode a 32-byte hash to a 64-character lowercase hex string.
54fn encode_chain_hash(hash: &[u8; 32]) -> String {
55 hex::encode(hash)
56}
57
58// ============================================================================
59// ChainHasher — stateful hasher for sequential entry writing
60// ============================================================================
61
62/// Stateful HMAC chain hasher for sequential audit entry writing.
63///
64/// Maintains the running chain state. Call [`ChainHasher::advance`] for each
65/// entry in order to produce the `chain_hash` value to embed in that entry.
66///
67/// # Thread safety
68///
69/// `ChainHasher` is **not** `Sync` by itself. For shared concurrent use, wrap
70/// in a `Mutex<ChainHasher>` or `Arc<Mutex<ChainHasher>>`.
71pub struct ChainHasher {
72 current: [u8; 32],
73}
74
75impl ChainHasher {
76 /// Create a new `ChainHasher` starting from the given seed.
77 ///
78 /// The seed must be exactly 32 bytes (256 bits). In production, derive it
79 /// from Vault or an environment variable — never hardcode it.
80 #[must_use]
81 pub const fn new(seed: [u8; 32]) -> Self {
82 Self { current: seed }
83 }
84
85 /// Advance the chain by one entry and return the hex-encoded chain hash.
86 ///
87 /// The returned value is the `chain_hash` field to embed in the entry.
88 /// The internal state is updated so the next call depends on this hash.
89 pub fn advance(&mut self, entry_json: &str) -> String {
90 self.current = compute_chain_hash(&self.current, entry_json);
91 encode_chain_hash(&self.current)
92 }
93
94 /// Return the current chain hash (hex-encoded) without advancing.
95 #[must_use]
96 pub fn current_hash(&self) -> String {
97 encode_chain_hash(&self.current)
98 }
99}
100
101// ============================================================================
102// Chain verification
103// ============================================================================
104
105/// Error returned by [`verify_chain`] when the chain is broken.
106#[derive(Debug, thiserror::Error)]
107#[non_exhaustive]
108pub enum ChainVerifyError {
109 /// A computed hash does not match the stored `chain_hash` at this entry.
110 #[error("Chain broken at entry {entry_index}: expected {expected_hash}, got {stored_hash}")]
111 BrokenLink {
112 /// Zero-based index of the first broken entry.
113 entry_index: usize,
114 /// The hash we computed from the chain.
115 expected_hash: String,
116 /// The `chain_hash` stored in the entry.
117 stored_hash: String,
118 },
119 /// An entry could not be parsed (missing or invalid `chain_hash` field).
120 #[error("Entry {entry_index} is missing or has an invalid `chain_hash` field")]
121 MissingChainHash {
122 /// Zero-based index of the malformed entry.
123 entry_index: usize,
124 },
125 /// An audit entry is not a JSON object.
126 ///
127 /// Every entry produced by the audit logger is a JSON object. If this
128 /// variant is returned the supplied entries are malformed or have been
129 /// tampered with.
130 #[error("Entry {entry_index} is not a JSON object")]
131 InvalidEntry {
132 /// Zero-based index of the malformed entry.
133 entry_index: usize,
134 },
135}
136
137/// Verify the HMAC chain over a sequence of JSON entries.
138///
139/// Each entry must be a `serde_json::Value` with a `"chain_hash"` field containing
140/// the hex-encoded 64-character hash. The entry JSON used for re-hashing is the
141/// entry **without** the `chain_hash` field (i.e. the hash is computed over the
142/// rest of the entry, then the result is stored in `chain_hash`).
143///
144/// Returns `Ok(count)` (total entries verified) if the chain is intact,
145/// or `Err(ChainVerifyError)` at the first broken link.
146///
147/// # Memory
148///
149/// O(1) — processes entries one at a time without accumulating state.
150///
151/// # Errors
152///
153/// Returns [`ChainVerifyError::BrokenLink`] if any hash mismatches.
154/// Returns [`ChainVerifyError::MissingChainHash`] if any entry lacks the field.
155pub fn verify_chain(
156 entries: impl IntoIterator<Item = serde_json::Value>,
157 seed: [u8; 32],
158) -> Result<usize, ChainVerifyError> {
159 let mut hasher = ChainHasher::new(seed);
160 let mut count = 0usize;
161
162 for (idx, entry) in entries.into_iter().enumerate() {
163 // Extract the stored chain_hash.
164 let stored_hash = entry
165 .get("chain_hash")
166 .and_then(|v| v.as_str())
167 .ok_or(ChainVerifyError::MissingChainHash { entry_index: idx })?
168 .to_string();
169
170 // Re-compute the hash over the entry without the chain_hash field.
171 let mut entry_for_hash = entry;
172 entry_for_hash
173 .as_object_mut()
174 .ok_or(ChainVerifyError::InvalidEntry { entry_index: idx })?
175 .remove("chain_hash");
176 let entry_json = entry_for_hash.to_string();
177 let expected_hash = hasher.advance(&entry_json);
178
179 if expected_hash != stored_hash {
180 return Err(ChainVerifyError::BrokenLink {
181 entry_index: idx,
182 expected_hash,
183 stored_hash,
184 });
185 }
186
187 count += 1;
188 }
189
190 Ok(count)
191}
192
193// ============================================================================
194// Tests
195// ============================================================================
196
197#[cfg(test)]
198mod tests {
199 #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
200
201 use super::*;
202
203 const TEST_SEED: [u8; 32] = *b"test-seed-32-bytes-exactly-here!";
204
205 fn make_entry(action: &str, hasher: &mut ChainHasher) -> serde_json::Value {
206 let mut entry = serde_json::json!({ "action": action, "user": "u1" });
207 let hash = hasher.advance(&entry.to_string());
208 entry["chain_hash"] = serde_json::Value::String(hash);
209 entry
210 }
211
212 fn generate_chained_entries(n: usize, seed: [u8; 32]) -> Vec<serde_json::Value> {
213 let mut hasher = ChainHasher::new(seed);
214 (0..n).map(|i| make_entry(&format!("action-{i}"), &mut hasher)).collect()
215 }
216
217 #[test]
218 fn test_chain_hash_is_deterministic() {
219 let h1 = compute_chain_hash(&TEST_SEED, "entry-1");
220 let h2 = compute_chain_hash(&TEST_SEED, "entry-1");
221 assert_eq!(h1, h2, "same inputs must produce same hash");
222 }
223
224 #[test]
225 fn test_chain_hash_changes_with_content() {
226 let h1 = compute_chain_hash(&TEST_SEED, r#"{"action":"query"}"#);
227 let h2 = compute_chain_hash(&TEST_SEED, r#"{"action":"mutation"}"#);
228 assert_ne!(h1, h2, "different content must produce different hash");
229 }
230
231 #[test]
232 fn test_chain_is_sequential() {
233 let h1 = compute_chain_hash(&TEST_SEED, "entry-1");
234 let h2 = compute_chain_hash(&h1, "entry-2");
235 let h3 = compute_chain_hash(&h2, "entry-3");
236 // Re-compute h3 skipping h2 — must differ.
237 let h3_alt = compute_chain_hash(&h1, "entry-3");
238 assert_ne!(h3, h3_alt, "sequential hashes must differ from skipped chain");
239 }
240
241 #[test]
242 fn test_chain_hash_output_is_64_hex_chars() {
243 let h = encode_chain_hash(&compute_chain_hash(&TEST_SEED, "entry"));
244 assert_eq!(h.len(), 64, "hex-encoded SHA256 must be 64 characters");
245 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
246 }
247
248 #[test]
249 fn test_hasher_advance_changes_state() {
250 let mut hasher = ChainHasher::new(TEST_SEED);
251 let h1 = hasher.advance("entry-1");
252 let h2 = hasher.advance("entry-1"); // same content, different state
253 assert_ne!(h1, h2, "advancing changes internal state");
254 }
255
256 #[test]
257 fn test_verify_valid_chain_passes() {
258 let entries = generate_chained_entries(100, TEST_SEED);
259 let result = verify_chain(entries, TEST_SEED);
260 assert!(result.is_ok(), "valid chain must pass verification");
261 assert_eq!(result.unwrap(), 100);
262 }
263
264 #[test]
265 fn test_verify_detects_modified_entry() {
266 let mut entries = generate_chained_entries(100, TEST_SEED);
267 entries[50]["action"] = serde_json::Value::String("TAMPERED".to_string());
268 let result = verify_chain(entries, TEST_SEED);
269 assert!(
270 matches!(
271 result,
272 Err(ChainVerifyError::BrokenLink {
273 entry_index: 50,
274 ..
275 })
276 ),
277 "modified entry must break chain at that index"
278 );
279 }
280
281 #[test]
282 fn test_verify_detects_deleted_entry() {
283 let mut entries = generate_chained_entries(100, TEST_SEED);
284 entries.remove(50);
285 let result = verify_chain(entries, TEST_SEED);
286 assert!(
287 matches!(
288 result,
289 Err(ChainVerifyError::BrokenLink {
290 entry_index: 50,
291 ..
292 })
293 ),
294 "deleted entry must break chain at the deletion point"
295 );
296 }
297
298 #[test]
299 fn test_verify_empty_chain_passes() {
300 let result = verify_chain([], TEST_SEED);
301 assert_eq!(result.unwrap(), 0);
302 }
303
304 #[test]
305 fn test_verify_detects_missing_chain_hash() {
306 let entries = vec![serde_json::json!({ "action": "query" })]; // no chain_hash
307 let result = verify_chain(entries, TEST_SEED);
308 assert!(matches!(result, Err(ChainVerifyError::MissingChainHash { entry_index: 0 })));
309 }
310}