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):
- Keys sorted lexicographically (Unicode code point order, ascending)
- No whitespace between tokens
- Numbers: IEEE 754 double-precision, no trailing zeros, no scientific notation for integers
- Strings: UTF-8, no unnecessary escaping (only
\",\\,\n,\r,\t,\uXXXXfor control chars) - Arrays: preserve element order
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 (agentId → allowedRails → currency → maxAmount → validUntil).
Usage in the SDK
import { canonicalizeEnvelope } from '@pqsafe/agent-pay'
const bytes = canonicalizeEnvelope(envelope) // Returns Uint8Arrayconst 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 signingconst signed = createSignedEnvelope(envelope, secretKey)envelope.memo = 'changed' // ← invalidates signature!verifyEnvelope(signed, publicKey) // false
// Fix: create the envelope with final values before signingconst 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)