Skip to main content

dent8_core/
hash.rs

1//! Canonical serialization and tamper-evident hashing for fact events.
2//!
3//! [`canonical_bytes`] produces a deterministic byte encoding of a [`FactEvent`] by
4//! serializing through `serde_json`'s default (`BTreeMap`-backed) `Value`, which emits
5//! object keys in sorted order and compact output. This is a sorted-key canonical form
6//! produced by `serde_json`, **not RFC 8785 (JCS)**: keys sort by Rust `String` order (UTF-8 byte
7//! order, whereas JCS uses UTF-16 code units) and the number/escape rules differ. The
8//! two coincide here only because every *dent8* object key is an ASCII field/variant name
9//! and every *dent8* number is an integer (`Confidence` is `u16`, `TimestampMillis` is
10//! `i64`). No dent8 field may introduce a non-ASCII or dynamic object key without bumping
11//! [`CANON_VERSION`] and revisiting this. (Embedded JSON inside `FactValue::Json` is
12//! exempt: its keys *may* be non-ASCII/dynamic and its numbers `f64` — they are sorted and
13//! emitted deterministically and idempotently, enough for the hash chain, but not in JCS
14//! order; see [`crate::model::CanonicalJson`].) See
15//! `docs/decisions/0004-canonicalization-and-hash-chain.md`.
16//!
17//! The "logically-equal events produce identical bytes" invariant holds for every field,
18//! **including** `FactValue::Json`: that variant is [`crate::model::CanonicalJson`], which
19//! is canonical by construction (parsed and re-emitted with sorted keys and no whitespace,
20//! re-applied on deserialize), so two semantically-equal JSON blobs that differ only in key
21//! order or whitespace hash identically (ADR 0004 item 6, resolved).
22//!
23//! **Adding optional fields.** A new field may be added *without* bumping [`CANON_VERSION`]
24//! only if it is (a) a static ASCII key, and (b) `Option` with
25//! `#[serde(default, skip_serializing_if = "Option::is_none")]` — then every pre-existing
26//! event still serializes to byte-identical canonical form (its stored hash keeps
27//! verifying), and events carrying the field produce new bytes that no v1 event could have
28//! emitted (no cross-collision). `provenance.attestation` (ADR 0013) is added under this
29//! rule. Any other shape of change still requires the versioning procedure on
30//! [`CANON_VERSION`].
31//!
32//! [`event_hash`] chains events with an **injective, length-framed** leaf encoding —
33//! `SHA-256(0x00 || version || len(canonical) || canonical || tag || prev_digest)` —
34//! with RFC 6962-style domain separation (a `0x00` leaf prefix). Length-prefixing the
35//! canonical bytes and tagging the genesis case mean no two distinct `(canonical,
36//! previous)` pairs can share a hash input, so the log is tamper-evident: altering any
37//! event changes its hash and every later one.
38
39use sha2::{Digest, Sha256};
40
41use crate::model::FactEvent;
42
43/// Version of the canonical encoding. Mixed into every leaf hash so events under
44/// different encodings never collide. (This is dent8's `schema_version`, realized as an
45/// out-of-band constant rather than a per-event field — see ADR 0004 item 7.)
46///
47/// **v2 (pre-1.0 fact-vocabulary format break).** Version 2 renamed the wire fields to the
48/// `fact` vocabulary (`claim_id` → `fact_id`) and lowercased `authority` (`"High"` → `"high"`),
49/// so every event's canonical bytes — and therefore its hash — differ from v1. This is a
50/// deliberate one-time break: **a v1 log will not verify against this build**, and there is no
51/// in-place migration (re-ingest from source). Bumping the constant also re-domain-separates the
52/// leaf so a v1 and v2 encoding of the "same" event can never collide.
53///
54/// Do not bump this again casually: a *backward-compatible* addition (a new optional field) must
55/// NOT bump it — add the field serde-default and exclude it from `canonical_bytes` so existing
56/// events keep verifying (ADR 0004 item 7). Only a breaking re-encoding like this one bumps.
57pub const CANON_VERSION: u8 = 2;
58
59/// Domain-separation prefix for a leaf (event) hash, RFC 6962 style. Interior/Merkle
60/// nodes would use `0x01`, reserved for a future inclusion/consistency-proof layer.
61const LEAF_PREFIX: u8 = 0x00;
62
63/// Byte width of a SHA-256 digest.
64const DIGEST_LEN: usize = 32;
65
66/// Canonical, deterministic byte encoding of a fact event: two logically-equal events
67/// produce identical bytes regardless of struct field order, map ordering, or — for a
68/// `FactValue::Json` value — the embedded JSON's key order and whitespace.
69pub fn canonical_bytes(event: &FactEvent) -> Result<Vec<u8>, CanonError> {
70    // Route through a Value so object keys are emitted in sorted order (serde_json's
71    // default Map is BTreeMap-backed); to_vec is compact (no insignificant whitespace).
72    let value = serde_json::to_value(event).map_err(CanonError::Serialize)?;
73    serde_json::to_vec(&value).map_err(CanonError::Serialize)
74}
75
76/// Domain-separation prefix for a signed write attestation (ADR 0013). Versioned and
77/// NUL-terminated like the identity-grant domains, so an attestation signature can never be
78/// confused with a grant, write-payload, or tree-head signature.
79const ATTESTATION_DOMAIN: &[u8] = b"dent8.event-attestation.v1\0";
80
81/// The exact bytes a write attestation signs (ADR 0013): the domain tag, then the
82/// length-framed canonical bytes of the event **with `provenance.attestation` stripped**
83/// (the signature cannot cover itself). Both the signer and any later verifier derive this
84/// from the event alone, so an attested event is offline-re-verifiable: recompute this
85/// message from the stored event and check the embedded signature against the embedded
86/// public key.
87pub fn attestation_message(event: &FactEvent) -> Result<Vec<u8>, CanonError> {
88    let mut unattested = event.clone();
89    unattested.provenance.attestation = None;
90    let body = canonical_bytes(&unattested)?;
91    let mut message = Vec::with_capacity(ATTESTATION_DOMAIN.len() + 8 + body.len());
92    message.extend_from_slice(ATTESTATION_DOMAIN);
93    message.extend_from_slice(&(body.len() as u64).to_be_bytes());
94    message.extend_from_slice(&body);
95    Ok(message)
96}
97
98/// The tamper-evident hash of an event, chained to `previous` (the prior event's hash
99/// as lowercase hex, or `None` for the first event in a stream). Returns lowercase hex
100/// of a SHA-256. Errors if `previous` is not a valid 64-char hex digest.
101pub fn event_hash(event: &FactEvent, previous: Option<&str>) -> Result<String, CanonError> {
102    let canonical = canonical_bytes(event)?;
103    let previous = previous.map(decode_digest).transpose()?;
104    Ok(hash_leaf(&canonical, previous.as_ref()))
105}
106
107/// Decode a lowercase-hex SHA-256 digest into its 32 raw bytes, rejecting any string
108/// that is not exactly a 64-char hex digest (including the empty string).
109fn decode_digest(hex_str: &str) -> Result<[u8; DIGEST_LEN], CanonError> {
110    let mut out = [0u8; DIGEST_LEN];
111    hex::decode_to_slice(hex_str, &mut out)
112        .map_err(|_| CanonError::InvalidPreviousHash(hex_str.to_string()))?;
113    Ok(out)
114}
115
116/// Injective, length-framed leaf hash over already-canonical bytes. The genesis case
117/// (`previous = None`, tag `0x00`) and any chained case (tag `0x01` + 32-byte digest)
118/// are unambiguous, and `len(canonical)` removes any `canonical || previous` framing
119/// ambiguity.
120#[must_use]
121fn hash_leaf(canonical: &[u8], previous: Option<&[u8; DIGEST_LEN]>) -> String {
122    let mut hasher = Sha256::new();
123    hasher.update([LEAF_PREFIX, CANON_VERSION]);
124    hasher.update((canonical.len() as u64).to_be_bytes());
125    hasher.update(canonical);
126    match previous {
127        None => hasher.update([0u8]),
128        Some(previous) => {
129            hasher.update([1u8]);
130            hasher.update(previous);
131        }
132    }
133    hex::encode(hasher.finalize())
134}
135
136/// Compute the hash chain for an ordered event stream: each event's hash links to the
137/// previous one. Returns one hash per event, in order. Altering any event changes its
138/// hash and every subsequent hash, so a stored chain can be reverified on replay.
139pub fn hash_chain(events: &[FactEvent]) -> Result<Vec<String>, CanonError> {
140    let mut hashes = Vec::with_capacity(events.len());
141    let mut previous: Option<String> = None;
142    for event in events {
143        let hash = event_hash(event, previous.as_deref())?;
144        previous = Some(hash.clone());
145        hashes.push(hash);
146    }
147    Ok(hashes)
148}
149
150/// Failure to canonicalize or hash an event. Distinct from invalid events, which are
151/// rejected earlier by validation.
152#[derive(Debug)]
153pub enum CanonError {
154    /// The event could not be serialized to canonical bytes.
155    Serialize(serde_json::Error),
156    /// A supplied previous-event hash was not a valid 64-char hex digest.
157    InvalidPreviousHash(String),
158}
159
160impl std::fmt::Display for CanonError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            Self::Serialize(error) => write!(f, "canonicalization failed: {error}"),
164            Self::InvalidPreviousHash(value) => {
165                write!(f, "previous hash is not a 64-char hex digest: {value:?}")
166            }
167        }
168    }
169}
170
171impl std::error::Error for CanonError {
172    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
173        match self {
174            Self::Serialize(error) => Some(error),
175            Self::InvalidPreviousHash(_) => None,
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{CanonError, canonical_bytes, event_hash, hash_chain};
183    use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
184    use crate::model::{
185        Authority, AuthorityLevel, Confidence, Evidence, EvidenceKind, FactEvent, FactEventKind,
186        FactValue, Predicate, Provenance, Subject, SupersessionReason, Ttl,
187    };
188
189    fn event(event_id: &str, kind: FactEventKind, value: Option<FactValue>) -> FactEvent {
190        FactEvent {
191            event_id: FactEventId::new(event_id).expect("event id"),
192            fact_id: FactId::new("fact:1").expect("fact id"),
193            kind,
194            subject: Subject::new("repo", "dent8").expect("subject"),
195            predicate: Predicate::new("uses_database").expect("predicate"),
196            value,
197            confidence: Confidence::from_millis(900).expect("confidence"),
198            authority: Authority {
199                level: AuthorityLevel::High,
200                issuer: None,
201                scope: None,
202            },
203            ttl: Ttl::Never,
204            provenance: Provenance {
205                source: SourceId::new("source:test").expect("source"),
206                actor: ActorId::new("actor:test").expect("actor"),
207                tool: None,
208                run_id: None,
209                input_digest: None,
210                recorded_at: TimestampMillis::from_unix_millis(1),
211                attestation: None,
212            },
213            evidence: vec![Evidence {
214                id: EvidenceId::new("evidence:1").expect("evidence id"),
215                kind: EvidenceKind::UserStatement,
216                locator: "x".to_string(),
217                digest: None,
218                summary: None,
219            }],
220            observed_at: None,
221            valid_from: None,
222            valid_to: None,
223        }
224    }
225
226    fn asserted(event_id: &str) -> FactEvent {
227        event(
228            event_id,
229            FactEventKind::Asserted,
230            Some(FactValue::Text("postgres".to_string())),
231        )
232    }
233
234    #[test]
235    fn canonicalization_is_deterministic() {
236        let e = asserted("event:1");
237        assert_eq!(canonical_bytes(&e).unwrap(), canonical_bytes(&e).unwrap());
238    }
239
240    #[test]
241    fn canonicalization_is_key_order_independent() {
242        // `to_vec(&event)` serializes struct fields in DECLARATION order (event_id
243        // first), which is not alphabetical; `canonical_bytes` sorts keys. The two must
244        // therefore differ — and a differently-ordered document must canonicalize to
245        // the same bytes. The inequality also fails loudly if serde_json's
246        // `preserve_order` feature is ever unified ON, breaking determinism.
247        let e = asserted("event:1");
248        let declaration_order = serde_json::to_vec(&e).expect("serialize");
249        let canonical = canonical_bytes(&e).expect("canonicalize");
250        assert_ne!(
251            declaration_order, canonical,
252            "to_value did not reorder keys"
253        );
254
255        let reparsed: FactEvent = serde_json::from_slice(&declaration_order).expect("deserialize");
256        assert_eq!(
257            canonical_bytes(&reparsed).expect("re-canonicalize"),
258            canonical
259        );
260    }
261
262    #[test]
263    fn embedded_json_is_canonicalized_so_equal_values_hash_equally() {
264        let json_event = |raw: &str| {
265            event(
266                "event:1",
267                FactEventKind::Asserted,
268                Some(FactValue::json(raw).expect("valid json")),
269            )
270        };
271        // Same JSON, different key order *and* whitespace.
272        let a = json_event("{ \"b\": 2, \"a\": 1 }");
273        let b = json_event("{\"a\":1,\n  \"b\":2}");
274        assert_eq!(canonical_bytes(&a).unwrap(), canonical_bytes(&b).unwrap());
275        assert_eq!(event_hash(&a, None).unwrap(), event_hash(&b, None).unwrap());
276
277        // A genuinely different value must still hash differently.
278        let c = json_event(r#"{"a": 2, "b": 1}"#);
279        assert_ne!(event_hash(&a, None).unwrap(), event_hash(&c, None).unwrap());
280    }
281
282    #[test]
283    fn a_float_json_event_survives_a_serde_round_trip_unchanged() {
284        // Regression for the float-idempotency bug: the re-canonicalizing Deserialize must
285        // not alter a legitimately-written float value, or replay/reload would false-alarm
286        // the hash chain. `13e300` is one of the ~10% of f64 values that drifts without the
287        // `float_roundtrip` feature.
288        let e = event(
289            "event:1",
290            FactEventKind::Asserted,
291            Some(FactValue::json(r#"{"ratio": 13e300, "p": 0.1}"#).expect("json")),
292        );
293        let original = canonical_bytes(&e).expect("canonicalize");
294        let reloaded: FactEvent = serde_json::from_slice(&original).expect("deserialize");
295        assert_eq!(
296            canonical_bytes(&reloaded).expect("re-canonicalize"),
297            original
298        );
299        assert_eq!(
300            event_hash(&reloaded, None).unwrap(),
301            event_hash(&e, None).unwrap()
302        );
303    }
304
305    #[test]
306    fn canonicalization_round_trips_through_serde() {
307        // canonicalize(deserialize(canonicalize(e))) == canonicalize(e)
308        let e = asserted("event:1");
309        let bytes = canonical_bytes(&e).expect("canonicalize");
310        let decoded: FactEvent = serde_json::from_slice(&bytes).expect("deserialize");
311        assert_eq!(decoded, e);
312        assert_eq!(canonical_bytes(&decoded).expect("re-canonicalize"), bytes);
313    }
314
315    #[test]
316    fn distinct_events_hash_differently() {
317        let a = event_hash(&asserted("event:1"), None).unwrap();
318        let b = event_hash(&asserted("event:2"), None).unwrap();
319        assert_ne!(a, b);
320        // A SHA-256 hex digest is 64 chars.
321        assert_eq!(a.len(), 64);
322    }
323
324    #[test]
325    fn genesis_is_unambiguous_and_previous_is_validated() {
326        let e = asserted("event:1");
327        // A genesis hash (no previous) is well-defined and 64 hex chars.
328        let genesis = event_hash(&e, None).unwrap();
329        assert_eq!(genesis.len(), 64);
330        // An empty or malformed previous is rejected, not silently treated as genesis —
331        // this is what makes the leaf encoding injective.
332        assert!(matches!(
333            event_hash(&e, Some("")),
334            Err(CanonError::InvalidPreviousHash(_))
335        ));
336        assert!(matches!(
337            event_hash(&e, Some("not-hex")),
338            Err(CanonError::InvalidPreviousHash(_))
339        ));
340        // A valid predecessor links cleanly and changes the hash.
341        let chained = event_hash(&e, Some(&genesis)).unwrap();
342        assert_ne!(genesis, chained);
343    }
344
345    #[test]
346    fn the_chain_links_each_event_to_the_previous() {
347        let first = asserted("event:1");
348        let second = event(
349            "event:2",
350            FactEventKind::Superseded {
351                by: FactId::new("fact:2").expect("fact id"),
352                reason: SupersessionReason::NewerObservation,
353            },
354            None,
355        );
356
357        let unchained = event_hash(&second, None).unwrap();
358        let chained = event_hash(&second, Some(&event_hash(&first, None).unwrap())).unwrap();
359        assert_ne!(unchained, chained);
360    }
361
362    #[test]
363    fn tampering_with_an_event_breaks_the_chain_from_that_point() {
364        let events = [
365            asserted("event:1"),
366            asserted("event:2"),
367            asserted("event:3"),
368        ];
369        let original = hash_chain(&events).expect("chain");
370
371        // Tamper with the middle event only.
372        let tampered = [
373            asserted("event:1"),
374            asserted("event:CHANGED"),
375            asserted("event:3"),
376        ];
377        let recomputed = hash_chain(&tampered).expect("chain");
378
379        assert_eq!(recomputed[0], original[0]); // before the tamper: unchanged
380        assert_ne!(recomputed[1], original[1]); // the tampered event
381        assert_ne!(recomputed[2], original[2]); // and everything after cascades
382    }
383
384    #[test]
385    fn attestation_field_is_skipped_when_none_so_v1_bytes_are_stable() {
386        // The ADR 0013 compatibility rule: an unattested event must serialize byte-identically
387        // to the pre-attestation encoding — no "attestation" key at all — so every stored v1
388        // hash keeps verifying without a CANON_VERSION bump.
389        let unattested = event(
390            "event:1",
391            FactEventKind::Asserted,
392            Some(FactValue::Text("postgres".into())),
393        );
394        let bytes = canonical_bytes(&unattested).expect("canonical bytes");
395        assert!(
396            !String::from_utf8(bytes)
397                .expect("utf8")
398                .contains("attestation"),
399            "None attestation must not appear in canonical bytes"
400        );
401
402        // An attested event *does* carry the field (covered by its own hash)…
403        let mut attested = unattested.clone();
404        attested.provenance.attestation = Some(crate::model::WriteAttestation {
405            algorithm: crate::model::AttestationAlgorithm::Ed25519,
406            public_key: "aa".repeat(32),
407            signature: "bb".repeat(64),
408        });
409        assert_ne!(
410            canonical_bytes(&attested).expect("canonical bytes"),
411            canonical_bytes(&unattested).expect("canonical bytes"),
412        );
413        assert_ne!(
414            event_hash(&attested, None).expect("hash"),
415            event_hash(&unattested, None).expect("hash"),
416        );
417    }
418
419    #[test]
420    fn attestation_message_strips_the_attestation_itself() {
421        // The signed message must be derivable from the event alone and independent of the
422        // attestation it carries (the signature cannot cover itself): attested and unattested
423        // forms of the same event produce the identical message.
424        let unattested = event(
425            "event:1",
426            FactEventKind::Asserted,
427            Some(FactValue::Text("postgres".into())),
428        );
429        let mut attested = unattested.clone();
430        attested.provenance.attestation = Some(crate::model::WriteAttestation {
431            algorithm: crate::model::AttestationAlgorithm::Ed25519,
432            public_key: "aa".repeat(32),
433            signature: "bb".repeat(64),
434        });
435        let message_unattested =
436            super::attestation_message(&unattested).expect("attestation message");
437        let message_attested = super::attestation_message(&attested).expect("attestation message");
438        assert_eq!(message_unattested, message_attested);
439
440        // …but the message is bound to the event *content*: changing the value changes it.
441        let other = event(
442            "event:1",
443            FactEventKind::Asserted,
444            Some(FactValue::Text("mysql".into())),
445        );
446        assert_ne!(
447            super::attestation_message(&other).expect("attestation message"),
448            message_unattested
449        );
450
451        // Domain separation: the message is not the raw canonical bytes.
452        assert!(message_unattested.starts_with(b"dent8.event-attestation.v1\0"));
453    }
454
455    #[test]
456    fn attestation_round_trips_through_serde() {
457        let mut attested = event(
458            "event:1",
459            FactEventKind::Asserted,
460            Some(FactValue::Text("postgres".into())),
461        );
462        attested.provenance.attestation = Some(crate::model::WriteAttestation {
463            algorithm: crate::model::AttestationAlgorithm::Ed25519,
464            public_key: "aa".repeat(32),
465            signature: "bb".repeat(64),
466        });
467        let json = serde_json::to_string(&attested).expect("serialize");
468        assert!(json.contains("\"algorithm\":\"ed25519\""));
469        let back: FactEvent = serde_json::from_str(&json).expect("deserialize");
470        assert_eq!(back, attested);
471
472        // An unknown algorithm fails loudly instead of silently "verifying".
473        let forged = json.replace("\"ed25519\"", "\"none\"");
474        assert!(serde_json::from_str::<FactEvent>(&forged).is_err());
475    }
476}