Skip to content

Canonical JSON

Why canonical JSON?

For a digital signature to be verifiable, the bytes that were signed must be reproduced exactly. Standard JSON.stringify() is non-deterministic: different JavaScript runtimes may order object keys differently, and floating point serialization can vary.

PQSafe uses Canonical JSON — a deterministic subset of JSON — to ensure every signer and verifier produces identical bytes.

Algorithm

PQSafe’s canonical JSON implementation follows RFC 8785 (JCS — JSON Canonicalization Scheme):

  1. Keys sorted lexicographically (Unicode code point order, ascending)
  2. No whitespace between tokens
  3. Numbers: IEEE 754 double-precision, no trailing zeros, no scientific notation for integers
  4. Strings: UTF-8, no unnecessary escaping (only \", \\, \n, \r, \t, \uXXXX for control chars)
  5. Arrays: preserve element order
  6. null, true, false: lowercase literals

Example

Input (unordered):

{
"validUntil": "2026-04-26T12:00:00.000Z",
"maxAmount": 50,
"agentId": "my-agent",
"allowedRails": ["airwallex"],
"currency": "USD"
}

Canonical output:

{"agentId":"my-agent","allowedRails":["airwallex"],"currency":"USD","maxAmount":50,"validUntil":"2026-04-26T12:00:00.000Z"}

Note: keys are alphabetically ordered (agentIdallowedRailscurrencymaxAmountvalidUntil).

Usage in the SDK

import { canonicalizeEnvelope } from '@pqsafe/agent-pay'
const bytes = canonicalizeEnvelope(envelope) // Returns Uint8Array
const hex = Buffer.from(bytes).toString('hex')
// This is what gets passed to ml_dsa65.sign()
const signature = ml_dsa65.sign(secretKey, bytes)

Debugging signature failures

If verifyEnvelope() returns false, the most common cause is envelope mutation after signing:

// Bug: modifying envelope after signing
const signed = createSignedEnvelope(envelope, secretKey)
envelope.memo = 'changed' // ← invalidates signature!
verifyEnvelope(signed, publicKey) // false
// Fix: create the envelope with final values before signing
const envelope = createSpendEnvelope({ ..., memo: 'final value' })
const signed = createSignedEnvelope(envelope, secretKey)

The SDK’s createSignedEnvelope() takes a snapshot of the envelope at signing time — but if you hold a reference to the original envelope object and mutate it, the stored canonical bytes and the live object diverge.

Cross-language interoperability

The Python SDK produces identical canonical JSON for the same input, ensuring TypeScript-signed envelopes can be verified in Python and vice versa.

from pqsafe_agent_pay import canonicalize_envelope, verify_envelope
canonical = canonicalize_envelope(envelope_dict)
is_valid = verify_envelope(signed_envelope, public_key)