/* RFC-0849: Bounded canonical JSON identity bytes — object-only opaque snapshot, strict RFC 8785 JCS encoder. Do not import node:*, Buffer, primitives.ts, or any node-bound module — this module MUST stay runtime-agnostic (workerd-safe); SHA-256 hashing lives in canonical-json-hash.ts. Do not define certification schemas, Diagnostic, or persistence — those belong to later packets. Do not change stableJsonHash or its consumers. RFC-0849: initial implementation of werkstatt/canonical-json@1 — bounded object-root profile of RFC 8785 JCS. RFC-1267 (REVIEW-RFC-1267-01): made node-free — Buffer replaced by TextEncoder/TextDecoder, canonicalJsonHashV1 moved to canonical-json-hash.ts so runtime-agnostic consumers (werkstatt-journal under workerd) can import this module directly via the ./fingerprint/canonical-json subpath. */ const utf8Encoder = new TextEncoder(); const utf8Decoder = new TextDecoder(); export const CANONICAL_JSON_V1 = "werkstatt/canonical-json@1" as const; // --------------------------------------------------------------------------- // Opaque branded type // --------------------------------------------------------------------------- declare const canonicalJsonObjectV1Brand: unique symbol; export type CanonicalJsonObjectV1 = { readonly [canonicalJsonObjectV1Brand]: true; }; const brandedRegistry = new WeakSet(); // --------------------------------------------------------------------------- // Path segments // --------------------------------------------------------------------------- export type CanonicalJsonPathSegmentV1 = | { readonly kind: "array-index"; readonly index: number } | { readonly kind: "object-key"; readonly sortedIndex: number }; // --------------------------------------------------------------------------- // Failure types // --------------------------------------------------------------------------- export type CanonicalJsonFailureCodeV1 = | "CERT-CANONICAL-DOMAIN-01" | "CERT-CANONICAL-TRAVERSAL-01" | "CERT-CANONICAL-UNICODE-01" | "CERT-CANONICAL-LIMIT-01"; export interface CanonicalJsonFailureV1 { readonly ok: false; readonly code: CanonicalJsonFailureCodeV1; readonly path: readonly CanonicalJsonPathSegmentV1[]; readonly omittedPathSegments: number; readonly message: string; readonly limit?: "bytes" | "depth" | "nodes" | "object-keys" | "array-items" | "string-bytes" | "key-bytes"; readonly actual?: number; readonly maximum?: number; } export interface CanonicalJsonSuccessV1 { readonly ok: true; readonly value: CanonicalJsonObjectV1; } export type CanonicalJsonObjectSnapshotResultV1 = CanonicalJsonSuccessV1 | CanonicalJsonFailureV1; // --------------------------------------------------------------------------- // Invariant error // --------------------------------------------------------------------------- export class CanonicalJsonInvariantError extends Error { readonly code = "CERT-CANONICAL-BRAND-01" as const; constructor(message: string) { super(message); this.name = "CanonicalJsonInvariantError"; } } // --------------------------------------------------------------------------- // Hard limits // --------------------------------------------------------------------------- const MAX_BYTES = 8 * 1024 * 1024; const MAX_DEPTH = 64; const MAX_NODES = 250_000; const MAX_OBJECT_KEYS = 10_000; const MAX_ARRAY_ITEMS = 100_000; const MAX_STRING_BYTES = 1024 * 1024; const MAX_KEY_BYTES = 1024; const MAX_MESSAGE_BYTES = 512; const MAX_PATH_SEGMENTS = 64; // --------------------------------------------------------------------------- // UTF-16 lone surrogate check // --------------------------------------------------------------------------- function hasLoneSurrogate(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code >= 0xd800 && code <= 0xdbff) { if (i + 1 >= str.length) return true; const next = str.charCodeAt(i + 1); if (next < 0xdc00 || next > 0xdfff) return true; i++; } else if (code >= 0xdc00 && code <= 0xdfff) { return true; } } return false; } function utf8ByteLength(str: string): number { return utf8Encoder.encode(str).length; } // --------------------------------------------------------------------------- // RFC 8785 key comparator (lexicographic over UTF-16 code units) // --------------------------------------------------------------------------- function compareJcsKeys(a: string, b: string): number { const len = Math.min(a.length, b.length); for (let i = 0; i < len; i++) { const ca = a.charCodeAt(i); const cb = b.charCodeAt(i); if (ca !== cb) return ca - cb; } return a.length - b.length; } // --------------------------------------------------------------------------- // RFC 8785 number serialization (§3.2.2.3 / ECMA-262 §7.1.12.1) // --------------------------------------------------------------------------- function serializeNumber(value: number): string { if (Object.is(value, -0)) { throw new CanonicalJsonInvariantError("negative zero rejected by Werkstatt profile"); } if (!Number.isFinite(value)) { throw new CanonicalJsonInvariantError("non-finite numbers rejected by Werkstatt profile"); } if (Number.isInteger(value) && !Number.isSafeInteger(value)) { throw new CanonicalJsonInvariantError("unsafe integer rejected by Werkstatt profile"); } if (value === 0) return "0"; if (Number.isInteger(value)) return value.toString(); // Non-integer finite numbers: use ECMA-262 §7.1.12.1 Number.prototype.toString // which produces the shortest representation that round-trips. return value.toString(); } // --------------------------------------------------------------------------- // RFC 8785 string escaping (§3.2.2.2) // --------------------------------------------------------------------------- function escapeString(str: string): string { let out = '"'; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); switch (code) { case 0x22: out += '\\"'; break; case 0x5c: out += "\\\\"; break; case 0x08: out += "\\b"; break; case 0x09: out += "\\t"; break; case 0x0a: out += "\\n"; break; case 0x0c: out += "\\f"; break; case 0x0d: out += "\\r"; break; default: if (code < 0x20) { out += "\\u" + code.toString(16).padStart(4, "0"); } else if (code >= 0xd800 && code <= 0xdbff) { // High surrogate — must be followed by low surrogate const next = str.charCodeAt(i + 1); if (next >= 0xdc00 && next <= 0xdfff) { // Valid surrogate pair — emit raw UTF-16 code units out += String.fromCharCode(code); out += String.fromCharCode(next); i++; } else { // This should have been caught by hasLoneSurrogate, but defensive throw new CanonicalJsonInvariantError( "lone high surrogate in string — should have been rejected", ); } } else { out += String.fromCharCode(code); } break; } } out += '"'; return out; } // --------------------------------------------------------------------------- // Traversal state // --------------------------------------------------------------------------- interface TraversalState { nodes: number; bytes: number; output: string[]; } type FailureResult = CanonicalJsonFailureV1; // --------------------------------------------------------------------------- // Deep freeze // --------------------------------------------------------------------------- function deepFreeze(value: unknown): void { if (value === null || typeof value !== "object") return; if (Array.isArray(value)) { for (const item of value) deepFreeze(item); Object.freeze(value); } else { for (const key of Object.keys(value as Record)) { deepFreeze((value as Record)[key]); } Object.freeze(value); } } // --------------------------------------------------------------------------- // Snapshot builder // --------------------------------------------------------------------------- function isPlainObjectRoot(value: unknown): boolean { if (value === null || typeof value !== "object") return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } function isPlainObject(value: unknown): boolean { return isPlainObjectRoot(value); } function buildSnapshot(input: unknown): CanonicalJsonObjectSnapshotResultV1 { // Root must be a plain object if (!isPlainObjectRoot(input)) { return fail("CERT-CANONICAL-DOMAIN-01", [], 0, "root must be a plain object"); } const visited = new Set(); const state: TraversalState = { nodes: 1, bytes: 0, output: [] }; if (state.nodes > MAX_NODES) { return failLimit("nodes", [], 0, state.nodes, MAX_NODES); } const result = traverseObject(input as Record, [], 0, state, visited); if (result) return result; const outputStr = state.output.join(""); const outputBytes = utf8Encoder.encode(outputStr).length; if (outputBytes > MAX_BYTES) { return failLimit("bytes", [], 0, outputBytes, MAX_BYTES); } // Build the detached frozen copy const detached = buildDetached(input); deepFreeze(detached); const root = detached as unknown as CanonicalJsonObjectV1; brandedRegistry.add(detached as object); return { ok: true, value: root }; } function traverseObject( obj: Record, path: CanonicalJsonPathSegmentV1[], depth: number, state: TraversalState, visited: Set, ): FailureResult | null { if (depth > MAX_DEPTH) { return failLimit("depth", path, 0, depth, MAX_DEPTH); } // Cycle/alias detection if (visited.has(obj)) { return fail("CERT-CANONICAL-TRAVERSAL-01", path, 0, "cycle or repeated reference detected"); } visited.add(obj); // Capture own keys let ownKeys: string[]; try { ownKeys = Reflect.ownKeys(obj) as string[]; } catch { return fail("CERT-CANONICAL-TRAVERSAL-01", path, 0, "Reflect.ownKeys threw during capture"); } // Check for symbol keys const symbols = Object.getOwnPropertySymbols(obj); if (symbols.length > 0) { return fail("CERT-CANONICAL-DOMAIN-01", path, 0, "symbol keys are not permitted"); } // Check all descriptors are enumerable data descriptors for (const key of ownKeys) { let desc: PropertyDescriptor; try { desc = Object.getOwnPropertyDescriptor(obj, key)!; } catch { return fail("CERT-CANONICAL-TRAVERSAL-01", path, 0, "getOwnPropertyDescriptor threw"); } if (!desc) { return fail("CERT-CANONICAL-DOMAIN-01", path, 0, `missing descriptor for key`); } if (!desc.enumerable) { return fail("CERT-CANONICAL-DOMAIN-01", path, 0, "non-enumerable property is not permitted"); } if (desc.get !== undefined || desc.set !== undefined) { return fail("CERT-CANONICAL-DOMAIN-01", path, 0, "accessor property is not permitted"); } } // Check key count if (ownKeys.length > MAX_OBJECT_KEYS) { return failLimit("object-keys", path, 0, ownKeys.length, MAX_OBJECT_KEYS); } // Check key bytes and lone surrogates for (const key of ownKeys) { const keyBytes = utf8ByteLength(key); if (keyBytes > MAX_KEY_BYTES) { return failLimit("key-bytes", path, 0, keyBytes, MAX_KEY_BYTES); } if (hasLoneSurrogate(key)) { return fail("CERT-CANONICAL-UNICODE-01", path, 0, "lone surrogate in object key"); } } // Sort keys by JCS comparator const sortedKeys = [...ownKeys].sort(compareJcsKeys); // Verify key list didn't change during capture let postKeys: string[]; try { postKeys = Reflect.ownKeys(obj) as string[]; } catch { return fail( "CERT-CANONICAL-TRAVERSAL-01", path, 0, "Reflect.ownKeys threw during post-capture", ); } if (postKeys.length !== ownKeys.length || !postKeys.every((k, i) => k === ownKeys[i])) { return fail("CERT-CANONICAL-TRAVERSAL-01", path, 0, "own-key shape changed during capture"); } // Emit object state.output.push("{"); for (let i = 0; i < sortedKeys.length; i++) { const key = sortedKeys[i]; if (i > 0) state.output.push(","); // Emit key state.output.push(escapeString(key)); state.output.push(":"); // Read value once let value: unknown; try { value = (obj as Record)[key]; } catch { const seg: CanonicalJsonPathSegmentV1 = { kind: "object-key", sortedIndex: i }; const newPath = pushPath(path, seg); return fail( "CERT-CANONICAL-TRAVERSAL-01", newPath.path, newPath.omitted, "property read threw", ); } const seg: CanonicalJsonPathSegmentV1 = { kind: "object-key", sortedIndex: i }; const newPath = pushPath(path, seg); const result = traverseValue(value, newPath.path, newPath.omitted, depth + 1, state, visited); if (result) return result; } state.output.push("}"); return null; } function traverseArray( arr: unknown[], path: CanonicalJsonPathSegmentV1[], omitted: number, depth: number, state: TraversalState, visited: Set, ): FailureResult | null { if (depth > MAX_DEPTH) { return failLimit("depth", path, omitted, depth, MAX_DEPTH); } if (visited.has(arr)) { return fail( "CERT-CANONICAL-TRAVERSAL-01", path, omitted, "cycle or repeated reference detected", ); } visited.add(arr); // Check array is dense (no holes) const len = arr.length; for (let i = 0; i < len; i++) { if (!(i in arr)) { const seg: CanonicalJsonPathSegmentV1 = { kind: "array-index", index: i }; const newPath = pushPath(path, seg); return fail( "CERT-CANONICAL-DOMAIN-01", newPath.path, newPath.omitted, "sparse array hole detected", ); } } // Check for extra own keys beyond indices const arrKeys = Object.keys(arr); for (const k of arrKeys) { if (!/^(?:0|[1-9]\d*)$/.test(k) || Number(k) >= len) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "array has extra own keys"); } } // Check symbol keys const symbols = Object.getOwnPropertySymbols(arr); if (symbols.length > 0) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "array has symbol keys"); } if (len > MAX_ARRAY_ITEMS) { return failLimit("array-items", path, omitted, len, MAX_ARRAY_ITEMS); } state.output.push("["); for (let i = 0; i < len; i++) { if (i > 0) state.output.push(","); const seg: CanonicalJsonPathSegmentV1 = { kind: "array-index", index: i }; const newPath = pushPath(path, seg); const result = traverseValue(arr[i], newPath.path, newPath.omitted, depth + 1, state, visited); if (result) return result; } state.output.push("]"); return null; } function traverseValue( value: unknown, path: CanonicalJsonPathSegmentV1[], omitted: number, depth: number, state: TraversalState, visited: Set, ): FailureResult | null { state.nodes++; if (state.nodes > MAX_NODES) { return failLimit("nodes", path, omitted, state.nodes, MAX_NODES); } if (value === null) { state.output.push("null"); return null; } if (value === undefined) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "undefined is not permitted"); } const type = typeof value; if (type === "boolean") { state.output.push(value ? "true" : "false"); return null; } if (type === "number") { const num = value as number; if (Object.is(num, NaN)) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "NaN is not permitted"); } if (!Number.isFinite(num)) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "Infinity is not permitted"); } if (Object.is(num, -0)) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "negative zero is not permitted"); } if (Number.isInteger(num) && !Number.isSafeInteger(num)) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "unsafe integer is not permitted"); } try { state.output.push(serializeNumber(num)); } catch (e) { if (e instanceof CanonicalJsonInvariantError) { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, e.message); } throw e; } return null; } if (type === "bigint") { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "bigint is not permitted"); } if (type === "symbol") { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "symbol is not permitted"); } if (type === "function") { return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, "function is not permitted"); } if (type === "string") { const strBytes = utf8ByteLength(value as string); if (strBytes > MAX_STRING_BYTES) { return failLimit("string-bytes", path, omitted, strBytes, MAX_STRING_BYTES); } if (hasLoneSurrogate(value as string)) { return fail("CERT-CANONICAL-UNICODE-01", path, omitted, "lone surrogate in string value"); } state.output.push(escapeString(value as string)); return null; } if (type === "object") { // Reject host objects and class instances if (!isPlainObject(value) && !Array.isArray(value)) { // Check for toJSON if (typeof (value as { toJSON?: unknown }).toJSON === "function") { return fail( "CERT-CANONICAL-DOMAIN-01", path, omitted, "toJSON customization is not permitted", ); } // Check for Date, Map, Set, RegExp, Error, typed arrays, etc. const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) { const ctor = proto?.constructor?.name ?? "unknown"; return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, `non-plain object type: ${ctor}`); } // null-prototype object — treat as plain } if (Array.isArray(value)) { return traverseArray(value, path, omitted, depth, state, visited); } return traverseObject(value as Record, path, depth, state, visited); } return fail("CERT-CANONICAL-DOMAIN-01", path, omitted, `unsupported value type: ${type}`); } // --------------------------------------------------------------------------- // Detached copy builder // --------------------------------------------------------------------------- function buildDetached(input: unknown): unknown { if (input === null || typeof input !== "object") return input; if (Array.isArray(input)) { const arr: unknown[] = []; for (const item of input) arr.push(buildDetached(item)); return arr; } const obj: Record = Object.create(null); for (const key of Object.keys(input as Record)) { obj[key] = buildDetached((input as Record)[key]); } return obj; } // --------------------------------------------------------------------------- // Path helpers // --------------------------------------------------------------------------- function pushPath( path: CanonicalJsonPathSegmentV1[], seg: CanonicalJsonPathSegmentV1, ): { path: CanonicalJsonPathSegmentV1[]; omitted: number } { if (path.length >= MAX_PATH_SEGMENTS) { return { path, omitted: 1 }; } return { path: [...path, seg], omitted: 0 }; } // --------------------------------------------------------------------------- // Failure helpers // --------------------------------------------------------------------------- function truncateMessage(msg: string): string { const buf = utf8Encoder.encode(msg); if (buf.length <= MAX_MESSAGE_BYTES) return msg; return utf8Decoder.decode(buf.subarray(0, MAX_MESSAGE_BYTES)); } function fail( code: CanonicalJsonFailureCodeV1, path: CanonicalJsonPathSegmentV1[], omitted: number, message: string, ): CanonicalJsonFailureV1 { return { ok: false, code, path, omittedPathSegments: omitted, message: truncateMessage(message), }; } function failLimit( limit: NonNullable, path: CanonicalJsonPathSegmentV1[], omitted: number, actual: number, maximum: number, ): CanonicalJsonFailureV1 { return { ok: false, code: "CERT-CANONICAL-LIMIT-01", path, omittedPathSegments: omitted, message: truncateMessage(`${limit} limit exceeded: ${actual} > ${maximum}`), limit, actual, maximum, }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function snapshotCanonicalJsonObjectV1(input: unknown): CanonicalJsonObjectSnapshotResultV1 { return buildSnapshot(input); } export function isCanonicalJsonObjectV1(value: unknown): value is CanonicalJsonObjectV1 { return typeof value === "object" && value !== null && brandedRegistry.has(value as object); } export function canonicalJsonBytesV1(value: CanonicalJsonObjectV1): Uint8Array { if (!isCanonicalJsonObjectV1(value)) { throw new CanonicalJsonInvariantError( "CERT-CANONICAL-BRAND-01: value is not a runtime-branded CanonicalJsonObjectV1", ); } // Re-encode from the detached frozen copy const output: string[] = []; encodeValue(value, output); const str = output.join(""); return utf8Encoder.encode(str); } // --------------------------------------------------------------------------- // Encoder (operates on detached frozen copy — no caller mutation possible) // --------------------------------------------------------------------------- function encodeValue(value: unknown, output: string[]): void { if (value === null) { output.push("null"); return; } if (typeof value === "boolean") { output.push(value ? "true" : "false"); return; } if (typeof value === "number") { output.push(serializeNumber(value)); return; } if (typeof value === "string") { output.push(escapeString(value)); return; } if (Array.isArray(value)) { output.push("["); for (let i = 0; i < value.length; i++) { if (i > 0) output.push(","); encodeValue(value[i], output); } output.push("]"); return; } // Plain object (null-prototype after detachment) if (typeof value === "object" && value !== null) { const keys = Object.keys(value as Record).sort(compareJcsKeys); output.push("{"); for (let i = 0; i < keys.length; i++) { if (i > 0) output.push(","); output.push(escapeString(keys[i])); output.push(":"); encodeValue((value as Record)[keys[i]], output); } output.push("}"); return; } }