Skip to main content

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}