use sha2::{Digest, Sha256};
use crate::model::FactEvent;
pub const CANON_VERSION: u8 = 2;
const LEAF_PREFIX: u8 = 0x00;
const DIGEST_LEN: usize = 32;
pub fn canonical_bytes(event: &FactEvent) -> Result<Vec<u8>, CanonError> {
let value = serde_json::to_value(event).map_err(CanonError::Serialize)?;
serde_json::to_vec(&value).map_err(CanonError::Serialize)
}
const ATTESTATION_DOMAIN: &[u8] = b"dent8.event-attestation.v1\0";
pub fn attestation_message(event: &FactEvent) -> Result<Vec<u8>, CanonError> {
let mut unattested = event.clone();
unattested.provenance.attestation = None;
let body = canonical_bytes(&unattested)?;
let mut message = Vec::with_capacity(ATTESTATION_DOMAIN.len() + 8 + body.len());
message.extend_from_slice(ATTESTATION_DOMAIN);
message.extend_from_slice(&(body.len() as u64).to_be_bytes());
message.extend_from_slice(&body);
Ok(message)
}
pub fn event_hash(event: &FactEvent, previous: Option<&str>) -> Result<String, CanonError> {
let canonical = canonical_bytes(event)?;
let previous = previous.map(decode_digest).transpose()?;
Ok(hash_leaf(&canonical, previous.as_ref()))
}
fn decode_digest(hex_str: &str) -> Result<[u8; DIGEST_LEN], CanonError> {
let mut out = [0u8; DIGEST_LEN];
hex::decode_to_slice(hex_str, &mut out)
.map_err(|_| CanonError::InvalidPreviousHash(hex_str.to_string()))?;
Ok(out)
}
#[must_use]
fn hash_leaf(canonical: &[u8], previous: Option<&[u8; DIGEST_LEN]>) -> String {
let mut hasher = Sha256::new();
hasher.update([LEAF_PREFIX, CANON_VERSION]);
hasher.update((canonical.len() as u64).to_be_bytes());
hasher.update(canonical);
match previous {
None => hasher.update([0u8]),
Some(previous) => {
hasher.update([1u8]);
hasher.update(previous);
}
}
hex::encode(hasher.finalize())
}
pub fn hash_chain(events: &[FactEvent]) -> Result<Vec<String>, CanonError> {
let mut hashes = Vec::with_capacity(events.len());
let mut previous: Option<String> = None;
for event in events {
let hash = event_hash(event, previous.as_deref())?;
previous = Some(hash.clone());
hashes.push(hash);
}
Ok(hashes)
}
#[derive(Debug)]
pub enum CanonError {
Serialize(serde_json::Error),
InvalidPreviousHash(String),
}
impl std::fmt::Display for CanonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Serialize(error) => write!(f, "canonicalization failed: {error}"),
Self::InvalidPreviousHash(value) => {
write!(f, "previous hash is not a 64-char hex digest: {value:?}")
}
}
}
}
impl std::error::Error for CanonError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Serialize(error) => Some(error),
Self::InvalidPreviousHash(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{CanonError, canonical_bytes, event_hash, hash_chain};
use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
use crate::model::{
Authority, AuthorityLevel, Confidence, Evidence, EvidenceKind, FactEvent, FactEventKind,
FactValue, Predicate, Provenance, Subject, SupersessionReason, Ttl,
};
fn event(event_id: &str, kind: FactEventKind, value: Option<FactValue>) -> FactEvent {
FactEvent {
event_id: FactEventId::new(event_id).expect("event id"),
fact_id: FactId::new("fact:1").expect("fact id"),
kind,
subject: Subject::new("repo", "dent8").expect("subject"),
predicate: Predicate::new("uses_database").expect("predicate"),
value,
confidence: Confidence::from_millis(900).expect("confidence"),
authority: Authority {
level: AuthorityLevel::High,
issuer: None,
scope: None,
},
ttl: Ttl::Never,
provenance: Provenance {
source: SourceId::new("source:test").expect("source"),
actor: ActorId::new("actor:test").expect("actor"),
tool: None,
run_id: None,
input_digest: None,
recorded_at: TimestampMillis::from_unix_millis(1),
attestation: None,
},
evidence: vec![Evidence {
id: EvidenceId::new("evidence:1").expect("evidence id"),
kind: EvidenceKind::UserStatement,
locator: "x".to_string(),
digest: None,
summary: None,
}],
observed_at: None,
valid_from: None,
valid_to: None,
}
}
fn asserted(event_id: &str) -> FactEvent {
event(
event_id,
FactEventKind::Asserted,
Some(FactValue::Text("postgres".to_string())),
)
}
#[test]
fn canonicalization_is_deterministic() {
let e = asserted("event:1");
assert_eq!(canonical_bytes(&e).unwrap(), canonical_bytes(&e).unwrap());
}
#[test]
fn canonicalization_is_key_order_independent() {
let e = asserted("event:1");
let declaration_order = serde_json::to_vec(&e).expect("serialize");
let canonical = canonical_bytes(&e).expect("canonicalize");
assert_ne!(
declaration_order, canonical,
"to_value did not reorder keys"
);
let reparsed: FactEvent = serde_json::from_slice(&declaration_order).expect("deserialize");
assert_eq!(
canonical_bytes(&reparsed).expect("re-canonicalize"),
canonical
);
}
#[test]
fn embedded_json_is_canonicalized_so_equal_values_hash_equally() {
let json_event = |raw: &str| {
event(
"event:1",
FactEventKind::Asserted,
Some(FactValue::json(raw).expect("valid json")),
)
};
let a = json_event("{ \"b\": 2, \"a\": 1 }");
let b = json_event("{\"a\":1,\n \"b\":2}");
assert_eq!(canonical_bytes(&a).unwrap(), canonical_bytes(&b).unwrap());
assert_eq!(event_hash(&a, None).unwrap(), event_hash(&b, None).unwrap());
let c = json_event(r#"{"a": 2, "b": 1}"#);
assert_ne!(event_hash(&a, None).unwrap(), event_hash(&c, None).unwrap());
}
#[test]
fn a_float_json_event_survives_a_serde_round_trip_unchanged() {
let e = event(
"event:1",
FactEventKind::Asserted,
Some(FactValue::json(r#"{"ratio": 13e300, "p": 0.1}"#).expect("json")),
);
let original = canonical_bytes(&e).expect("canonicalize");
let reloaded: FactEvent = serde_json::from_slice(&original).expect("deserialize");
assert_eq!(
canonical_bytes(&reloaded).expect("re-canonicalize"),
original
);
assert_eq!(
event_hash(&reloaded, None).unwrap(),
event_hash(&e, None).unwrap()
);
}
#[test]
fn canonicalization_round_trips_through_serde() {
let e = asserted("event:1");
let bytes = canonical_bytes(&e).expect("canonicalize");
let decoded: FactEvent = serde_json::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, e);
assert_eq!(canonical_bytes(&decoded).expect("re-canonicalize"), bytes);
}
#[test]
fn distinct_events_hash_differently() {
let a = event_hash(&asserted("event:1"), None).unwrap();
let b = event_hash(&asserted("event:2"), None).unwrap();
assert_ne!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn genesis_is_unambiguous_and_previous_is_validated() {
let e = asserted("event:1");
let genesis = event_hash(&e, None).unwrap();
assert_eq!(genesis.len(), 64);
assert!(matches!(
event_hash(&e, Some("")),
Err(CanonError::InvalidPreviousHash(_))
));
assert!(matches!(
event_hash(&e, Some("not-hex")),
Err(CanonError::InvalidPreviousHash(_))
));
let chained = event_hash(&e, Some(&genesis)).unwrap();
assert_ne!(genesis, chained);
}
#[test]
fn the_chain_links_each_event_to_the_previous() {
let first = asserted("event:1");
let second = event(
"event:2",
FactEventKind::Superseded {
by: FactId::new("fact:2").expect("fact id"),
reason: SupersessionReason::NewerObservation,
},
None,
);
let unchained = event_hash(&second, None).unwrap();
let chained = event_hash(&second, Some(&event_hash(&first, None).unwrap())).unwrap();
assert_ne!(unchained, chained);
}
#[test]
fn tampering_with_an_event_breaks_the_chain_from_that_point() {
let events = [
asserted("event:1"),
asserted("event:2"),
asserted("event:3"),
];
let original = hash_chain(&events).expect("chain");
let tampered = [
asserted("event:1"),
asserted("event:CHANGED"),
asserted("event:3"),
];
let recomputed = hash_chain(&tampered).expect("chain");
assert_eq!(recomputed[0], original[0]); assert_ne!(recomputed[1], original[1]); assert_ne!(recomputed[2], original[2]); }
#[test]
fn attestation_field_is_skipped_when_none_so_v1_bytes_are_stable() {
let unattested = event(
"event:1",
FactEventKind::Asserted,
Some(FactValue::Text("postgres".into())),
);
let bytes = canonical_bytes(&unattested).expect("canonical bytes");
assert!(
!String::from_utf8(bytes)
.expect("utf8")
.contains("attestation"),
"None attestation must not appear in canonical bytes"
);
let mut attested = unattested.clone();
attested.provenance.attestation = Some(crate::model::WriteAttestation {
algorithm: crate::model::AttestationAlgorithm::Ed25519,
public_key: "aa".repeat(32),
signature: "bb".repeat(64),
});
assert_ne!(
canonical_bytes(&attested).expect("canonical bytes"),
canonical_bytes(&unattested).expect("canonical bytes"),
);
assert_ne!(
event_hash(&attested, None).expect("hash"),
event_hash(&unattested, None).expect("hash"),
);
}
#[test]
fn attestation_message_strips_the_attestation_itself() {
let unattested = event(
"event:1",
FactEventKind::Asserted,
Some(FactValue::Text("postgres".into())),
);
let mut attested = unattested.clone();
attested.provenance.attestation = Some(crate::model::WriteAttestation {
algorithm: crate::model::AttestationAlgorithm::Ed25519,
public_key: "aa".repeat(32),
signature: "bb".repeat(64),
});
let message_unattested =
super::attestation_message(&unattested).expect("attestation message");
let message_attested = super::attestation_message(&attested).expect("attestation message");
assert_eq!(message_unattested, message_attested);
let other = event(
"event:1",
FactEventKind::Asserted,
Some(FactValue::Text("mysql".into())),
);
assert_ne!(
super::attestation_message(&other).expect("attestation message"),
message_unattested
);
assert!(message_unattested.starts_with(b"dent8.event-attestation.v1\0"));
}
#[test]
fn attestation_round_trips_through_serde() {
let mut attested = event(
"event:1",
FactEventKind::Asserted,
Some(FactValue::Text("postgres".into())),
);
attested.provenance.attestation = Some(crate::model::WriteAttestation {
algorithm: crate::model::AttestationAlgorithm::Ed25519,
public_key: "aa".repeat(32),
signature: "bb".repeat(64),
});
let json = serde_json::to_string(&attested).expect("serialize");
assert!(json.contains("\"algorithm\":\"ed25519\""));
let back: FactEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, attested);
let forged = json.replace("\"ed25519\"", "\"none\"");
assert!(serde_json::from_str::<FactEvent>(&forged).is_err());
}
}