Skip to main content

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}