// Copyright 2018-2026 the Deno authors. MIT license. // This module is browser compatible. import type { JsonPrimitive, JsonValue } from "./types.ts"; import type { Uint8Array_ } from "./_types.ts"; /** * Serializes a primitive JSON value (null, boolean, number, string) to its * canonical string representation per RFC 8785. */ function serializePrimitive(value: JsonPrimitive): string { // JSON.stringify handles null, boolean, and string correctly per RFC 8785 if (typeof value !== "number") return JSON.stringify(value); // RFC 8785 Section 3.2.2.3: Numbers must conform to I-JSON (RFC 7493) if (!Number.isFinite(value)) { throw new TypeError( `Cannot canonicalize non-finite number: ${value} is not allowed in I-JSON`, ); } // Handle -0 as "0" (RFC 8785 Section 3.2.2.3) if (Object.is(value, -0)) return "0"; // ECMAScript Number-to-String for all other numbers return value.toString(); } /** * Serializes an array to its canonical string representation. * Undefined elements become null (standard JSON behavior). */ function serializeArray(value: JsonValue[], ancestors: object[]): string { if (value.length === 0) return "[]"; const parts: string[] = []; for (const elem of value) { parts.push(elem === undefined ? "null" : serializeValue(elem, ancestors)); } return "[" + parts.join(",") + "]"; } /** * Serializes an object to its canonical string representation. * Keys are sorted by UTF-16 code units (RFC 8785 Section 3.2.3). * Undefined values are skipped (standard JSON behavior, RFC 8785 Section 3.1). */ function serializeObject( value: { [key: string]: JsonValue | undefined }, ancestors: object[], ): string { // Default sort uses UTF-16 code unit comparison per RFC 8785 const keys = Object.keys(value).sort(); const parts: string[] = []; for (const key of keys) { const propValue = value[key]; if (propValue === undefined) continue; parts.push( JSON.stringify(key) + ":" + serializeValue(propValue, ancestors), ); } return "{" + parts.join(",") + "}"; } /** * Recursively serializes a JSON value to its canonical string representation. * * @param value The JSON value to serialize * @param ancestors Stack of ancestor objects for cycle detection */ function serializeValue(value: JsonValue, ancestors: object[] = []): string { if (value === null) return "null"; if (typeof value !== "object") return serializePrimitive(value); // Circular reference detection: check if this object is an ancestor if (ancestors.includes(value)) { throw new TypeError("Converting circular structure to JSON"); } ancestors.push(value); const result = Array.isArray(value) ? serializeArray(value, ancestors) : serializeObject(value, ancestors); ancestors.pop(); return result; } /** * Serializes a JSON value to a canonical string per * {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} JSON * Canonicalization Scheme (JCS). * * This produces a deterministic JSON string suitable for hashing or signing, * with object keys sorted lexicographically by UTF-16 code units and no * whitespace between tokens. * * Note: The input must be JSON-compatible data. Objects with `toJSON()` methods * (like `Date`) should be converted to their JSON representation first. * * @param value The JSON value to canonicalize. * @returns The canonical JSON string. * * @example Basic usage (RFC 8785 Appendix E inspired) * ```ts * import { canonicalize } from "canonicalize.ts"; * import { assertEquals } from "../assert/mod.ts"; * * // Keys are sorted lexicographically, no whitespace between tokens * const data = { * time: "2019-01-28T07:45:10Z", * big: "055", * val: 3.5, * }; * assertEquals(canonicalize(data), '{"big":"055","time":"2019-01-28T07:45:10Z","val":3.5}'); * ``` * * @example Number serialization (RFC 8785 Section 3.2.2.3) * ```ts * import { canonicalize } from "canonicalize.ts"; * import { assertEquals } from "../assert/mod.ts"; * * // Numbers follow ECMAScript serialization rules * assertEquals(canonicalize(10.0), "10"); // No unnecessary decimals * assertEquals(canonicalize(1e21), "1e+21"); // Scientific notation for large * assertEquals(canonicalize(0.0000001), "1e-7"); // Scientific notation for small * assertEquals(canonicalize(-0), "0"); // Negative zero becomes "0" * ``` * * @example Key sorting by UTF-16 code units (RFC 8785 Section 3.2.3) * ```ts * import { canonicalize } from "canonicalize.ts"; * import { assertEquals } from "../assert/mod.ts"; * * // Keys sorted by UTF-16 code units: digits < uppercase < lowercase * const data = { a: 1, A: 2, "1": 3 }; * assertEquals(canonicalize(data), '{"1":3,"A":2,"a":1}'); * ``` * * @throws {TypeError} If the value contains non-finite numbers (Infinity, -Infinity, NaN). * @throws {TypeError} If the value contains circular references. * * @see {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} */ export function canonicalize(value: JsonValue): string { return serializeValue(value); } /** * Serializes a JSON value to canonical UTF-8 bytes per * {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} JSON * Canonicalization Scheme (JCS). * * This is equivalent to `new TextEncoder().encode(canonicalize(value))` and * is provided as a convenience for cryptographic operations that require * byte input. * * @param value The JSON value to canonicalize. * @returns The canonical JSON as UTF-8 bytes. * * @example Creating a verifiable hash * ```ts * import { canonicalizeToBytes } from "canonicalize.ts"; * import { encodeHex } from "../encoding/hex.ts"; * import { assertEquals } from "../assert/mod.ts"; * * async function sha256Hex(data: Uint8Array): Promise { * const hash = await crypto.subtle.digest("SHA-256", data.buffer as ArrayBuffer); * return encodeHex(new Uint8Array(hash)); * } * * // Create a deterministic hash of JSON data for verification * const payload = { action: "transfer", amount: 100, to: "alice" }; * const hash = await sha256Hex(canonicalizeToBytes(payload)); * * // Same hash regardless of original key order * const reordered = { to: "alice", action: "transfer", amount: 100 }; * const reorderedHash = await sha256Hex(canonicalizeToBytes(reordered)); * * assertEquals(hash, reorderedHash); * ``` * * @throws {TypeError} If the value contains non-finite numbers (Infinity, -Infinity, NaN). * @throws {TypeError} If the value contains circular references. * * @see {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} */ export function canonicalizeToBytes(value: JsonValue): Uint8Array_ { return new TextEncoder().encode(canonicalize(value)); }