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.
44pub(crate) fn 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.
54pub(crate) fn 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}