Canonical JSON: numbers as ECMAScript writes them (jouleclaw-jcs), and the integer range (sandbox-attest) #313
7 changed files with 3574 additions and 40 deletions
1
jouleclaw/jouleclaw-rs/Cargo.lock
generated
1
jouleclaw/jouleclaw-rs/Cargo.lock
generated
|
|
@ -4022,7 +4022,6 @@ name = "jouleclaw-jcs"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"ryu",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,3 @@ serde = { workspace = true }
|
||||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
blake3 = { workspace = true }
|
blake3 = { workspace = true }
|
||||||
ryu = "1.0"
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,13 @@
|
||||||
//!
|
//!
|
||||||
//! ## Algorithm (per RFC 8785)
|
//! ## Algorithm (per RFC 8785)
|
||||||
//!
|
//!
|
||||||
//! 1. **Numbers** — finite floats serialized per ECMA-262 ToString
|
//! 1. **Numbers** — every number is the IEEE-754 double it reads
|
||||||
//! (shortest round-trip via `ryu`); integers within safe range
|
//! as, written as ECMA-262's Number::toString writes it: the
|
||||||
//! use plain integer formatting; non-finite floats are an error.
|
//! fewest digits that read back as the double (the nearest of
|
||||||
|
//! those, a tie going to the even), in plain decimals from 10⁻⁶
|
||||||
|
//! up to 10²¹ and in exponent form outside. An integer past
|
||||||
|
//! ±(2⁵³ − 1), which a double cannot hold exactly, has no
|
||||||
|
//! canonical form and is an error, as is a non-finite number.
|
||||||
//! 2. **Strings** — escape `"`, `\`, control chars (with shortcuts
|
//! 2. **Strings** — escape `"`, `\`, control chars (with shortcuts
|
||||||
//! for `\b \t \n \f \r`), `\u00XX` for other U+0000–U+001F;
|
//! for `\b \t \n \f \r`), `\u00XX` for other U+0000–U+001F;
|
||||||
//! everything else emitted as UTF-8.
|
//! everything else emitted as UTF-8.
|
||||||
|
|
@ -45,9 +49,10 @@ pub enum JcsError {
|
||||||
/// these values.
|
/// these values.
|
||||||
#[error("non-finite number in JCS input")]
|
#[error("non-finite number in JCS input")]
|
||||||
NonFiniteNumber,
|
NonFiniteNumber,
|
||||||
/// A number with no representable form. Should never happen
|
/// An integer past ±(2⁵³ − 1): a double cannot hold it exactly,
|
||||||
/// with `serde_json::Number` but covers the unreachable arm.
|
/// so two different integers would share one canonical text
|
||||||
#[error("unrepresentable number")]
|
/// (I-JSON, RFC 7493 §2.2).
|
||||||
|
#[error("unrepresentable number: an integer past ±(2^53 − 1)")]
|
||||||
UnrepresentableNumber,
|
UnrepresentableNumber,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,34 +124,125 @@ fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> Result<(), JcsError>
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_number(n: &serde_json::Number, out: &mut Vec<u8>) -> Result<(), JcsError> {
|
fn write_number(n: &serde_json::Number, out: &mut Vec<u8>) -> Result<(), JcsError> {
|
||||||
if let Some(i) = n.as_i64() {
|
const SAFE: u64 = (1 << 53) - 1;
|
||||||
// Integer in safe range — direct formatting.
|
let x = if let Some(i) = n.as_i64() {
|
||||||
let s = format!("{}", i);
|
if i.unsigned_abs() > SAFE {
|
||||||
out.extend_from_slice(s.as_bytes());
|
return Err(JcsError::UnrepresentableNumber);
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(u) = n.as_u64() {
|
|
||||||
let s = format!("{}", u);
|
|
||||||
out.extend_from_slice(s.as_bytes());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(f) = n.as_f64() {
|
|
||||||
if !f.is_finite() {
|
|
||||||
return Err(JcsError::NonFiniteNumber);
|
|
||||||
}
|
}
|
||||||
// Integer-valued floats serialize as integers (RFC 8785
|
i as f64
|
||||||
// / ECMA-262 ToString); other floats use ryu shortest
|
} else if let Some(u) = n.as_u64() {
|
||||||
// round-trip.
|
if u > SAFE {
|
||||||
if f.fract() == 0.0 && f.abs() < 1e16 {
|
return Err(JcsError::UnrepresentableNumber);
|
||||||
let s = format!("{}", f as i64);
|
|
||||||
out.extend_from_slice(s.as_bytes());
|
|
||||||
} else {
|
|
||||||
let mut buf = ryu::Buffer::new();
|
|
||||||
out.extend_from_slice(buf.format(f).as_bytes());
|
|
||||||
}
|
}
|
||||||
return Ok(());
|
u as f64
|
||||||
|
} else {
|
||||||
|
n.as_f64().ok_or(JcsError::UnrepresentableNumber)?
|
||||||
|
};
|
||||||
|
if !x.is_finite() {
|
||||||
|
return Err(JcsError::NonFiniteNumber);
|
||||||
}
|
}
|
||||||
Err(JcsError::UnrepresentableNumber)
|
out.extend_from_slice(es_number(x).as_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finite double as ECMA-262's Number::toString writes it (6.1.6.1.20).
|
||||||
|
fn es_number(x: f64) -> String {
|
||||||
|
if x == 0.0 {
|
||||||
|
return "0".into();
|
||||||
|
}
|
||||||
|
let (s, n) = shortest(x.abs());
|
||||||
|
let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect();
|
||||||
|
let k = s.len() as i32;
|
||||||
|
let mut out = String::new();
|
||||||
|
if x < 0.0 {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
if k <= n && n <= 21 {
|
||||||
|
out.push_str(&digits);
|
||||||
|
out.extend(std::iter::repeat_n('0', (n - k) as usize));
|
||||||
|
} else if 0 < n && n <= 21 {
|
||||||
|
out.push_str(&digits[..n as usize]);
|
||||||
|
out.push('.');
|
||||||
|
out.push_str(&digits[n as usize..]);
|
||||||
|
} else if -6 < n && n <= 0 {
|
||||||
|
out.push_str("0.");
|
||||||
|
out.extend(std::iter::repeat_n('0', (-n) as usize));
|
||||||
|
out.push_str(&digits);
|
||||||
|
} else {
|
||||||
|
out.push_str(&digits[..1]);
|
||||||
|
if k > 1 {
|
||||||
|
out.push('.');
|
||||||
|
out.push_str(&digits[1..]);
|
||||||
|
}
|
||||||
|
let _ = write!(out, "e{}{}", if n - 1 < 0 { '-' } else { '+' }, (n - 1).abs());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digits s and the exponent n, x = 0.s × 10ⁿ, that ECMA-262
|
||||||
|
/// chooses for `x` > 0: the fewest that read back as `x`, and of those
|
||||||
|
/// the nearest to `x`, a tie going to the even.
|
||||||
|
fn shortest(x: f64) -> (Vec<u8>, i32) {
|
||||||
|
let e = format!("{x:e}");
|
||||||
|
let (mant, exp) = e.split_once('e').unwrap_or((&e, "0"));
|
||||||
|
let mut s: Vec<u8> = mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
|
||||||
|
while s.len() > 1 && s.last() == Some(&0) {
|
||||||
|
s.pop();
|
||||||
|
}
|
||||||
|
let n = exp.parse::<i32>().unwrap_or(0) + 1;
|
||||||
|
// Rust's digits are the fewest, but a tie between two equally near
|
||||||
|
// ones rounds up (RFC 8785's 1424953923781206.25). Only when another
|
||||||
|
// of the same length also reads back as x (near 17 digits) is there a
|
||||||
|
// choice: then take x's exact expansion (a double's has at most 767
|
||||||
|
// significant digits) rounded to that length.
|
||||||
|
if ![false, true].into_iter().any(|up| step(&s, up).is_some_and(|t| reads_as(&t, n, x))) {
|
||||||
|
return (s, n);
|
||||||
|
}
|
||||||
|
let k = s.len();
|
||||||
|
let exact = format!("{x:.800e}");
|
||||||
|
let (em, ee) = exact.split_once('e').unwrap_or((&exact, "0"));
|
||||||
|
let all: Vec<u8> = em.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect();
|
||||||
|
let mut cn = ee.parse::<i32>().unwrap_or(0) + 1;
|
||||||
|
let mut c = all[..k].to_vec();
|
||||||
|
let rest = &all[k..];
|
||||||
|
let up = match rest.first().copied().unwrap_or(0) {
|
||||||
|
d if d > 5 => true,
|
||||||
|
5 => rest[1..].iter().any(|&d| d != 0) || c[k - 1] % 2 == 1,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if up {
|
||||||
|
match step(&c, true) {
|
||||||
|
Some(t) => c = t,
|
||||||
|
None => {
|
||||||
|
c = std::iter::once(1).chain(std::iter::repeat_n(0, k - 1)).collect();
|
||||||
|
cn += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reads_as(&c, cn, x) { (c, cn) } else { (s, n) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decimal one unit above or below `s` in its last digit, if it
|
||||||
|
/// still has as many digits.
|
||||||
|
fn step(s: &[u8], up: bool) -> Option<Vec<u8>> {
|
||||||
|
let mut t = s.to_vec();
|
||||||
|
for i in (0..t.len()).rev() {
|
||||||
|
match (up, t[i]) {
|
||||||
|
(true, 9) => t[i] = 0,
|
||||||
|
(false, 0) => t[i] = 9,
|
||||||
|
(_, d) => {
|
||||||
|
t[i] = if up { d + 1 } else { d - 1 };
|
||||||
|
return (t[0] != 0).then_some(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether 0.s × 10ⁿ reads as `x` (std's reader is correctly rounded).
|
||||||
|
fn reads_as(s: &[u8], n: i32, x: f64) -> bool {
|
||||||
|
let digits: String = s.iter().map(|d| char::from(b'0' + d)).collect();
|
||||||
|
format!("0.{digits}e{n}").parse::<f64>().is_ok_and(|y| y == x)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_string(s: &str, out: &mut Vec<u8>) {
|
fn write_string(s: &str, out: &mut Vec<u8>) {
|
||||||
|
|
@ -214,12 +310,51 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floats_use_shortest_round_trip() {
|
fn floats_use_shortest_round_trip() {
|
||||||
// ryu shortest = "1.5"
|
|
||||||
assert_eq!(enc(json!(1.5)), "1.5");
|
assert_eq!(enc(json!(1.5)), "1.5");
|
||||||
// ryu shortest = "0.1"
|
|
||||||
assert_eq!(enc(json!(0.1)), "0.1");
|
assert_eq!(enc(json!(0.1)), "0.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbers_are_laid_out_as_ecmascript_lays_them_out() {
|
||||||
|
// Plain decimals from 10^-6 up to 10^21, exponent form outside.
|
||||||
|
for (x, want) in [
|
||||||
|
(0.000001, "0.000001"),
|
||||||
|
(0.0000015, "0.0000015"),
|
||||||
|
(1e-7, "1e-7"),
|
||||||
|
(1.5e-5, "0.000015"),
|
||||||
|
(1e16, "10000000000000000"),
|
||||||
|
(1e20, "100000000000000000000"),
|
||||||
|
(1e21, "1e+21"),
|
||||||
|
(-0.0, "0"),
|
||||||
|
(5e-324, "5e-324"),
|
||||||
|
] {
|
||||||
|
assert_eq!(enc(json!(x)), want, "{x:e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbers_match_rfc_8785_appendix_b_and_a_javascript_engine() {
|
||||||
|
// Bit patterns and what a JavaScript engine's Number::toString
|
||||||
|
// writes for each: RFC 8785's appendix B, the edges of each
|
||||||
|
// layout, and patterns spread over every exponent.
|
||||||
|
let mut n = 0;
|
||||||
|
for line in include_str!("../testdata/es-number-tostring.txt").lines() {
|
||||||
|
let (bits, want) = line.split_once(' ').unwrap();
|
||||||
|
let x = f64::from_bits(u64::from_str_radix(bits, 16).unwrap());
|
||||||
|
assert_eq!(es_number(x), want, "{bits}");
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
assert!(n > 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integers_a_double_cannot_hold_have_no_canonical_form() {
|
||||||
|
assert_eq!(enc(json!(-9_007_199_254_740_991_i64)), "-9007199254740991");
|
||||||
|
for v in [json!(9_007_199_254_740_992_u64), json!(-9_007_199_254_740_992_i64), json!(u64::MAX)] {
|
||||||
|
assert!(matches!(canonicalize(&v), Err(JcsError::UnrepresentableNumber)), "{v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_finite_number_errors() {
|
fn non_finite_number_errors() {
|
||||||
// serde_json can't construct NaN/Inf directly, so we test
|
// serde_json can't construct NaN/Inf directly, so we test
|
||||||
|
|
|
||||||
3385
jouleclaw/jouleclaw-rs/crates/jouleclaw-jcs/testdata/es-number-tostring.txt
vendored
Normal file
3385
jouleclaw/jouleclaw-rs/crates/jouleclaw-jcs/testdata/es-number-tostring.txt
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -87,7 +87,7 @@ A signature over an object binds its content address, not the reverse (§7.3).
|
||||||
|
|
||||||
## 4. Canonical serialization
|
## 4. Canonical serialization
|
||||||
|
|
||||||
The canonical form of receipt and attestation cores is **deterministic CBOR** (RFC 8949 §4.2 core deterministic encoding) for the JCR-1 receipt, and **JCS** (RFC 8785) for the JSON attestation. A conforming implementation **MUST** produce byte-identical canonical encodings for equal objects across platforms. **Floats MUST NOT appear in any canonical form or decision path.** All energy figures are integer microjoules; all budgets, counts, and limits are unsigned integers.
|
The canonical form of receipt and attestation cores is **deterministic CBOR** (RFC 8949 §4.2 core deterministic encoding) for the JCR-1 receipt, and **JCS** (RFC 8785) for the JSON attestation. A conforming implementation **MUST** produce byte-identical canonical encodings for equal objects across platforms. **Floats MUST NOT appear in any canonical form or decision path.** All energy figures are integer microjoules; all budgets, counts, and limits are unsigned integers. In the JCS form every integer **MUST** lie within ±(2⁵³ − 1): RFC 8785 holds numbers as IEEE-754 doubles (I-JSON, RFC 7493 §2.2), so a larger one has no encoding that every platform writes alike, and an implementation **MUST** refuse a core that holds one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ impl FleetRecord {
|
||||||
let mut idx = index;
|
let mut idx = index;
|
||||||
let mut siblings = Vec::new();
|
let mut siblings = Vec::new();
|
||||||
while level.len() > 1 {
|
while level.len() > 1 {
|
||||||
let sib = if idx % 2 == 0 {
|
let sib = if idx.is_multiple_of(2) {
|
||||||
// sibling on the right (or self, duplicated, at an odd end)
|
// sibling on the right (or self, duplicated, at an odd end)
|
||||||
(*level.get(idx + 1).unwrap_or(&level[idx]), true)
|
(*level.get(idx + 1).unwrap_or(&level[idx]), true)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
//! the same primitive the ARL Sandbox uses, so the two interoperate at the
|
//! the same primitive the ARL Sandbox uses, so the two interoperate at the
|
||||||
//! attestation layer (SPEC §7.3). Our cores contain only objects, arrays,
|
//! attestation layer (SPEC §7.3). Our cores contain only objects, arrays,
|
||||||
//! strings, and unsigned integers; **no floats** appear (SPEC §4), which keeps
|
//! strings, and unsigned integers; **no floats** appear (SPEC §4), which keeps
|
||||||
//! number canonicalization trivial (integers serialize verbatim).
|
//! number canonicalization trivial: an integer within ±(2^53 − 1) is written as
|
||||||
|
//! its digits, which is what RFC 8785 writes for it on every platform. A larger
|
||||||
|
//! one is refused: RFC 8785 holds numbers as doubles (I-JSON, RFC 7493 §2.2), so
|
||||||
|
//! an implementation that reads it as one would write other bytes.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|
@ -13,6 +16,8 @@ use serde_json::Value;
|
||||||
pub enum JcsError {
|
pub enum JcsError {
|
||||||
#[error("floats are not permitted in a canonical attestation core (SPEC §4)")]
|
#[error("floats are not permitted in a canonical attestation core (SPEC §4)")]
|
||||||
FloatNotPermitted,
|
FloatNotPermitted,
|
||||||
|
#[error("an integer past ±(2^53 − 1) has no canonical form that every platform writes alike (SPEC §4)")]
|
||||||
|
IntegerOutOfRange,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonicalize a JSON value to its RFC 8785 bytes. Object keys are sorted by
|
/// Canonicalize a JSON value to its RFC 8785 bytes. Object keys are sorted by
|
||||||
|
|
@ -31,6 +36,10 @@ fn write_value(v: &Value, out: &mut String) -> Result<(), JcsError> {
|
||||||
if n.is_f64() {
|
if n.is_f64() {
|
||||||
return Err(JcsError::FloatNotPermitted);
|
return Err(JcsError::FloatNotPermitted);
|
||||||
}
|
}
|
||||||
|
const SAFE: u64 = (1 << 53) - 1;
|
||||||
|
if n.as_u64().is_some_and(|u| u > SAFE) || n.as_i64().is_some_and(|i| i.unsigned_abs() > SAFE) {
|
||||||
|
return Err(JcsError::IntegerOutOfRange);
|
||||||
|
}
|
||||||
out.push_str(&n.to_string());
|
out.push_str(&n.to_string());
|
||||||
}
|
}
|
||||||
Value::String(s) => write_string(s, out),
|
Value::String(s) => write_string(s, out),
|
||||||
|
|
@ -97,14 +106,21 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn integers_serialize_verbatim() {
|
fn integers_serialize_verbatim() {
|
||||||
let v = json!({ "n": 18446744073709551615u64 });
|
let v = json!({ "n": 9007199254740991u64, "m": -9007199254740991i64 });
|
||||||
let bytes = canonicalize(&v).unwrap();
|
let bytes = canonicalize(&v).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
String::from_utf8(bytes).unwrap(),
|
String::from_utf8(bytes).unwrap(),
|
||||||
r#"{"n":18446744073709551615}"#
|
r#"{"m":-9007199254740991,"n":9007199254740991}"#
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integers_a_double_cannot_hold_are_refused() {
|
||||||
|
for v in [json!({ "n": 9007199254740992u64 }), json!({ "n": u64::MAX }), json!([-9007199254740992i64])] {
|
||||||
|
assert!(matches!(canonicalize(&v), Err(JcsError::IntegerOutOfRange)), "{v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn floats_are_rejected() {
|
fn floats_are_rejected() {
|
||||||
let v = json!({ "x": 1.5 });
|
let v = json!({ "x": 1.5 });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue