ev/canonical.rs
1//! Canonical-JSON (RFC 8785 / JCS) + content-addressed identity.
2//! For our string-only payload, serde_json's default output (sorted BTreeMap
3//! keys, compact separators, raw non-ASCII, no `/`-escape) IS JCS — verified by
4//! the golden vectors. Liveness arrays are sorted+deduped here so set-valued
5//! fields are order-insensitive; grounds[] keeps authored order.
6use crate::tick::{Check, Tick};
7use serde_json::{json, Map, Value};
8use sha2::{Digest, Sha256};
9
10fn sorted_set(v: &[String]) -> Vec<String> {
11 let mut s: Vec<String> = v.to_vec();
12 s.sort(); // byte order == codepoint == UTF-16 order for the ASCII tokens we use
13 s.dedup();
14 s
15}
16
17fn check_value(c: &Check) -> Value {
18 match c {
19 Check::Person { reference } => json!({ "by": "person", "ref": reference }),
20 Check::Test {
21 reference,
22 verified_at_sha,
23 counter_test,
24 liveness,
25 } => {
26 // Built as a manual Map (not json!) so an absent counter_test OMITS the key entirely —
27 // mirroring the grounds[].check omit-on-None below. omit-on-None keeps both the
28 // Some-carrying goldens AND every harvested id stable; a "tidy to always-emit" would
29 // silently move every harvested id.
30 let mut o = Map::new();
31 o.insert("by".into(), Value::String("test".into()));
32 o.insert("ref".into(), Value::String(reference.clone()));
33 o.insert(
34 "verified_at_sha".into(),
35 Value::String(verified_at_sha.clone()),
36 );
37 if let Some(ct) = counter_test {
38 o.insert("counter_test".into(), Value::String(ct.clone()));
39 }
40 o.insert(
41 "liveness".into(),
42 json!({
43 "platforms": sorted_set(&liveness.platforms),
44 "triggered_by": sorted_set(&liveness.triggered_by),
45 "surfaces": sorted_set(&liveness.surfaces),
46 }),
47 );
48 Value::Object(o)
49 }
50 }
51}
52
53/// The Value containing ONLY the hashed fields (decision, observe, grounds, parent_id).
54pub fn hashed_value(t: &Tick) -> Value {
55 let grounds: Vec<Value> = t
56 .grounds
57 .iter()
58 .map(|g| {
59 // Built as a manual Map (not json!) so an absent check OMITS the key
60 // entirely — never serializes as null (design §4.8).
61 let mut o = Map::new();
62 o.insert("claim".into(), Value::String(g.claim.clone()));
63 o.insert("supports".into(), Value::String(g.supports.clone()));
64 if let Some(c) = &g.check {
65 o.insert("check".into(), check_value(c));
66 }
67 Value::Object(o)
68 })
69 .collect();
70 json!({
71 "decision": t.decision,
72 "observe": t.observe,
73 "grounds": grounds,
74 "parent_id": t.parent_id,
75 })
76}
77
78/// Canonical bytes for our **string-only** hashed `Value`. serde_json's compact
79/// output over a sorted-key `Value` equals RFC-8785/JCS *only* because the payload
80/// contains no numbers/bools/nulls (JCS number canonicalization is not applied).
81/// Do not reuse on a number-bearing `Value` (see module header).
82pub fn canonical_json(v: &Value) -> String {
83 serde_json::to_string(v).expect("Value is serializable")
84}
85
86/// id = first 12 hex of SHA-256 over the canonical-JSON of the hashed fields.
87pub fn compute_id(t: &Tick) -> String {
88 let canon = canonical_json(&hashed_value(t));
89 let full = hex::encode(Sha256::digest(canon.as_bytes()));
90 full[..12].to_string()
91}