Skip to content

chicong065/json-freeze

Repository files navigation

json-freeze

RFC 8785 Canonical JSON for JavaScript.

JSON.stringify({ name: 'Alice', age: 30 })
// '{"name":"Alice","age":30}'

JSON.stringify({ age: 30, name: 'Alice' })
// '{"age":30,"name":"Alice"}'

// Same data. Different bytes. Any hash, signature, or content
// address built on top of JSON.stringify silently breaks.
import { canonicalize } from 'json-freeze'

canonicalize({ name: 'Alice', age: 30 })
// '{"age":30,"name":"Alice"}'

canonicalize({ age: 30, name: 'Alice' })
// '{"age":30,"name":"Alice"}'

// Same data. Same bytes. That's what RFC 8785 guarantees.

json-freeze implements RFC 8785 (JSON Canonicalization Scheme), the IETF standard for canonical JSON. It's written in pure TypeScript, has no runtime dependencies, and runs unchanged in Node, Bun, Deno and the browsers.

Features

  • Full RFC 8785 compliance
  • String or Uint8Array output for hashing and signing
  • Typed errors with code and path
  • JSON.stringify-compatible replacer for BigInt, Date, and custom types
  • CLI for canonicalizing JSON from the shell

Installation

npm install json-freeze
# or
pnpm add json-freeze
# or
yarn add json-freeze

Quick start

Hash a payload in Node

A canonical hash is the cheapest content identifier you can compute. Two processes on different machines that hold the same logical payload produce the same hash, no matter how each one serialized it.

import { canonicalizeBytes } from 'json-freeze'
import { createHash } from 'node:crypto'

function contentId(value: unknown): string {
  return createHash('sha256').update(canonicalizeBytes(value)).digest('hex')
}

const orderId = contentId({
  userId: 'acct_42',
  amount: 1500,
  currency: 'USD',
  lineItems: [
    { sku: 'book', quantity: 1 },
    { sku: 'mug', quantity: 2 },
  ],
})

Sign and verify in the browser

The signer and the verifier typically run in different processes, often on different machines. Each side calls canonicalizeBytes independently, so the input to the signing and verifying algorithms is byte identical no matter how each side received the payload.

import { canonicalizeBytes } from 'json-freeze'

// Runs on the producer, which holds the private key.
async function sign(payload: unknown, privateKey: CryptoKey) {
  return crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, canonicalizeBytes(payload))
}

// Runs on the verifier, which holds the matching public key.
async function verify(payload: unknown, signature: ArrayBuffer, publicKey: CryptoKey) {
  return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature, canonicalizeBytes(payload))
}

Canonicalize from the shell

The json-freeze CLI reads JSON from stdin or a file and prints the canonical form to stdout.

echo '{"b":2,"a":1}' | json-freeze
# {"a":1,"b":2}

json-freeze payload.json
# {"amount":1500,"currency":"USD"}

API reference

canonicalize(value, options?): string

Returns the RFC 8785 canonical string for value. Throws CanonicalizeError on any value that cannot be canonicalized, such as BigInt, NaN, or a circular reference.

canonicalizeBytes(value, options?): Uint8Array

Returns the UTF-8 byte sequence of the canonical string. Equivalent to new TextEncoder().encode(canonicalize(value, options)) but avoids the manual encoding step at the call site. Same throwing contract as canonicalize.

Options

type Options = {
  replacer?: (this: unknown, key: string, value: unknown) => unknown
}

The replacer matches the second argument to JSON.stringify. It runs after any toJSON method on the value. Return a transformed value to substitute it, or return undefined to drop an object key or emit null in an array position.

canonicalize(
  { id: 9007199254740993n },
  {
    replacer(_key, value) {
      if (typeof value === 'bigint') {
        return value.toString()
      }
      return value
    },
  }
)
// {"id":"9007199254740993"}

CanonicalizeError

class CanonicalizeError extends Error {
  readonly name: 'CanonicalizeError'
  readonly code: CanonicalizeErrorCode
  readonly path: readonly string[]
}

path is the route from the root to the failing value. Each segment is a property name or a stringified array index. An empty array means the root itself.

code Triggered by
UNSUPPORTED_TYPE BigInt, Symbol, Function, or a root value that resolves to undefined
NON_FINITE_NUMBER NaN, Infinity, -Infinity
CIRCULAR_REFERENCE An object or array that references one of its ancestors
DUPLICATE_KEY A replacer produced two entries with the same key

JsonValue

type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }

Recursive union covering every value the library can serialize without a replacer. Use it as a parameter type when you want the compiler to reject non spec inputs at the call site.

CLI

Usage: json-freeze [file]

Reads JSON from stdin or the given file, writes the RFC 8785 canonical form
to stdout, and exits. A trailing newline is appended for shell friendliness.

Options:
  -h, --help    show this help text

Exit codes:
  0  success
  1  input, file, or JSON parse error
  2  canonicalization error (unsupported type, non finite number, cycle)

License

json-freeze is open-source software licensed under the MIT License.

About

RFC 8785 Canonical JSON for JavaScript.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors