Skip to main content

confium_signatif/
jcs.rs

1//! RFC 8785 JSON Canonicalization Scheme (JCS).
2//!
3//! Produces the deterministic byte string that all co-signatures and
4//! bundle signatures attest: same logical content, same bytes, for any
5//! conforming implementation. The rules implemented here:
6//!
7//! - object keys sorted by UTF-16 code unit sequence;
8//! - no insignificant whitespace;
9//! - strings serialized with the JSON minimal-escape set (`\b \t \n \f
10//!   \r` `\"` `\\` and lowercase `\u00xx` for other control characters),
11//!   all other characters emitted verbatim as UTF-8;
12//! - numbers: integers exactly; floats in shortest round-trip form
13//!   (ECMAScript number-to-string), negative zero normalized to `0`,
14//!   NaN and infinities rejected;
15//! - booleans and null as `true` / `false` / `null`.
16
17use crate::error::{SignatifError, SignatifResult};
18use serde_json::Value;
19
20/// Canonicalize a JSON value to its RFC 8785 byte string.
21///
22/// # Errors
23///
24/// Returns [`SignatifError::Encoding`] for values that cannot appear in
25/// canonical JSON (non-finite numbers).
26pub fn canonicalize(value: &Value) -> SignatifResult<String> {
27    let mut out = String::new();
28    write_value(value, &mut out)?;
29    Ok(out)
30}
31
32/// SHA-256 over the JCS canonicalization — the SIGNATIF canonical
33/// payload hash of a JSON object.
34///
35/// # Errors
36///
37/// Propagates canonicalization errors.
38pub fn canonical_hash(value: &Value) -> SignatifResult<[u8; 32]> {
39    use sha2::{Digest, Sha256};
40    let canon = canonicalize(value)?;
41    Ok(Sha256::digest(canon.as_bytes()).into())
42}
43
44fn write_value(v: &Value, out: &mut String) -> SignatifResult<()> {
45    match v {
46        Value::Null => out.push_str("null"),
47        Value::Bool(true) => out.push_str("true"),
48        Value::Bool(false) => out.push_str("false"),
49        Value::Number(n) => write_number(n, out)?,
50        Value::String(s) => write_string(s, out),
51        Value::Array(items) => {
52            out.push('[');
53            for (i, item) in items.iter().enumerate() {
54                if i > 0 {
55                    out.push(',');
56                }
57                write_value(item, out)?;
58            }
59            out.push(']');
60        }
61        Value::Object(map) => {
62            // RFC 8785 §3.2.3: lexicographic order by UTF-16 code units.
63            let mut keys: Vec<&String> = map.keys().collect();
64            keys.sort_by(|a, b| utf16_cmp(a, b));
65            out.push('{');
66            for (i, key) in keys.iter().enumerate() {
67                if i > 0 {
68                    out.push(',');
69                }
70                write_string(key, out);
71                out.push(':');
72                write_value(&map[*key], out)?;
73            }
74            out.push('}');
75        }
76    }
77    Ok(())
78}
79
80fn utf16_cmp(a: &str, b: &str) -> std::cmp::Ordering {
81    let mut ai = a.encode_utf16();
82    let mut bi = b.encode_utf16();
83    loop {
84        match (ai.next(), bi.next()) {
85            (None, None) => return std::cmp::Ordering::Equal,
86            (None, Some(_)) => return std::cmp::Ordering::Less,
87            (Some(_), None) => return std::cmp::Ordering::Greater,
88            (Some(x), Some(y)) => match x.cmp(&y) {
89                std::cmp::Ordering::Equal => continue,
90                other => return other,
91            },
92        }
93    }
94}
95
96fn write_number(n: &serde_json::Number, out: &mut String) -> SignatifResult<()> {
97    if let Some(i) = n.as_i64() {
98        out.push_str(&i.to_string());
99        return Ok(());
100    }
101    if let Some(u) = n.as_u64() {
102        out.push_str(&u.to_string());
103        return Ok(());
104    }
105    let f = n
106        .as_f64()
107        .ok_or_else(|| SignatifError::Encoding("number is not finite".into()))?;
108    if !f.is_finite() {
109        return Err(SignatifError::Encoding(
110            "NaN and Infinity are not allowed".into(),
111        ));
112    }
113    if f == 0.0 {
114        // RFC 8785 §3.2.2.3: negative zero becomes 0.
115        out.push('0');
116        return Ok(());
117    }
118    // serde_json serializes f64 with shortest round-trip (ryu), matching
119    // the ECMAScript number serialization JCS specifies.
120    out.push_str(&serde_json::to_string(&Value::from(f)).expect("finite f64 serializes"));
121    Ok(())
122}
123
124fn write_string(s: &str, out: &mut String) {
125    out.push('"');
126    for c in s.chars() {
127        match c {
128            '"' => out.push_str("\\\""),
129            '\\' => out.push_str("\\\\"),
130            '\u{08}' => out.push_str("\\b"),
131            '\u{09}' => out.push_str("\\t"),
132            '\u{0a}' => out.push_str("\\n"),
133            '\u{0c}' => out.push_str("\\f"),
134            '\u{0d}' => out.push_str("\\r"),
135            c if (c as u32) < 0x20 => {
136                out.push_str(&format!("\\u{:04x}", c as u32));
137            }
138            c => out.push(c),
139        }
140    }
141    out.push('"');
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148
149    #[test]
150    fn control_chars_escaped_lowercase_hex() {
151        assert_eq!(
152            canonicalize(&json!("\u{0007}\u{001f}")).unwrap(),
153            "\"\\u0007\\u001f\""
154        );
155    }
156
157    #[test]
158    fn verbatim_characters_pass_through() {
159        // RFC 8785: U+20AC etc. are not escaped.
160        assert_eq!(canonicalize(&json!("€ßé")).unwrap(), "\"€ßé\"");
161    }
162
163    #[test]
164    fn sorts_keys_by_utf16() {
165        let v = json!({"b": 1, "a": 2});
166        assert_eq!(canonicalize(&v).unwrap(), r#"{"a":2,"b":1}"#);
167    }
168
169    #[test]
170    fn utf16_ordering_beats_codepoint_ordering() {
171        // U+FF21 (FULLWIDTH A) sorts before U+10000 in UTF-16, and after
172        // in UTF-32. Constructing such keys checks the comparator.
173        // U+10000 is the surrogate pair D800 DE00 in UTF-16, which
174        // sorts before U+FF21 — but after it in codepoint order.
175        let v = json!({"\u{10000}": 1, "\u{FF21}": 2});
176        let c = canonicalize(&v).unwrap();
177        let surrogate_pos = c.find('\u{10000}').expect("surrogate char present");
178        let fullwidth_pos = c.find('\u{FF21}').expect("fullwidth char present");
179        assert!(surrogate_pos < fullwidth_pos, "got {c}");
180    }
181
182    #[test]
183    fn deterministic_nested_structures() {
184        let a = json!({"z": [1, 2.5, true, null], "a": {"y": "x"}});
185        let b = json!({"a": {"y": "x"}, "z": [1, 2.5, true, null]});
186        assert_eq!(canonicalize(&a).unwrap(), canonicalize(&b).unwrap());
187    }
188
189    #[test]
190    fn negative_zero_normalizes() {
191        let v: Value = serde_json::from_str("-0.0").unwrap();
192        assert_eq!(canonicalize(&v).unwrap(), "0");
193    }
194
195    #[test]
196    fn floats_shortest_round_trip() {
197        assert_eq!(canonicalize(&json!(2.5)).unwrap(), "2.5");
198        assert_eq!(canonicalize(&json!(1e30)).unwrap(), "1e+30");
199    }
200
201    #[test]
202    fn canonical_hash_is_sha256_of_canonical_bytes() {
203        use sha2::{Digest, Sha256};
204        let h = canonical_hash(&json!({"a":1})).unwrap();
205        let expect: [u8; 32] = Sha256::digest(b"{\"a\":1}").into();
206        assert_eq!(h, expect);
207    }
208}