Skip to content

Commit 823ee48

Browse files
feat(json/unstable): implement RFC 8785 JSON canonicalization (#6965)
1 parent f7e1209 commit 823ee48

File tree

5 files changed

+585
-5
lines changed

5 files changed

+585
-5
lines changed

json/_types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright 2018-2026 the Deno authors. MIT license.
2+
3+
/**
4+
* Proxy type of {@code Uint8Array<ArrayBuffer>} or {@code Uint8Array} in TypeScript 5.1 or below respectively.
5+
*
6+
* This type is internal utility type and should not be used directly.
7+
*
8+
* @internal @private
9+
*/
10+
export type Uint8Array_ = ReturnType<Uint8Array["slice"]>;

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

0 commit comments

Comments
 (0)