Skip to content

Commit 76fa2f1

Browse files
feat(json/unstable): add RFC 8785 JSON canonicalization
1 parent 007896d commit 76fa2f1

File tree

4 files changed

+574
-5
lines changed

4 files changed

+574
-5
lines changed

json/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"./types": "./types.ts",
77
"./concatenated-json-parse-stream": "./concatenated_json_parse_stream.ts",
88
"./parse-stream": "./parse_stream.ts",
9-
"./stringify-stream": "./stringify_stream.ts"
9+
"./stringify-stream": "./stringify_stream.ts",
10+
"./unstable-canonicalize": "./unstable_canonicalize.ts"
1011
}
1112
}

json/types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright 2018-2026 the Deno authors. MIT license.
22
// This module is browser compatible.
33

4+
/** A primitive JSON value. */
5+
export type JsonPrimitive = string | number | boolean | null;
6+
47
/** The type of the result of parsing JSON. */
58
export type JsonValue =
69
| { [key: string]: JsonValue | undefined }
710
| JsonValue[]
8-
| string
9-
| number
10-
| boolean
11-
| null;
11+
| JsonPrimitive;

json/unstable_canonicalize.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Copyright 2018-2026 the Deno authors. MIT license.
2+
// This module is browser compatible.
3+
4+
import type { JsonPrimitive, JsonValue } from "./types.ts";
5+
6+
/**
7+
* Serializes a primitive JSON value (null, boolean, number, string) to its
8+
* canonical string representation per RFC 8785.
9+
*/
10+
function serializePrimitive(value: JsonPrimitive): string {
11+
// JSON.stringify handles null, boolean, and string correctly per RFC 8785
12+
if (typeof value !== "number") return JSON.stringify(value);
13+
14+
// RFC 8785 Section 3.2.2.3: Numbers must conform to I-JSON (RFC 7493)
15+
if (!Number.isFinite(value)) {
16+
throw new TypeError(
17+
`Cannot canonicalize non-finite number: ${value} is not allowed in I-JSON`,
18+
);
19+
}
20+
// Handle -0 as "0" (RFC 8785 Section 3.2.2.3)
21+
if (Object.is(value, -0)) return "0";
22+
// ECMAScript Number-to-String for all other numbers
23+
return value.toString();
24+
}
25+
26+
/**
27+
* Serializes an array to its canonical string representation.
28+
* Undefined elements become null (standard JSON behavior).
29+
*/
30+
function serializeArray(value: JsonValue[], ancestors: object[]): string {
31+
if (value.length === 0) return "[]";
32+
33+
const parts: string[] = [];
34+
for (const elem of value) {
35+
parts.push(elem === undefined ? "null" : serializeValue(elem, ancestors));
36+
}
37+
return "[" + parts.join(",") + "]";
38+
}
39+
40+
/**
41+
* Serializes an object to its canonical string representation.
42+
* Keys are sorted by UTF-16 code units (RFC 8785 Section 3.2.3).
43+
* Undefined values are skipped (standard JSON behavior, RFC 8785 Section 3.1).
44+
*/
45+
function serializeObject(
46+
value: { [key: string]: JsonValue | undefined },
47+
ancestors: object[],
48+
): string {
49+
// Default sort uses UTF-16 code unit comparison per RFC 8785
50+
const keys = Object.keys(value).sort();
51+
52+
const parts: string[] = [];
53+
for (const key of keys) {
54+
const propValue = value[key];
55+
if (propValue === undefined) continue;
56+
parts.push(
57+
JSON.stringify(key) + ":" + serializeValue(propValue, ancestors),
58+
);
59+
}
60+
61+
return "{" + parts.join(",") + "}";
62+
}
63+
64+
/**
65+
* Recursively serializes a JSON value to its canonical string representation.
66+
*
67+
* @param value The JSON value to serialize
68+
* @param ancestors Stack of ancestor objects for cycle detection
69+
*/
70+
function serializeValue(value: JsonValue, ancestors: object[] = []): string {
71+
if (value === null) return "null";
72+
if (typeof value !== "object") return serializePrimitive(value);
73+
74+
// Circular reference detection: check if this object is an ancestor
75+
if (ancestors.includes(value)) {
76+
throw new TypeError("Converting circular structure to JSON");
77+
}
78+
ancestors.push(value);
79+
80+
const result = Array.isArray(value)
81+
? serializeArray(value, ancestors)
82+
: serializeObject(value, ancestors);
83+
84+
ancestors.pop();
85+
return result;
86+
}
87+
88+
/**
89+
* Serializes a JSON value to a canonical string per
90+
* {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} JSON
91+
* Canonicalization Scheme (JCS).
92+
*
93+
* This produces a deterministic JSON string suitable for hashing or signing,
94+
* with object keys sorted lexicographically by UTF-16 code units and no
95+
* whitespace between tokens.
96+
*
97+
* Note: The input must be JSON-compatible data. Objects with `toJSON()` methods
98+
* (like `Date`) should be converted to their JSON representation first.
99+
*
100+
* @experimental **UNSTABLE**: New API, yet to be vetted.
101+
*
102+
* @param value The JSON value to canonicalize.
103+
* @returns The canonical JSON string.
104+
*
105+
* @example Basic usage (RFC 8785 Appendix E inspired)
106+
* ```ts
107+
* import { canonicalize } from "@std/json/unstable-canonicalize";
108+
* import { assertEquals } from "@std/assert";
109+
*
110+
* // Keys are sorted lexicographically, no whitespace between tokens
111+
* const data = {
112+
* time: "2019-01-28T07:45:10Z",
113+
* big: "055",
114+
* val: 3.5,
115+
* };
116+
* assertEquals(canonicalize(data), '{"big":"055","time":"2019-01-28T07:45:10Z","val":3.5}');
117+
* ```
118+
*
119+
* @example Number serialization (RFC 8785 Section 3.2.2.3)
120+
* ```ts
121+
* import { canonicalize } from "@std/json/unstable-canonicalize";
122+
* import { assertEquals } from "@std/assert";
123+
*
124+
* // Numbers follow ECMAScript serialization rules
125+
* assertEquals(canonicalize(10.0), "10"); // No unnecessary decimals
126+
* assertEquals(canonicalize(1e21), "1e+21"); // Scientific notation for large
127+
* assertEquals(canonicalize(0.0000001), "1e-7"); // Scientific notation for small
128+
* assertEquals(canonicalize(-0), "0"); // Negative zero becomes "0"
129+
* ```
130+
*
131+
* @example Key sorting by UTF-16 code units (RFC 8785 Section 3.2.3)
132+
* ```ts
133+
* import { canonicalize } from "@std/json/unstable-canonicalize";
134+
* import { assertEquals } from "@std/assert";
135+
*
136+
* // Keys sorted by UTF-16 code units: digits < uppercase < lowercase
137+
* const data = { a: 1, A: 2, "1": 3 };
138+
* assertEquals(canonicalize(data), '{"1":3,"A":2,"a":1}');
139+
* ```
140+
*
141+
* @throws {TypeError} If the value contains non-finite numbers (Infinity, -Infinity, NaN).
142+
* @throws {TypeError} If the value contains circular references.
143+
*
144+
* @see {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785}
145+
*/
146+
export function canonicalize(value: JsonValue): string {
147+
return serializeValue(value);
148+
}
149+
150+
/**
151+
* Serializes a JSON value to canonical UTF-8 bytes per
152+
* {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} JSON
153+
* Canonicalization Scheme (JCS).
154+
*
155+
* This is equivalent to `new TextEncoder().encode(canonicalize(value))` and
156+
* is provided as a convenience for cryptographic operations that require
157+
* byte input.
158+
*
159+
* @experimental **UNSTABLE**: New API, yet to be vetted.
160+
*
161+
* @param value The JSON value to canonicalize.
162+
* @returns The canonical JSON as UTF-8 bytes.
163+
*
164+
* @example Creating a verifiable hash
165+
* ```ts
166+
* import { canonicalizeToBytes } from "@std/json/unstable-canonicalize";
167+
* import { encodeHex } from "@std/encoding/hex";
168+
* import { assertEquals } from "@std/assert";
169+
*
170+
* // Create a deterministic hash of JSON data for verification
171+
* const payload = { action: "transfer", amount: 100, to: "alice" };
172+
* const bytes = canonicalizeToBytes(payload);
173+
* const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
174+
* const hash = encodeHex(new Uint8Array(hashBuffer));
175+
*
176+
* // Same hash regardless of original key order
177+
* const reordered = { to: "alice", action: "transfer", amount: 100 };
178+
* const reorderedBytes = canonicalizeToBytes(reordered);
179+
* const reorderedHash = encodeHex(
180+
* new Uint8Array(await crypto.subtle.digest("SHA-256", reorderedBytes)),
181+
* );
182+
*
183+
* assertEquals(hash, reorderedHash);
184+
* ```
185+
*
186+
* @throws {TypeError} If the value contains non-finite numbers (Infinity, -Infinity, NaN).
187+
* @throws {TypeError} If the value contains circular references.
188+
*
189+
* @see {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785}
190+
*/
191+
export function canonicalizeToBytes(value: JsonValue): Uint8Array {
192+
return new TextEncoder().encode(canonicalize(value));
193+
}

0 commit comments

Comments
 (0)