Skip to main content

firstpass_core/
hashchain.rs

1//! Tamper-evident audit hash chain (SPEC §9).
2//!
3//! Every trace record carries the hash of the previous record (`prev_hash`), forming an
4//! append-only chain: altering any past record changes its hash and breaks every link after
5//! it. The chain is **re-derivable by an external auditor** from the stored records alone —
6//! so hashing must not depend on struct field order or on which serde features are compiled
7//! in. We achieve that by canonicalizing to sorted-key, whitespace-free JSON before hashing.
8//!
9//! A record hashes over its *entire* content **including** its own `prev_hash`, but **not**
10//! any field holding its own hash (that would be circular) — hence trace records store
11//! `prev_hash` only, and their own hash is derived on demand via [`record_hash`].
12
13use crate::error::{Error, Result};
14use serde::Serialize;
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17use std::collections::BTreeMap;
18
19/// The `prev_hash` of the first record in a chain: 64 hex zeros (SHA-256 width).
20pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
21
22/// Hex-encoded SHA-256 of arbitrary bytes.
23#[must_use]
24pub fn sha256_hex(bytes: &[u8]) -> String {
25    hex::encode(Sha256::digest(bytes))
26}
27
28/// Canonical JSON encoding of a value: object keys sorted lexicographically at every depth,
29/// no insignificant whitespace.
30///
31/// Determinism notes: numbers are emitted by `serde_json` (shortest round-trip via `ryu`),
32/// which is stable for a given `f64`. Inputs in this crate are finite and non-negative, so
33/// signed-zero / non-finite edge cases do not arise.
34///
35/// # Errors
36/// Returns [`Error::Json`] if the value cannot be represented as JSON.
37pub fn canonical_json<T: Serialize>(value: &T) -> Result<String> {
38    let v = serde_json::to_value(value)?;
39    Ok(serde_json::to_string(&canonicalize(v))?)
40}
41
42/// Recursively rebuild a JSON value with object keys in sorted order. This is explicit
43/// (rather than relying on `serde_json`'s default `BTreeMap`-backed map) so canonicalization
44/// holds even if the `preserve_order` feature is enabled anywhere in the build graph.
45fn canonicalize(v: Value) -> Value {
46    match v {
47        Value::Object(map) => {
48            let sorted: BTreeMap<String, Value> = map
49                .into_iter()
50                .map(|(k, val)| (k, canonicalize(val)))
51                .collect();
52            let mut out = serde_json::Map::new();
53            for (k, val) in sorted {
54                out.insert(k, val);
55            }
56            Value::Object(out)
57        }
58        Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize).collect()),
59        other => other,
60    }
61}
62
63/// The hash of a record: `SHA-256` over its canonical JSON, hex-encoded.
64///
65/// # Errors
66/// Returns [`Error::Json`] if the record cannot be serialized.
67pub fn record_hash<T: Serialize>(record: &T) -> Result<String> {
68    Ok(sha256_hex(canonical_json(record)?.as_bytes()))
69}
70
71/// A record that participates in the hash chain by exposing the previous record's hash.
72pub trait Chained {
73    /// The hash of the preceding record (or [`GENESIS_HASH`] for the first).
74    fn prev_hash(&self) -> &str;
75}
76
77/// Verify that `records` form an unbroken chain starting from `genesis`.
78///
79/// For each record, its `prev_hash` must equal the hash of the record before it (or
80/// `genesis` for the first). Any mismatch — a tampered payload or a re-linked record —
81/// surfaces as [`Error::ChainBroken`] pointing at the first bad index.
82///
83/// # Errors
84/// Returns [`Error::ChainBroken`] on the first broken link, or [`Error::Json`] if a record
85/// cannot be serialized for hashing.
86pub fn verify_chain<T: Serialize + Chained>(records: &[T], genesis: &str) -> Result<()> {
87    let mut expected_prev = genesis.to_owned();
88    for (i, rec) in records.iter().enumerate() {
89        if rec.prev_hash() != expected_prev {
90            return Err(Error::ChainBroken {
91                index: i,
92                detail: format!(
93                    "prev_hash link mismatch: expected {expected_prev}, found {}",
94                    rec.prev_hash()
95                ),
96            });
97        }
98        expected_prev = record_hash(rec)?;
99    }
100    Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde::Serialize;
107    use serde_json::json;
108
109    #[test]
110    fn canonical_json_sorts_keys_at_every_depth() {
111        let v = json!({ "b": 1, "a": { "d": 4, "c": 3 }, "arr": [ { "y": 2, "x": 1 } ] });
112        assert_eq!(
113            canonical_json(&v).unwrap(),
114            r#"{"a":{"c":3,"d":4},"arr":[{"x":1,"y":2}],"b":1}"#
115        );
116    }
117
118    #[test]
119    fn canonical_json_is_field_order_independent() {
120        // Same logical content, different insertion order -> identical canonical form -> identical hash.
121        let a = json!({ "x": 1, "y": 2 });
122        let b = json!({ "y": 2, "x": 1 });
123        assert_eq!(record_hash(&a).unwrap(), record_hash(&b).unwrap());
124    }
125
126    #[test]
127    fn record_hash_is_deterministic_and_sensitive() {
128        let a = json!({ "n": 1 });
129        assert_eq!(record_hash(&a).unwrap(), record_hash(&a).unwrap());
130        assert_ne!(
131            record_hash(&a).unwrap(),
132            record_hash(&json!({ "n": 2 })).unwrap()
133        );
134    }
135
136    #[derive(Serialize)]
137    struct Rec {
138        prev_hash: String,
139        n: u64,
140    }
141    impl Chained for Rec {
142        fn prev_hash(&self) -> &str {
143            &self.prev_hash
144        }
145    }
146
147    fn build_chain(payloads: &[u64]) -> Vec<Rec> {
148        let mut prev = GENESIS_HASH.to_owned();
149        let mut out = Vec::new();
150        for &n in payloads {
151            let rec = Rec {
152                prev_hash: prev.clone(),
153                n,
154            };
155            prev = record_hash(&rec).unwrap();
156            out.push(rec);
157        }
158        out
159    }
160
161    #[test]
162    fn valid_chain_verifies() {
163        let chain = build_chain(&[10, 20, 30]);
164        assert!(verify_chain(&chain, GENESIS_HASH).is_ok());
165    }
166
167    #[test]
168    fn tampering_payload_breaks_the_next_link() {
169        let mut chain = build_chain(&[10, 20, 30]);
170        chain[1].n = 999; // alter middle record's content
171        match verify_chain(&chain, GENESIS_HASH) {
172            Err(Error::ChainBroken { index, .. }) => assert_eq!(index, 2),
173            other => panic!("expected ChainBroken at 2, got {other:?}"),
174        }
175    }
176
177    #[test]
178    fn relinking_a_prev_hash_is_detected() {
179        let mut chain = build_chain(&[10, 20, 30]);
180        chain[1].prev_hash = GENESIS_HASH.to_owned(); // forge the link itself
181        match verify_chain(&chain, GENESIS_HASH) {
182            Err(Error::ChainBroken { index, .. }) => assert_eq!(index, 1),
183            other => panic!("expected ChainBroken at 1, got {other:?}"),
184        }
185    }
186}