Canonical encoding for receipts
Alcove receipts -- the self-attested record and the certificate-backed non-repudiation receipt (see PROTOCOL.md) -- are hashed and signed over a byte string. For a hash or signature to verify, every party that produces or checks a receipt must derive exactly the same bytes from the same logical object. That includes an independent third party -- an auditor running a different implementation, possibly in a different language -- who has only the receipt, the certificate, and this document.
The Canonical encoding overview covers what this encoding is for and what it protects against; this document is its normative complement -- the specification of that byte string. It is written so the bytes can be reproduced without reading the Alcove source. Wherever a detail is delegated to an external standard, that standard is cited; nothing is left to an implementation's discretion.
Normative reference
The canonical encoding is RFC 8785, JSON Canonicalization Scheme (JCS), applied to the restricted value domain in Value domain below. An implementation that produces RFC 8785 output for an object in that domain produces the correct Alcove canonical bytes; the rules in Encoding rules restate the parts of RFC 8785 that matter here, plus the additional constraints Alcove imposes so that the input domain is always reproducible.
There is no exception to that equivalence. Across the whole domain, RFC 8785 output is Alcove output, so an implementation written to the RFC alone reproduces every canonical byte string Alcove produces. The domain admits only a well-formed string, which is what keeps the two in step over the one case where an encoder could otherwise diverge: RFC 8785 section 3.2.2.2 requires a compliant implementation to terminate with an error on invalid Unicode data such as a lone surrogate, and Alcove refuses such a value rather than encoding it (see Strings).
That equivalence rests on the canonicalize package's conformance to RFC 8785, which Alcove delegates to and has checked only against the test vectors committed in this repository. Its conformance beyond those vectors is unverified as of 2026-09-29.
RFC 8785 builds both of its primitive serializations on ECMAScript (ECMA-262): number-to-string conversion (Number.prototype.toString, equivalently JSON.stringify of a number, cited as ECMA-262 section 7.1.12.1) and JSON string production (JSON.stringify of a string, cited as ECMA-262 section 24.3.2.2 and restated in the RFC, which adds the lone-surrogate rule above). Both are fully specified by ECMA-262 and produce identical results in Node.js and every browser, which is why the canonical bytes are platform-independent.
Scope
This encoding is the single canonicalization primitive for everything that a party or a verifier re-encodes to check a hash, commitment, or signature: the agreed-terms object embedded in a receipt, the commitments and agreed-terms hash of the self-attested record, the receipt itself, and the signing certificate. The record's own serialized form is ordinary pretty-printed JSON rather than canonical bytes, since the record is unsigned. The certificate's self-signature is computed over the domain-separated canonical bytes of its body, and its pinned fingerprint is a SHA-256 of those bytes, each under its own distinct domain label (see PROTOCOL.md). It supersedes ad hoc JSON.stringify and key-sorting for those artifacts. Equality checks that must match a hashed form -- for example the cross-party linkage-terms comparison in validateCompatibility -- use it too, so that "equal" means "hashes equal".
It is not the wire format for exchange messages or configuration files; those remain ordinary JSON/YAML.
Two hashed values are computed over ordinary JSON.stringify bytes instead, because each is checked over the exact bytes received rather than re-encoded: the invitation checksum (a truncated SHA-256 of the token's JSON bytes) and the terms-update MAC (over the update body's JSON bytes, see EXCHANGE_FILE.md).
Value domain
A value to be canonically encoded is one of:
| Kind | Notes |
|---|---|
| string | a well-formed JavaScript string: every UTF-16 surrogate is paired, so the string has a UTF-8 encoding (see Strings) |
| number | finite; integers must be safe (see Numbers) |
| boolean | true or false |
| null | the JSON null literal |
| array | ordered list of values in this domain |
| object | unordered set of string-keyed members whose values are in this domain |
Everything else is rejected rather than coerced -- as is a value of the shapes above that contains a member an encoder would substitute or drop -- because silent coercion is the classic way two implementations diverge:
undefined-- as an object member value, an array element, or the top-level value. An absent field is expressed by omitting the key, never by a member set toundefined(see Absent versus null).- Non-finite numbers (
NaN,Infinity,-Infinity). - Integers outside the safe range (see Numbers).
bigint, symbols, and functions.- Non-plain objects: dates, maps, sets, typed arrays, class instances, and anything else that is not a plain JSON object. Binary data is not a member of the domain; it must be encoded to a string first (see Binary fields).
- A callable
toJSONon an object or an array, own or inherited, enumerable or not. An encoder that honored it would serialize whattoJSON()returns instead of the value it was handed, so such a value is rejected even though its shape is otherwise in the domain. - A sparse array hole -- a missing element, as at the second position of
[1, , 3]. An explicit gap is written asnull. - An own property of an array that is not an element index (
lengthaside), and a symbol-keyed property on an array or an object. Only the elements[0, length)and an object's string keys are encoded, so such a property would be dropped without a trace. - A non-enumerable string-keyed property of an object, which an encoder enumerating keys would drop, and an accessor (getter or setter) property, which can yield a different value on each read. These checks cover the properties of plain objects and arrays; an exotic object such as a Proxy, whose reads need not match its property descriptors, is outside the domain the encoder is given. On an array too large for the engine to list every own property name (Node refuses to enumerate 2^24 or more keys), the encoder checks the enumerable string keys instead, so a non-enumerable named property on such an array is not detected; every value this implementation encodes is built by its own code or parsed from JSON, neither of which produces one.
- A string -- a value or an object key -- holding a lone UTF-16 surrogate. It is not a Unicode scalar value and has no UTF-8 encoding (see Strings).
A conforming implementation MUST reject each of these with an error rather than emit bytes for it.
Encoding rules
Object member ordering
Object members are sorted by their keys, and the sort is applied recursively at every level of nesting. Keys are compared by UTF-16 code unit: each key is viewed as the sequence of 16-bit code units of its UTF-16 encoding, and keys are ordered by the first position at which they differ, by numeric code-unit value. This is the ordering produced by ECMAScript Array.prototype.sort with no comparator, and is the ordering RFC 8785 specifies. The encoder's input holds each key once. Two keys of a parsed document would become one before encoding when the key fold below maps both to the same name (my_param and myParam), so every parse of linkage terms -- the operator's own and a partner's alike -- refuses such a document (see the key-fold collision rule below).
Array element order is significant and is preserved as given.
The encoder sorts keys but does not fold their casing: two objects whose keys differ only in snake_case-vs-camelCase form ({"input_format": ...} vs {"inputFormat": ...}) encode to different bytes. This matters for the partner-controlled transform.params keys, the only linkage-terms keys whose form could vary. They are normalized to camelCase at every parse path that produces a LinkageTerms, each by a camelizeKeys pass before validating: the post-handshake wire path (parseLinkageTerms), config load (parseExchangeSpec over the whole exchange file, and safeParseLinkageTermsTheReaderWrote over the linkage_terms block alcove verify-receipt re-reads), and the invitation decode path. So by the time terms reach a canonical encoding they are already camelCase on both sides: a casing fold is a parse-layer invariant, not something the consumers re-do. The agreed-terms hash (computeTermsHash) and the cross-party validateCompatibility comparison therefore both encode the same camelCase form without folding, which is what keeps the hash cross-party reproducible.
A third-party implementation reproducing the agreed-terms hash MUST apply the identical key fold before encoding, because the fold is byte-significant and is a minimal rewrite, not a general snake_case-to-camelCase conversion. The normative rule: in every object key throughout the terms (applied recursively), replace each underscore immediately followed by an ASCII lowercase letter (a-z) with that letter upper-cased and drop the underscore; leave every other character unchanged, and never transform string values, only keys. It is exactly the substitution s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()). The edge cases a reproducer must match: an underscore not followed by an ASCII lowercase letter is left intact, so input_format -> inputFormat but INPUT_FORMAT -> INPUT_FORMAT (the _F does not fold), field_1 -> field_1 (digit), and input__format -> input_Format (only the second underscore folds); a leading underscore before a lowercase letter still folds and is dropped, so _format -> Format; an already-camelCase key has no underscore-plus-lowercase and is unchanged. The fold applies everywhere except inside the two unfolded subtrees below. Alcove emits only camelCase keys, so a token it produced is already in this normal form -- the fold is observable only for a hand-authored or third-party token containing snake_case keys.
A reproducer MUST leave two subtrees of the terms unfolded, encoding every key inside them exactly as written, at every depth. The key that names the subtree is itself folded; only the value under it is left as written.
- A provider-options subtree. The value of any key that folds to
providerOptions, at any depth and of any type. The key is matched by its folded name, not by its path, soprovider_optionsnames one too, including insidetransform.params:{"provider_options": {"input_format": "x"}}encodes as{"providerOptions": {"input_format": "x"}}. - An over-wide params object. The value of any key that folds to
params, at any depth, when that value is an object (not an array ornull) holding more than 256 own keys (MAX_PARAMS_ENTRIES). Aparamsobject of 256 keys or fewer is folded, and an array underparamsis walked and folded as anywhere else. Atransform.paramsholding more than 256 entries is refused by the schema; this exception decides what is folded, not what is accepted.
A parse of linkage terms MUST refuse a document in which one object holds two keys that the fold maps to the same name -- my_param beside myParam, or provider_options beside providerOptions -- rather than keep either member, and the refusal names both keys. Keeping one would make the agreed-terms hash depend on which member a reader kept. Keys inside an unfolded subtree are not folded, so two of them never collide.
A second parse-layer rewrite is byte-significant in the same way. A payload.send or payload.receive list naming a column more than once parses to one entry per name at every path that produces a LinkageTerms: the first entry naming a column stands, with its own description, and a later entry naming it is dropped. The encoder is handed a list holding each column once and preserves its order as given. A third-party implementation reproducing the agreed-terms hash MUST apply the same collapse before encoding, and MUST apply it to both lists, since a document holding a repeat encodes to different bytes with and without it.
Two entries name the same column when their name strings are equal code unit for code unit, the comparison the member ordering above uses. No Unicode normalization is applied: a name holding U+00E9 (NFC) and one holding e followed by the combining acute U+0301 (NFD) are two distinct columns, and both entries stand. Names are not case folded either, so dose and Dose are distinct, and whitespace is significant, so dose is not dose. A reproducer that folds any of these collapses entries Alcove keeps, and encodes different bytes.
The collapse changes the agreed-terms hash for a terms document that carries a repeated payload column name: an exchange record or signed receipt a pre-collapse build produced over such a document verifies as a terms-hash mismatch against this build.
Numbers
A number MUST be finite. RFC 8785 section 3.2.2.3 does not restate a number algorithm; it mandates ECMA-262 section 7.1.12.1 (with that section's "Note 2" enhancement) and gives sample serializations in its Appendix B. That algorithm is what ECMAScript Number.prototype.toString performs (equivalently, JSON.stringify of the number): the shortest decimal string that round-trips to the same IEEE-754 double, with -0 rendered as 0 (RFC 8785 Appendix B), no insignificant trailing zeros, and exponential form only outside the magnitude range that same ECMA-262 section fixes.
Alcove adds one constraint to keep the input reproducible: an integer-valued number MUST be a safe integer, i.e. its absolute value is at most 2^53 - 1 (9007199254740991). Beyond that range a decimal integer literal in the source JSON may not round-trip to the same double across implementations, so such a value MUST instead be encoded as a string by the producer. Finite non-integer numbers are permitted and follow the rule above without further constraint.
Receipt and record numeric fields that are hashed or signed (result sizes, row counts) are safe integers by schema -- they are validated as safe integers before a receipt is assembled -- so for those fields the number format reduces to a plain decimal integer with no exponent.
Strings
Strings are serialized exactly as ECMAScript JSON.stringify serializes a string, which is what RFC 8785 requires:
- The string is delimited by
". - These characters use two-character escapes:
"becomes\",\becomes\\, backspace (U+0008) becomes\b, tab (U+0009) becomes\t, line feed (U+000A) becomes\n, form feed (U+000C) becomes\f, carriage return (U+000D) becomes\r. - Any other control character in U+0000 through U+001F is escaped as a six-character backslash-u-zero-zero-XX sequence with lowercase hexadecimal digits (U+0000 and U+001F escape to their lowercase six-character forms, shown exactly in the
control-character-escapestest vector). - The forward slash
/is not escaped. - Every other character, including all non-ASCII characters, is emitted as its raw UTF-8 bytes -- it is not
\u-escaped. For example the euro sign (U+20AC) is emitted as the three bytesE2 82 AC. - A string holding a lone surrogate (an unpaired UTF-16 surrogate code unit) is refused: the encoder terminates with an error and emits no bytes, as RFC 8785 section 3.2.2.2 requires for invalid Unicode data. A lone surrogate is not a Unicode scalar value and has no UTF-8 encoding, so there are no bytes for it to have. The refusal covers an object key as well as a string value, since both are serialized as JSON strings. The
lone-surrogate,lone-surrogate-low, andlone-surrogate-object-keytest vectors fix the refusal; a well-formed surrogate pair is unaffected and emits its raw UTF-8 bytes, as theastral-emojivector fixes. A linkage terms document an earlier build accepted over such a value is refused beforealcove verify-receiptreaches a verdict: its--config-fileand--partner-termsinputs both load their document through the linkage terms parse, which refuses it and reports the CLI's own invalid-linkage-terms usage error, naming the field that holds the value (config file <path> has invalid linkage_terms: legal_agreement.reference: a linkage terms text value must not contain an unpaired UTF-16 surrogate) rather than a terms-hash mismatch. The partner's terms a signed receipt retains in its envelope are read under that same parse, so a record holding such a document is refused as an unreadable artifact rather than verified against terms the encoder cannot reproduce.
A linkage terms document MUST be well-formed UTF-16 throughout -- every string value, every array element, and every object key, the partner-named transform.params keys included -- and the terms schema (packages/core/src/config/linkageTermsSchema.ts) refuses one that is not on every parse path, so an exchange refuses such a document where the partner's terms are read rather than at the agreed-terms hash, which is computed after the exchange has disclosed.
A received payload frame MUST be well-formed UTF-16 the same way -- every column name and every cell -- and the payload wire schema (packages/core/src/payloadExchange.ts) refuses one that is not where the frame is parsed. Those names and values are committed verbatim by the receipt MACs and by the record's payload commitments, which are likewise computed after the exchange has disclosed.
Absent versus null
null is a value; an absent member is the absence of a key. They are distinct and encode differently: {"a":1} and {"a":1,"b":null} are different objects with different bytes. A member whose value would be undefined is not "null" and is not "absent in a tidy way" -- it is rejected. Producers express "no value" by leaving the key out.
Binary fields
Binary data embedded in a receipt or record -- salts, signatures, certificate fingerprints, certificate blobs -- is encoded as a base64url string without padding (the URL- and filename-safe alphabet of RFC 4648 section 5, A-Z a-z 0-9 - _, with no trailing =). That section fixes the alphabet and nothing else here: dropping the padding is section 3.2's allowance, which requires the pad characters "unless the specification referring to this document explicitly states otherwise", and this document is that specification, so stated. The producer encodes the bytes to that string before the value enters the object; the canonical encoder then treats it as an ordinary string.
There is exactly one byte-to-string encoding for binary data, so the canonical form is reproducible -- but that follows from RFC 4648 section 3.5 rather than from the alphabet: a canonical encoding is guaranteed only where the encoder sets the bits padding the final character to zero, which conforming encoders MUST do. These strings are hashed as strings, so a producer that left those bits set would map the same bytes to a different canonical encoding. A reproducer must encode as that section requires.
Final byte string
The canonical byte string is the UTF-8 encoding of the canonical character string defined by the rules above. UTF-8 is emitted without a byte order mark. This byte string is what is hashed and signed; hashing and signature details belong to the receipt itself (see PROTOCOL.md).
An implementation may produce the byte string in consecutive pieces, provided the pieces concatenate to exactly the byte string above. Nothing in the hashed or signed result shows how it was produced.
Worked examples
In each row, encoding the value yields the canonical string shown; the receipt bytes are that string's UTF-8 encoding. Backslash escape sequences in the canonical strings below are literal backslash sequences, not the control characters themselves.
| Value | Canonical string |
|---|---|
{"b":1,"a":2,"c":3} | {"a":2,"b":1,"c":3} |
{"z":{"b":1,"a":2},"a":[3,2,1]} | {"a":[3,2,1],"z":{"a":2,"b":1}} |
{"n":9007199254740991} | {"n":9007199254740991} |
{"n":-0} | {"n":0} |
{"a":null,"c":false,"b":true} | {"a":null,"b":true,"c":false} |
an object {s} whose value is the euro sign | {"s":"<euro as raw UTF-8 E2 82 AC>"} |
an object whose only member s holds control characters | see the control-character-escapes test vector for the exact bytes |
{"sig":"f39_","salt":"3q2-7w"} | {"salt":"3q2-7w","sig":"f39_"} |
The euro and control-character rows are described rather than shown literally to keep this document free of raw non-ASCII and control bytes; their exact value, canonical, and bytesHex are in the test vectors below (non-ascii-string, astral-emoji, and control-character-escapes).
Test vectors
A machine-readable set of test vectors is checked in at packages/core/test/vectors/canonical-vectors.json. It is a JSON object with a vectors array; each entry has:
| Field | Meaning |
|---|---|
name | short identifier |
description | what the vector exercises |
value | the input value |
refuses | present and true on a vector the encoder MUST reject; such a vector has none of the three fields below |
canonical | the expected canonical character string |
bytesHex | the expected canonical byte string, as lowercase hex |
sha256Hex | the SHA-256 of bytesHex's bytes, as lowercase hex |
An independent implementation reproduces the bytes by encoding each value and checking the result equals bytesHex (and, if it also hashes, that the SHA-256 equals sha256Hex). A vector marked refuses is the other half of the contract: encoding its value MUST terminate with an error rather than produce any bytes.
One normative case is absent from the file: the -0 to 0 normalization (Numbers and the worked example {"n":-0} -> {"n":0}). JSON has no negative zero -- a -0 literal parses to 0 -- so it cannot be represented as a JSON value. Verify it from a -0 literal in your own language; the Node suite does so directly.
The Alcove test suite runs these vectors in both Node.js (packages/core/test/utils/canonical.test.ts) and a real browser (apps/web/test/browser/canonical.test.ts), asserting byte-identical output on both platforms.
Implementation note (non-normative)
Alcove implements the encoding in packages/core/src/utils/canonical.ts. It delegates the RFC 8785 serialization to the canonicalize package -- the scheme author's reference implementation -- behind a strict pre-validation pass that rejects every out-of-domain value listed above rather than letting the underlying serializer coerce it. canonicalString(value) returns the canonical character string and canonicalBytes(value) returns its UTF-8 bytes. Numeric schema fields use safeIntegerSchema. canonicalize calls the ES2024 String.prototype.isWellFormed; where an engine lacks it, canonicalString installs one built on the same lone-surrogate scan the pre-validation uses. None of this is required to reproduce the bytes; the normative definition is RFC 8785 over the value domain above.
writeCanonicalBytes(value, write) passes write the same bytes in consecutive chunks of about 64 Ki UTF-16 code units of text each, after validating the whole value. It writes the brackets, commas, colons and member names itself, ordering members by UTF-16 code units, and takes each primitive's encoding from canonicalize, so a chunk ends only between two whole tokens. For the exchange record's commitments and the receipt's payload MACs, packages/core/src/utils/canonicalHmac.ts copies these chunks into one byte array and computes HMAC-SHA-256 over it with one crypto.subtle call, so the encoding is never held as one string, whose length V8 caps. The chunked writer descends one call per nesting level where canonicalize keeps its own stack, so a value nested more deeply than a few thousand levels exhausts the call stack and throws CanonicalEncodingError there while canonicalBytes still encodes it (measured on Node 26.10: depth 3000 encodes both ways, depth 5000 only through canonicalBytes). The record and receipt values nest at most 3 levels.
The canonicalize package is inlined into @alcove/core's built artifacts rather than resolved at runtime; why, and what that costs when an advisory lands against it, are in DEPENDENCY_PINS.md.
See also
- SECURITY_DESIGN.md - the Canonical encoding overview: what the encoding is for and what it protects against
- PROTOCOL.md - how receipts use these bytes
- EXCHANGE_RECORD.md - the self-attested record whose commitments and agreed-terms hash are computed over these bytes
- EXCHANGE_REFERENCE.md - the exchange specification whose linkage terms are embedded, canonically encoded, in a receipt
- RFC 8785 - JSON Canonicalization Scheme
- RFC 4648 - base64url: the alphabet in section 5, the unpadded form in section 3.2, the zero pad bits a canonical encoding rests on in section 3.5