1use sha2::{Digest, Sha256};
40
41use crate::model::ClaimEvent;
42
43pub const CANON_VERSION: u8 = 1;
53
54const LEAF_PREFIX: u8 = 0x00;
57
58const DIGEST_LEN: usize = 32;
60
61pub fn canonical_bytes(event: &ClaimEvent) -> Result<Vec<u8>, CanonError> {
65 let value = serde_json::to_value(event).map_err(CanonError::Serialize)?;
68 serde_json::to_vec(&value).map_err(CanonError::Serialize)
69}
70
71const ATTESTATION_DOMAIN: &[u8] = b"dent8.event-attestation.v1\0";
75
76pub 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
93pub 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
102fn 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#[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
131pub 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#[derive(Debug)]
148pub enum CanonError {
149 Serialize(serde_json::Error),
151 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 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 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 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 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 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 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 let genesis = event_hash(&e, None).unwrap();
323 assert_eq!(genesis.len(), 64);
324 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 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 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]); assert_ne!(recomputed[1], original[1]); assert_ne!(recomputed[2], original[2]); }
377
378 #[test]
379 fn attestation_field_is_skipped_when_none_so_v1_bytes_are_stable() {
380 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 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 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 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 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 let forged = json.replace("\"ed25519\"", "\"none\"");
468 assert!(serde_json::from_str::<ClaimEvent>(&forged).is_err());
469 }
470}