Skip to main content

continuity_receipt/
canon.rs

1//! JCS-subset canonicalization for Continuity Receipts (v0).
2//!
3//! Mirrors `continuity_receipt/canon.py`: deterministic bytes for signing and
4//! hashing with sorted keys (Unicode code point order; UTF-8 byte order for
5//! valid strings is identical), compact separators, UTF-8 output, and
6//! integers/strings/bools/null only. Floats are rejected.
7
8use std::fmt;
9
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12
13const HEX: &[u8; 16] = b"0123456789abcdef";
14
15/// Canonicalization failure (float encountered, invalid commitment salt, ...).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct CanonError(pub String);
18
19impl fmt::Display for CanonError {
20    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21        formatter.write_str(&self.0)
22    }
23}
24
25impl std::error::Error for CanonError {}
26
27/// Deterministic bytes for signing and hashing (pinned JCS subset).
28pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>, CanonError> {
29    let mut out = Vec::new();
30    write_value(value, &mut out)?;
31    Ok(out)
32}
33
34fn write_value(value: &Value, out: &mut Vec<u8>) -> Result<(), CanonError> {
35    match value {
36        Value::Null => out.extend_from_slice(b"null"),
37        Value::Bool(true) => out.extend_from_slice(b"true"),
38        Value::Bool(false) => out.extend_from_slice(b"false"),
39        Value::Number(number) => {
40            let token = number.to_string();
41            if token.contains('.') || token.contains('e') || token.contains('E') {
42                return Err(CanonError(
43                    "floats are not allowed in continuity receipts (v0)".to_string(),
44                ));
45            }
46            out.extend_from_slice(token.as_bytes());
47        }
48        Value::String(text) => write_string(text, out),
49        Value::Array(items) => {
50            out.push(b'[');
51            for (index, item) in items.iter().enumerate() {
52                if index > 0 {
53                    out.push(b',');
54                }
55                write_value(item, out)?;
56            }
57            out.push(b']');
58        }
59        Value::Object(map) => {
60            let mut keys: Vec<&String> = map.keys().collect();
61            keys.sort_unstable();
62            out.push(b'{');
63            for (index, key) in keys.iter().enumerate() {
64                if index > 0 {
65                    out.push(b',');
66                }
67                write_string(key, out);
68                out.push(b':');
69                if let Some(item) = map.get(key.as_str()) {
70                    write_value(item, out)?;
71                }
72            }
73            out.push(b'}');
74        }
75    }
76    Ok(())
77}
78
79/// JSON string escaping matching Python `json.dumps(..., ensure_ascii=False)`.
80fn write_string(text: &str, out: &mut Vec<u8>) {
81    out.push(b'"');
82    for character in text.chars() {
83        match character {
84            '"' => out.extend_from_slice(b"\\\""),
85            '\\' => out.extend_from_slice(b"\\\\"),
86            '\u{08}' => out.extend_from_slice(b"\\b"),
87            '\u{0c}' => out.extend_from_slice(b"\\f"),
88            '\n' => out.extend_from_slice(b"\\n"),
89            '\r' => out.extend_from_slice(b"\\r"),
90            '\t' => out.extend_from_slice(b"\\t"),
91            c if (c as u32) < 0x20 => {
92                let escape = format!("\\u{:04x}", c as u32);
93                out.extend_from_slice(escape.as_bytes());
94            }
95            c => {
96                let mut buffer = [0u8; 4];
97                out.extend_from_slice(c.encode_utf8(&mut buffer).as_bytes());
98            }
99        }
100    }
101    out.push(b'"');
102}
103
104/// `sha256:<lowercase hex>` hash form (`sha256_prefixed` in Python).
105pub fn sha256_prefixed(data: &[u8]) -> String {
106    let digest = Sha256::digest(data);
107    let mut out = String::with_capacity(7 + 64);
108    out.push_str("sha256:");
109    for byte in digest {
110        out.push(HEX[(byte >> 4) as usize] as char);
111        out.push(HEX[(byte & 0x0f) as usize] as char);
112    }
113    out
114}
115
116/// v0 reference commitment: `sha256(salt_bytes || 0x7c || JCS(value))`.
117///
118/// `salt_hex` is the hex-encoded random salt. The delimiter `0x7c` is `|`.
119pub fn commit_field(salt_hex: &str, value: &Value) -> Result<String, CanonError> {
120    let salt = hex_decode(salt_hex)
121        .ok_or_else(|| CanonError(format!("invalid salt hex: {salt_hex:?}")))?;
122    let mut data = salt;
123    data.push(0x7c);
124    data.extend_from_slice(&canonical_bytes(value)?);
125    Ok(sha256_prefixed(&data))
126}
127
128fn hex_decode(text: &str) -> Option<Vec<u8>> {
129    let bytes = text.as_bytes();
130    if !bytes.len().is_multiple_of(2) {
131        return None;
132    }
133    let mut out = Vec::with_capacity(bytes.len() / 2);
134    let mut index = 0;
135    while index < bytes.len() {
136        let high = hex_value(bytes[index])?;
137        let low = hex_value(bytes[index + 1])?;
138        out.push((high << 4) | low);
139        index += 2;
140    }
141    Some(out)
142}
143
144fn hex_value(byte: u8) -> Option<u8> {
145    match byte {
146        b'0'..=b'9' => Some(byte - b'0'),
147        b'a'..=b'f' => Some(byte - b'a' + 10),
148        b'A'..=b'F' => Some(byte - b'A' + 10),
149        _ => None,
150    }
151}