cairn_mod/audit/hash.rs
1//! Audit-log hash-chain primitive (#39, v1.3).
2//!
3//! Pure function `compute_audit_row_hash` plus the `GENESIS_PREV_HASH`
4//! sentinel. Called by both append paths (writer-task-mediated and
5//! pool-direct) so the hash implementation is single-sourced — drift
6//! between the paths is impossible by construction.
7//!
8//! The hash construction matches the existing label-signing posture
9//! (§6.2): proto-blue's `lex_cbor::encode` for canonical DAG-CBOR + an
10//! ATProto-conventional SHA-256. No new dep, no parallel canonical
11//! scheme.
12//!
13//! ```text
14//! row_hash = SHA-256(prev_hash || dag_cbor_canonical(row_content))
15//! ```
16//!
17//! `row_content` is a CBOR map of the row's audit fields, deliberately
18//! excluding the SQL-level `id`, `prev_hash`, and `row_hash` columns:
19//!
20//! - `id` is a primary key, not an audit semantic. The chain locks
21//! ordering via prev_hash; an attacker reordering rows would break
22//! every affected row's prev_hash link, so id-in-hash adds no
23//! integrity power.
24//! - `prev_hash` and `row_hash` are the chain fields themselves
25//! (including either would be circular).
26
27use std::collections::BTreeMap;
28
29use proto_blue_lex_cbor::encode;
30use proto_blue_lex_data::LexValue;
31
32use crate::error::{Error, Result};
33
34/// Sentinel used as `prev_hash` for the genesis row of any audit chain.
35/// Documented value: 32 zero bytes.
36///
37/// The append path falls back to this sentinel whenever the latest
38/// audit_log row has `NULL row_hash` — i.e., the table is empty, or it
39/// only contains pre-v1.3 rows that haven't been backfilled yet. After
40/// `cairn audit-rebuild` (#40) runs, pre-v1.3 rows form their own chain
41/// rooted at this same sentinel; the post-migration chain is unaffected.
42pub const GENESIS_PREV_HASH: [u8; 32] = [0u8; 32];
43
44/// Borrowed view of an audit row's hash-relevant content.
45///
46/// Field set is the audit_log columns minus the SQL/chain plumbing
47/// (`id`, `prev_hash`, `row_hash`). Optional columns are `Option<&str>`;
48/// absence is encoded as field-omission in the canonical CBOR (matching
49/// the §6.2 / `@atproto/api` convention of "absent != null").
50pub struct AuditRowForHashing<'a> {
51 /// Internal wall-clock epoch-ms (matches the `created_at INTEGER`
52 /// column).
53 pub created_at: i64,
54 /// Audit-action discriminator (e.g. `"label_applied"`).
55 pub action: &'a str,
56 /// DID of the moderator/admin/operator that triggered the action.
57 pub actor_did: &'a str,
58 /// Optional target identifier (AT-URI, DID, report id, etc.).
59 pub target: Option<&'a str>,
60 /// Optional CID pin on the target.
61 pub target_cid: Option<&'a str>,
62 /// `"success"` or `"failure"`.
63 pub outcome: &'a str,
64 /// Optional structured-JSON or free-text payload.
65 pub reason: Option<&'a str>,
66}
67
68/// Compute the row hash for `row` chained from `prev_hash`.
69///
70/// `Err` only on the (unreachable in practice) case that proto-blue's
71/// canonical encoder rejects the constructed `LexValue` — every field
72/// is a bounded scalar or string here, so the encoder cannot fail
73/// under valid inputs. The fallible signature is preserved for symmetry
74/// with the rest of the signing surface and to keep the door open for
75/// future field types that may push the encoder into refusal cases.
76pub fn compute_audit_row_hash(
77 prev_hash: &[u8; 32],
78 row: &AuditRowForHashing<'_>,
79) -> Result<[u8; 32]> {
80 let canonical = encode(&audit_row_to_lex_value(row))?;
81 Ok(compute_chain_hash(prev_hash, &canonical))
82}
83
84/// SHA-256 wrap that turns `(prev_hash, canonical_bytes)` into the
85/// next chain link's `row_hash`. Extracted from
86/// [`compute_audit_row_hash`] so #85's `pds_admin_audit` table can
87/// share the same SHA-256 primitive without re-deriving it. Different
88/// row shapes canonicalize their content their own way (different
89/// fields, different `LexValue::Map` keys); the chain-link primitive
90/// itself is identical regardless of which table the row lives in.
91///
92/// `pub(crate)` rather than `pub` because the hash construction is
93/// an internal contract — external callers should always go through
94/// the typed per-table wrappers
95/// ([`compute_audit_row_hash`], or §F23's
96/// `pds_admin_audit` analog) so the row-content discipline (which
97/// fields participate, which are excluded) stays single-sourced.
98pub(crate) fn compute_chain_hash(prev_hash: &[u8; 32], canonical: &[u8]) -> [u8; 32] {
99 let mut input = Vec::with_capacity(prev_hash.len() + canonical.len());
100 input.extend_from_slice(prev_hash);
101 input.extend_from_slice(canonical);
102 proto_blue_crypto::sha256(&input)
103}
104
105/// Build the `LexValue::Map` representation of the row's hash-relevant
106/// content. proto-blue's canonical encoder applies the §6.2 sort
107/// (length-first, then byte order) over the map keys; callers don't
108/// need to pre-sort.
109///
110/// Optional fields are conditionally inserted so absence canonicalizes
111/// as "key omitted" rather than "key with null value." The two encodings
112/// produce different hashes, so this distinction is load-bearing for
113/// chain integrity.
114fn audit_row_to_lex_value(row: &AuditRowForHashing<'_>) -> LexValue {
115 let mut m = BTreeMap::new();
116 m.insert("created_at".to_string(), LexValue::Integer(row.created_at));
117 m.insert(
118 "action".to_string(),
119 LexValue::String(row.action.to_string()),
120 );
121 m.insert(
122 "actor_did".to_string(),
123 LexValue::String(row.actor_did.to_string()),
124 );
125 if let Some(target) = row.target {
126 m.insert("target".to_string(), LexValue::String(target.to_string()));
127 }
128 if let Some(target_cid) = row.target_cid {
129 m.insert(
130 "target_cid".to_string(),
131 LexValue::String(target_cid.to_string()),
132 );
133 }
134 m.insert(
135 "outcome".to_string(),
136 LexValue::String(row.outcome.to_string()),
137 );
138 if let Some(reason) = row.reason {
139 m.insert("reason".to_string(), LexValue::String(reason.to_string()));
140 }
141 LexValue::Map(m)
142}
143
144/// Decode a stored `row_hash` blob into a 32-byte array. Returns
145/// `Err(Error::Signing)` if the blob length is wrong — that's a DB
146/// corruption case the caller surfaces as an internal error.
147pub fn parse_stored_hash(bytes: &[u8]) -> Result<[u8; 32]> {
148 bytes.try_into().map_err(|_| {
149 Error::Signing(format!(
150 "stored audit row_hash has wrong length: {} bytes (expected 32)",
151 bytes.len()
152 ))
153 })
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn fixture_row<'a>() -> AuditRowForHashing<'a> {
161 AuditRowForHashing {
162 created_at: 1_776_902_400_000,
163 action: "label_applied",
164 actor_did: "did:plc:moderator0000000000000000",
165 target: Some("at://did:plc:target/col/r"),
166 target_cid: Some("bafytest"),
167 outcome: "success",
168 reason: Some(r#"{"val":"spam","neg":false,"moderator_reason":null}"#),
169 }
170 }
171
172 #[test]
173 fn deterministic_for_same_input() {
174 let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
175 let h2 = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
176 assert_eq!(h1, h2, "hash must be deterministic");
177 }
178
179 #[test]
180 fn genesis_sentinel_is_32_zero_bytes() {
181 assert_eq!(GENESIS_PREV_HASH, [0u8; 32]);
182 }
183
184 #[test]
185 fn different_prev_hash_produces_different_row_hash() {
186 let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
187 let h2 = compute_audit_row_hash(&[1u8; 32], &fixture_row()).unwrap();
188 assert_ne!(h1, h2, "prev_hash must affect row_hash");
189 }
190
191 #[test]
192 fn different_action_produces_different_row_hash() {
193 let mut row_a = fixture_row();
194 row_a.action = "label_applied";
195 let mut row_b = fixture_row();
196 row_b.action = "label_negated";
197 let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_a).unwrap();
198 let h2 = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_b).unwrap();
199 assert_ne!(h1, h2);
200 }
201
202 #[test]
203 fn absent_optional_differs_from_empty_string() {
204 // Field-absent vs. field-present-with-empty-string canonicalize
205 // differently; chain integrity depends on this distinction.
206 let mut row_absent = fixture_row();
207 row_absent.target = None;
208 let mut row_empty = fixture_row();
209 row_empty.target = Some("");
210 let h_absent = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_absent).unwrap();
211 let h_empty = compute_audit_row_hash(&GENESIS_PREV_HASH, &row_empty).unwrap();
212 assert_ne!(
213 h_absent, h_empty,
214 "absent target and empty target must hash distinctly"
215 );
216 }
217
218 #[test]
219 fn parse_stored_hash_round_trip() {
220 let computed = compute_audit_row_hash(&GENESIS_PREV_HASH, &fixture_row()).unwrap();
221 let parsed = parse_stored_hash(&computed).unwrap();
222 assert_eq!(parsed, computed);
223 }
224
225 #[test]
226 fn parse_stored_hash_rejects_wrong_length() {
227 let too_short = [0u8; 20];
228 assert!(parse_stored_hash(&too_short).is_err());
229 let too_long = [0u8; 64];
230 assert!(parse_stored_hash(&too_long).is_err());
231 }
232
233 #[test]
234 fn chain_link_locks_ordering() {
235 // Build a 3-row chain: H1 = H(GENESIS, row1); H2 = H(H1, row2);
236 // H3 = H(H2, row3). Then verify that swapping row2 and row3's
237 // content while keeping H2/H3 untouched would not match
238 // recomputation — i.e., the chain detects reordering.
239 let row1 = AuditRowForHashing {
240 created_at: 1,
241 action: "label_applied",
242 actor_did: "did:plc:m1",
243 target: None,
244 target_cid: None,
245 outcome: "success",
246 reason: None,
247 };
248 let row2 = AuditRowForHashing {
249 created_at: 2,
250 action: "label_negated",
251 actor_did: "did:plc:m1",
252 target: None,
253 target_cid: None,
254 outcome: "success",
255 reason: None,
256 };
257 let row3 = AuditRowForHashing {
258 created_at: 3,
259 action: "report_resolved",
260 actor_did: "did:plc:m2",
261 target: None,
262 target_cid: None,
263 outcome: "success",
264 reason: None,
265 };
266
267 let h1 = compute_audit_row_hash(&GENESIS_PREV_HASH, &row1).unwrap();
268 let h2 = compute_audit_row_hash(&h1, &row2).unwrap();
269 let h3 = compute_audit_row_hash(&h2, &row3).unwrap();
270
271 // Recompute as if rows 2 and 3 were swapped in commit order.
272 // h2_swapped = H(h1, row3), h3_swapped = H(h2_swapped, row2).
273 // Both swapped hashes differ from the originals, proving the
274 // chain detects the reorder.
275 let h2_swapped = compute_audit_row_hash(&h1, &row3).unwrap();
276 let h3_swapped = compute_audit_row_hash(&h2_swapped, &row2).unwrap();
277 assert_ne!(h2, h2_swapped);
278 assert_ne!(h3, h3_swapped);
279 }
280}