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 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 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 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 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 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 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 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 let genesis = event_hash(&e, None).unwrap();
324 assert_eq!(genesis.len(), 64);
325 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 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 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]); assert_ne!(recomputed[1], original[1]); assert_ne!(recomputed[2], original[2]); }
378
379 #[test]
380 fn attestation_field_is_skipped_when_none_so_v1_bytes_are_stable() {
381 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 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 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 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 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 let forged = json.replace("\"ed25519\"", "\"none\"");
469 assert!(serde_json::from_str::<ClaimEvent>(&forged).is_err());
470 }
471}