export const strictCanonicalJsonRules = Object.freeze({ version: 1 as const, encoding: 'utf-8' as const, objectKeyOrdering: 'utf-16-code-unit-ascending' as const, arrayOrdering: 'preserved-dense' as const, unicodeNormalization: 'preserved' as const, numberEncoding: 'safe-integer-json-no-negative-zero' as const, digest: 'sha256-lowercase-hex' as const, }); export type TCanonicalJsonFailure = (reasonArg: string) => never; const assertUnicodeScalarString = ( valueArg: string, fieldNameArg: string, failArg: TCanonicalJsonFailure, ): void => { for (let index = 0; index < valueArg.length; index++) { const codeUnit = valueArg.charCodeAt(index); if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { const followingCodeUnit = valueArg.charCodeAt(index + 1); if ( !Number.isInteger(followingCodeUnit) || followingCodeUnit < 0xdc00 || followingCodeUnit > 0xdfff ) { failArg(`${fieldNameArg} must not contain an unpaired UTF-16 surrogate`); } index++; continue; } if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { failArg(`${fieldNameArg} must not contain an unpaired UTF-16 surrogate`); } } }; const readStrictRecord = ( valueArg: object, fieldNameArg: string, failArg: TCanonicalJsonFailure, ): Record => { const prototype = Object.getPrototypeOf(valueArg); if (prototype !== Object.prototype && prototype !== null) { return failArg(`${fieldNameArg} must be a plain object`); } const record = valueArg as Record; const ownKeys = Reflect.ownKeys(record); if (ownKeys.some((keyArg) => typeof keyArg !== 'string')) { return failArg(`${fieldNameArg} must not contain symbol keys`); } for (const key of ownKeys as string[]) { const descriptor = Object.getOwnPropertyDescriptor(record, key); if ( !descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value') ) { return failArg( `${fieldNameArg} must contain enumerable data properties only`, ); } } return record; }; const readStrictArray = ( valueArg: unknown[], fieldNameArg: string, failArg: TCanonicalJsonFailure, ): unknown[] => { if (Object.getPrototypeOf(valueArg) !== Array.prototype) { return failArg(`${fieldNameArg} must be a dense plain array`); } for (let index = 0; index < valueArg.length; index++) { if (!Object.hasOwn(valueArg, index)) { return failArg(`${fieldNameArg} must not contain sparse entries`); } const descriptor = Object.getOwnPropertyDescriptor(valueArg, String(index)); if ( !descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value') ) { return failArg(`${fieldNameArg} must contain enumerable data entries only`); } } for (const key of Reflect.ownKeys(valueArg)) { if (key === 'length') { continue; } if ( typeof key !== 'string' || !/^(?:0|[1-9][0-9]*)$/.test(key) || Number(key) >= valueArg.length ) { return failArg(`${fieldNameArg} must not contain extra properties`); } } return valueArg; }; export const canonicalizeStrictJson = ( valueArg: unknown, failArg: TCanonicalJsonFailure, fieldNameArg = 'canonical JSON input', ): string => { const ancestors = new Set(); const visit = (currentValueArg: unknown, currentFieldNameArg: string): string => { if (currentValueArg === null) { return 'null'; } if (typeof currentValueArg === 'boolean') { return currentValueArg ? 'true' : 'false'; } if (typeof currentValueArg === 'string') { assertUnicodeScalarString(currentValueArg, currentFieldNameArg, failArg); return JSON.stringify(currentValueArg); } if (typeof currentValueArg === 'number') { if ( !Number.isSafeInteger(currentValueArg) || Object.is(currentValueArg, -0) ) { return failArg(`${currentFieldNameArg} must contain safe integers only`); } return String(currentValueArg); } if (!currentValueArg || typeof currentValueArg !== 'object') { return failArg(`${currentFieldNameArg} contains a non-JSON value`); } if (ancestors.has(currentValueArg)) { return failArg(`${currentFieldNameArg} contains a cycle`); } ancestors.add(currentValueArg); try { if (Array.isArray(currentValueArg)) { const array = readStrictArray( currentValueArg, currentFieldNameArg, failArg, ); return `[${array .map((entryArg, indexArg) => visit(entryArg, `${currentFieldNameArg}[${indexArg}]`), ) .join(',')}]`; } const record = readStrictRecord( currentValueArg, currentFieldNameArg, failArg, ); const keys = Object.keys(record).sort((leftArg, rightArg) => { return leftArg < rightArg ? -1 : leftArg > rightArg ? 1 : 0; }); return `{${keys .map((keyArg) => { assertUnicodeScalarString( keyArg, `${currentFieldNameArg} key`, failArg, ); return `${JSON.stringify(keyArg)}:${visit( record[keyArg], `${currentFieldNameArg}.${keyArg}`, )}`; }) .join(',')}}`; } finally { ancestors.delete(currentValueArg); } }; return visit(valueArg, fieldNameArg); }; export const createSha256Hex = async ( contentsArg: Uint8Array, failArg: TCanonicalJsonFailure, ): Promise => { const subtle = globalThis.crypto?.subtle; if (!subtle) { return failArg('Web Crypto SHA-256 is unavailable in this runtime'); } const digest = await subtle.digest('SHA-256', new Uint8Array(contentsArg)); return Array.from(new Uint8Array(digest), (byteArg) => byteArg.toString(16).padStart(2, '0'), ).join(''); }; export const createCanonicalJsonSha256Hex = async ( canonicalJsonArg: string, failArg: TCanonicalJsonFailure, ): Promise => { return createSha256Hex(new TextEncoder().encode(canonicalJsonArg), failArg); }; export const deepFreezeValue = ( valueArg: T, seenArg = new WeakSet(), ): T => { if (!valueArg || typeof valueArg !== 'object') { return valueArg; } const object = valueArg as object; if (ArrayBuffer.isView(object) || seenArg.has(object)) { return valueArg; } seenArg.add(object); for (const key of Reflect.ownKeys(object)) { const descriptor = Object.getOwnPropertyDescriptor(object, key); if (descriptor && Object.hasOwn(descriptor, 'value')) { deepFreezeValue(descriptor.value, seenArg); } } return Object.freeze(valueArg); };