Skip to main content

dent8_core/
hash.rs

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