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            valid_to: None,
218        }
219    }
220
221    fn asserted(event_id: &str) -> ClaimEvent {
222        event(
223            event_id,
224            ClaimEventKind::Asserted,
225            Some(ClaimValue::Text("postgres".to_string())),
226        )
227    }
228
229    #[test]
230    fn canonicalization_is_deterministic() {
231        let e = asserted("event:1");
232        assert_eq!(canonical_bytes(&e).unwrap(), canonical_bytes(&e).unwrap());
233    }
234
235    #[test]
236    fn canonicalization_is_key_order_independent() {
237        // `to_vec(&event)` serializes struct fields in DECLARATION order (event_id
238        // first), which is not alphabetical; `canonical_bytes` sorts keys. The two must
239        // therefore differ — and a differently-ordered document must canonicalize to
240        // the same bytes. The inequality also fails loudly if serde_json's
241        // `preserve_order` feature is ever unified ON, breaking determinism.
242        let e = asserted("event:1");
243        let declaration_order = serde_json::to_vec(&e).expect("serialize");
244        let canonical = canonical_bytes(&e).expect("canonicalize");
245        assert_ne!(
246            declaration_order, canonical,
247            "to_value did not reorder keys"
248        );
249
250        let reparsed: ClaimEvent = serde_json::from_slice(&declaration_order).expect("deserialize");
251        assert_eq!(
252            canonical_bytes(&reparsed).expect("re-canonicalize"),
253            canonical
254        );
255    }
256
257    #[test]
258    fn embedded_json_is_canonicalized_so_equal_values_hash_equally() {
259        let json_event = |raw: &str| {
260            event(
261                "event:1",
262                ClaimEventKind::Asserted,
263                Some(ClaimValue::json(raw).expect("valid json")),
264            )
265        };
266        // Same JSON, different key order *and* whitespace.
267        let a = json_event("{ \"b\": 2, \"a\": 1 }");
268        let b = json_event("{\"a\":1,\n  \"b\":2}");
269        assert_eq!(canonical_bytes(&a).unwrap(), canonical_bytes(&b).unwrap());
270        assert_eq!(event_hash(&a, None).unwrap(), event_hash(&b, None).unwrap());
271
272        // A genuinely different value must still hash differently.
273        let c = json_event(r#"{"a": 2, "b": 1}"#);
274        assert_ne!(event_hash(&a, None).unwrap(), event_hash(&c, None).unwrap());
275    }
276
277    #[test]
278    fn a_float_json_event_survives_a_serde_round_trip_unchanged() {
279        // Regression for the float-idempotency bug: the re-canonicalizing Deserialize must
280        // not alter a legitimately-written float value, or replay/reload would false-alarm
281        // the hash chain. `13e300` is one of the ~10% of f64 values that drifts without the
282        // `float_roundtrip` feature.
283        let e = event(
284            "event:1",
285            ClaimEventKind::Asserted,
286            Some(ClaimValue::json(r#"{"ratio": 13e300, "p": 0.1}"#).expect("json")),
287        );
288        let original = canonical_bytes(&e).expect("canonicalize");
289        let reloaded: ClaimEvent = serde_json::from_slice(&original).expect("deserialize");
290        assert_eq!(
291            canonical_bytes(&reloaded).expect("re-canonicalize"),
292            original
293        );
294        assert_eq!(
295            event_hash(&reloaded, None).unwrap(),
296            event_hash(&e, None).unwrap()
297        );
298    }
299
300    #[test]
301    fn canonicalization_round_trips_through_serde() {
302        // canonicalize(deserialize(canonicalize(e))) == canonicalize(e)
303        let e = asserted("event:1");
304        let bytes = canonical_bytes(&e).expect("canonicalize");
305        let decoded: ClaimEvent = serde_json::from_slice(&bytes).expect("deserialize");
306        assert_eq!(decoded, e);
307        assert_eq!(canonical_bytes(&decoded).expect("re-canonicalize"), bytes);
308    }
309
310    #[test]
311    fn distinct_events_hash_differently() {
312        let a = event_hash(&asserted("event:1"), None).unwrap();
313        let b = event_hash(&asserted("event:2"), None).unwrap();
314        assert_ne!(a, b);
315        // A SHA-256 hex digest is 64 chars.
316        assert_eq!(a.len(), 64);
317    }
318
319    #[test]
320    fn genesis_is_unambiguous_and_previous_is_validated() {
321        let e = asserted("event:1");
322        // A genesis hash (no previous) is well-defined and 64 hex chars.
323        let genesis = event_hash(&e, None).unwrap();
324        assert_eq!(genesis.len(), 64);
325        // An empty or malformed previous is rejected, not silently treated as genesis —
326        // this is what makes the leaf encoding injective.
327        assert!(matches!(
328            event_hash(&e, Some("")),
329            Err(CanonError::InvalidPreviousHash(_))
330        ));
331        assert!(matches!(
332            event_hash(&e, Some("not-hex")),
333            Err(CanonError::InvalidPreviousHash(_))
334        ));
335        // A valid predecessor links cleanly and changes the hash.
336        let chained = event_hash(&e, Some(&genesis)).unwrap();
337        assert_ne!(genesis, chained);
338    }
339
340    #[test]
341    fn the_chain_links_each_event_to_the_previous() {
342        let first = asserted("event:1");
343        let second = event(
344            "event:2",
345            ClaimEventKind::Superseded {
346                by: ClaimId::new("claim:2").expect("claim id"),
347                reason: SupersessionReason::NewerObservation,
348            },
349            None,
350        );
351
352        let unchained = event_hash(&second, None).unwrap();
353        let chained = event_hash(&second, Some(&event_hash(&first, None).unwrap())).unwrap();
354        assert_ne!(unchained, chained);
355    }
356
357    #[test]
358    fn tampering_with_an_event_breaks_the_chain_from_that_point() {
359        let events = [
360            asserted("event:1"),
361            asserted("event:2"),
362            asserted("event:3"),
363        ];
364        let original = hash_chain(&events).expect("chain");
365
366        // Tamper with the middle event only.
367        let tampered = [
368            asserted("event:1"),
369            asserted("event:CHANGED"),
370            asserted("event:3"),
371        ];
372        let recomputed = hash_chain(&tampered).expect("chain");
373
374        assert_eq!(recomputed[0], original[0]); // before the tamper: unchanged
375        assert_ne!(recomputed[1], original[1]); // the tampered event
376        assert_ne!(recomputed[2], original[2]); // and everything after cascades
377    }
378
379    #[test]
380    fn attestation_field_is_skipped_when_none_so_v1_bytes_are_stable() {
381        // The ADR 0013 compatibility rule: an unattested event must serialize byte-identically
382        // to the pre-attestation encoding — no "attestation" key at all — so every stored v1
383        // hash keeps verifying without a CANON_VERSION bump.
384        let unattested = event(
385            "event:1",
386            ClaimEventKind::Asserted,
387            Some(ClaimValue::Text("postgres".into())),
388        );
389        let bytes = canonical_bytes(&unattested).expect("canonical bytes");
390        assert!(
391            !String::from_utf8(bytes)
392                .expect("utf8")
393                .contains("attestation"),
394            "None attestation must not appear in canonical bytes"
395        );
396
397        // An attested event *does* carry the field (covered by its own hash)…
398        let mut attested = unattested.clone();
399        attested.provenance.attestation = Some(crate::model::WriteAttestation {
400            algorithm: crate::model::AttestationAlgorithm::Ed25519,
401            public_key: "aa".repeat(32),
402            signature: "bb".repeat(64),
403        });
404        assert_ne!(
405            canonical_bytes(&attested).expect("canonical bytes"),
406            canonical_bytes(&unattested).expect("canonical bytes"),
407        );
408        assert_ne!(
409            event_hash(&attested, None).expect("hash"),
410            event_hash(&unattested, None).expect("hash"),
411        );
412    }
413
414    #[test]
415    fn attestation_message_strips_the_attestation_itself() {
416        // The signed message must be derivable from the event alone and independent of the
417        // attestation it carries (the signature cannot cover itself): attested and unattested
418        // forms of the same event produce the identical message.
419        let unattested = event(
420            "event:1",
421            ClaimEventKind::Asserted,
422            Some(ClaimValue::Text("postgres".into())),
423        );
424        let mut attested = unattested.clone();
425        attested.provenance.attestation = Some(crate::model::WriteAttestation {
426            algorithm: crate::model::AttestationAlgorithm::Ed25519,
427            public_key: "aa".repeat(32),
428            signature: "bb".repeat(64),
429        });
430        let message_unattested =
431            super::attestation_message(&unattested).expect("attestation message");
432        let message_attested = super::attestation_message(&attested).expect("attestation message");
433        assert_eq!(message_unattested, message_attested);
434
435        // …but the message is bound to the event *content*: changing the value changes it.
436        let other = event(
437            "event:1",
438            ClaimEventKind::Asserted,
439            Some(ClaimValue::Text("mysql".into())),
440        );
441        assert_ne!(
442            super::attestation_message(&other).expect("attestation message"),
443            message_unattested
444        );
445
446        // Domain separation: the message is not the raw canonical bytes.
447        assert!(message_unattested.starts_with(b"dent8.event-attestation.v1\0"));
448    }
449
450    #[test]
451    fn attestation_round_trips_through_serde() {
452        let mut attested = event(
453            "event:1",
454            ClaimEventKind::Asserted,
455            Some(ClaimValue::Text("postgres".into())),
456        );
457        attested.provenance.attestation = Some(crate::model::WriteAttestation {
458            algorithm: crate::model::AttestationAlgorithm::Ed25519,
459            public_key: "aa".repeat(32),
460            signature: "bb".repeat(64),
461        });
462        let json = serde_json::to_string(&attested).expect("serialize");
463        assert!(json.contains("\"algorithm\":\"ed25519\""));
464        let back: ClaimEvent = serde_json::from_str(&json).expect("deserialize");
465        assert_eq!(back, attested);
466
467        // An unknown algorithm fails loudly instead of silently "verifying".
468        let forged = json.replace("\"ed25519\"", "\"none\"");
469        assert!(serde_json::from_str::<ClaimEvent>(&forged).is_err());
470    }
471}