archy/core/archipelago/src/trust/canonical.rs
archipelago 0fef808671 wip(trust): park agent's signed-manifest module + release-root key off main
Moved here so main stays clean for the v1.7.98 release. Contains the trust/
module (canonical.rs, did.rs, signed_doc.rs) + seed::derive_release_root_ed25519.
Not wired into the build yet. Continue this work on this branch.
2026-06-16 11:22:24 -04:00

88 lines
3.2 KiB
Rust

//! Canonical JSON for signing — a pragmatic subset of RFC 8785 (JCS).
//!
//! Signatures are computed over a *byte-exact* serialization so that a verifier
//! reproduces the same preimage the signer hashed. We guarantee:
//!
//! * object keys recursively sorted (lexicographic by Rust `str` ordering,
//! i.e. Unicode scalar value — matches JCS for the ASCII keys we use),
//! * no insignificant whitespace,
//! * arrays preserved in order.
//!
//! We do NOT implement JCS number canonicalization (ECMAScript shortest-form).
//! Archipelago manifests/catalogs carry only integers, strings, bools, arrays
//! and objects, for which `serde_json`'s output is already unambiguous. If a
//! float ever enters a signed document this must be hardened (or rejected).
//! `contains_float()` lets callers enforce that invariant.
use serde_json::Value;
/// Serialize `value` to canonical JSON bytes (sorted keys, compact).
///
/// Rebuilds every object through a `BTreeMap` so the result is independent of
/// the `serde_json/preserve_order` feature being toggled on anywhere in the
/// dependency graph.
pub fn to_canonical_bytes(value: &Value) -> Vec<u8> {
let canonical = canonicalize(value);
// serde_json never fails to serialize a Value it produced.
serde_json::to_vec(&canonical).expect("canonical JSON serialization")
}
/// Reject documents that contain a float anywhere — they are not safely
/// canonicalizable under this implementation.
pub fn contains_float(value: &Value) -> bool {
match value {
Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(),
Value::Array(items) => items.iter().any(contains_float),
Value::Object(map) => map.values().any(contains_float),
_ => false,
}
}
fn canonicalize(value: &Value) -> Value {
match value {
Value::Object(map) => {
// BTreeMap gives deterministic key ordering on serialize.
let sorted: std::collections::BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), canonicalize(v)))
.collect();
serde_json::to_value(sorted).expect("canonical object")
}
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn key_order_does_not_change_bytes() {
let a = json!({"b": 1, "a": 2, "c": {"z": 1, "y": 2}});
let b = json!({"c": {"y": 2, "z": 1}, "a": 2, "b": 1});
assert_eq!(to_canonical_bytes(&a), to_canonical_bytes(&b));
}
#[test]
fn output_is_sorted_and_compact() {
let v = json!({"b": 1, "a": [3, 2, 1]});
assert_eq!(to_canonical_bytes(&v), br#"{"a":[3,2,1],"b":1}"#.to_vec());
}
#[test]
fn array_order_is_preserved() {
let a = json!([1, 2, 3]);
let b = json!([3, 2, 1]);
assert_ne!(to_canonical_bytes(&a), to_canonical_bytes(&b));
}
#[test]
fn detects_floats() {
assert!(contains_float(&json!({"x": 1.5})));
assert!(contains_float(&json!([1, 2, 0.1])));
assert!(!contains_float(&json!({"x": 12345, "y": "s", "z": [1, 2]})));
}
}