@sdxc/json-canonical
RFC 8785 JSON Canonicalization Scheme: one byte-exact serialization for hashing and signing JSON
- Installs with
- @sdxc/cli@sdxc/json-pointer@sdxc/result
- Depends on
remix- Source
- packages/json-canonical
RFC 8785 JSON Canonicalization Scheme: one byte-exact serialization for hashing and signing JSON.
JSON.stringify writes members in insertion order, so two equal objects can produce different
text and different hashes. The JSON Canonicalization Scheme
(JCS) fixes every choice: no whitespace, object members sorted by the UTF-16 code units of their
names at every depth, and numbers and strings written exactly as ECMAScript writes them. Input
must be I-JSON: no NaN, Infinity or lone surrogates.
Installation
npm add @sdxc/json-canonicalFallible calls return @sdxc/result values.
Usage
import { canonicalize } from "@sdxc/json-canonical";
canonicalize({ b: [4.5, "€"], a: { d: true, c: null } });
// success('{"a":{"c":null,"d":true},"b":[4.5,"€"]}')
canonicalize(JSON.parse('{"n": 1E30, "x": 0.000001}'));
// success('{"n":1e+30,"x":0.000001}')Refused input
import { canonicalize } from "@sdxc/json-canonical";
import { isFailure } from "@sdxc/result";
let result = canonicalize({ price: NaN });
if (isFailure(result)) result.error.pointer; // "/price"API
canonicalize(value: unknown): Result<string, CanonicalJsonError>
The canonical JSON text of value. Encode it with TextEncoder before hashing or signing, as
RFC 8785 specifies UTF-8. Plain objects (including Object.create(null)), arrays, strings, finite
numbers, booleans and null are accepted; -0 is written 0.
CanonicalJsonError
The failure for NaN, Infinity, a lone surrogate in a string or member name, undefined, a
sparse array hole, a cycle, or any value other than the JSON types above. pointer is the RFC 6901
JSON Pointer to the offending value.
Pattern: Hash A JSON Document
import { canonicalize } from "@sdxc/json-canonical";
import { unwrap } from "@sdxc/result";
async function digest(value: unknown): Promise<string> {
let bytes = new TextEncoder().encode(unwrap(canonicalize(value)));
let hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
(await digest({ a: 1, b: 2 })) === (await digest({ b: 2, a: 1 })); // truePattern: Sign A Payload Both Sides Can Re-Serialize
The sender signs the canonical bytes and sends the JSON in any form; the receiver parses it, canonicalizes again and verifies against the same bytes.
import { canonicalize } from "@sdxc/json-canonical";
import { isFailure } from "@sdxc/result";
async function verify(body: string, signature: ArrayBuffer, key: CryptoKey): Promise<boolean> {
let canonical = canonicalize(JSON.parse(body));
if (isFailure(canonical)) return false;
let bytes = new TextEncoder().encode(canonical.data);
return crypto.subtle.verify("Ed25519", key, signature, bytes);
}Command line
bunx @sdxc/json-canonical --helpWith @sdxc/kit installed, sdxc pkg json-canonical … runs the same command line, using the version your project installs.
canonicalize prints a JSON file's canonical bytes (or stdin's, when the file is - or absent) with no trailing newline; --newline adds one.
bunx @sdxc/json-canonical canonicalize payload.jsonhash prints the digest of those bytes in hex, or unpadded base64url with --base64url; --alg takes sha-256 (the default), sha-384 or sha-512.
bunx @sdxc/json-canonical hash payload.json --alg sha-512 --base64urlText that is not JSON exits 2, and JSON outside I-JSON exits 1 naming its JSON Pointer.