Canonicalization
All INK messages are canonicalized using JSON Canonicalization Scheme (JCS, RFC 8785) before signing. This ensures deterministic byte-level representation regardless of JSON serialization order.
Two signatures, two rules
INK signs a message twice, and the two signatures treat the envelope’s own signature member differently. Conflating them is the single most expensive mistake an implementer can make, because it produces a build that verifies its own traffic and nothing else.
| Transport signature (§3.3) | Body signature (§3.6) | |
|---|---|---|
| Where it travels | the Authorization: INK-Ed25519 <sig> header | the envelope’s signature member |
| What is canonicalized | the request body exactly as delivered, nothing removed | the object with its signature member removed |
| Domain | the fixed first line ink/0.1 of the six-field base | the prefix tulpa/sign\n, or ink/sign\n when the signed protocol is exactly ink/0.2 |
Every intent envelope carries a signature member; it is a MUST. So on real traffic the distinction is never academic: a transport base built over the body minus signature is computed over different bytes than the sender signed, and every request fails with invalid_signature.
The corpus pins this in both directions. The signature-base category carries a body that includes a signature member and a transport signature over the whole thing (accept), and the same body against a transport signature computed over the stripped form (reject). An implementation that strips fails the first; one that accepts a stripped base fails the second.
Procedure
To produce the transport signature, implementations MUST:
- Serialize the request body using JCS, with no field removed. The
signaturemember, if the body carries one, is canonicalized along with everything else. - Construct the signature base by joining six fields with
\n:"ink/0.1" + "\n" + METHOD + "\n" + PATH + "\n" + recipientDid + "\n" + JCS(body) + "\n" + timestamp. The first line is always the fixed literalink/0.1for every request, includingink/0.2traffic. The transport signature base never tracks the envelopeprotocolvalue. Only the body-signature domain (see Authentication) selects on the signedprotocol. - Sign the resulting byte string with the sender’s Ed25519 private key.
To produce the body signature, remove the signature member first, canonicalize the remainder with JCS, prefix the domain selected from the signed protocol, and sign that.
Omitting the first ink/0.1 line is the most common implementation bug: signatures from a five-field base will not verify against a six-field one. See Authentication for the full anatomy.
Why JCS
SSB’s original design used JSON.stringify with specific key ordering, which caused interoperability bugs across implementations. JCS (RFC 8785) is a proper IETF standard that defines canonical JSON serialization unambiguously.
Key JCS rules:
- Object keys sorted lexicographically by code point
- No whitespace between tokens
- Numbers serialized per ECMAScript
Number.toString() - Strings escaped per JSON spec with no unnecessary escaping
Signed body string safety
Two implementations that canonicalize the same body to different bytes disagree on the signature, which is a consensus failure. INK closes four hazards where a lenient or defective JSON parser rewrites the body before canonicalization, so each check MUST run on the raw body rather than the parsed value: once the parser has rewritten a byte the original is gone.
Raw UTF-8 validity
A receiver MUST reject a signed body whose raw bytes are not valid UTF-8, before JSON parsing. A lenient decoder replaces an invalid byte sequence with U+FFFD at parse time, so a body that reached canonicalization would be signed over different bytes than an implementation that rejected the invalid bytes. A signer MUST NOT sign a body whose raw bytes are not valid UTF-8. Rejection is the only outcome; a receiver MUST NOT substitute U+FFFD and continue.
The check is on the raw bytes, not a decoded string. A runtime whose string type cannot hold invalid UTF-8, such as JavaScript, has already crossed the byte boundary once it holds the body as a string, because its decoder substituted U+FFFD for any invalid sequence and the original bytes are gone. Such a receiver MUST run the check on the byte buffer before decoding, with a fatal UTF-8 decoder that fails rather than substitutes.
A leading UTF-8 byte-order mark (EF BB BF) is itself valid UTF-8, so the byte gate MUST NOT strip it. The mark decodes to U+FEFF, which is not legal at the start of a JSON document, so a body that begins with a BOM passes the byte gate and then rejects at the JSON parse step. A byte gate that silently strips the BOM would accept a body whose canonical bytes differ from what the signer signed.
Lone surrogates
A receiver MUST reject any signed JSON body whose raw text carries a \uXXXX escape for an unpaired UTF-16 surrogate in any member name or string value, before JSON parsing. In JSON a lone surrogate can only appear as an escape, because UTF-8 cannot encode a surrogate as raw bytes, and a lenient parser replaces it with U+FFFD the same way an invalid byte sequence is replaced. A signer MUST refuse to sign a body that carries a lone surrogate. A backslash escapes the next character, so a literal \\uD800 is text and is accepted. A valid pair such as an emoji is accepted. Hex digits are case-insensitive.
Number literals outside the double range
A receiver MUST reject a signed body whose raw text contains a number literal whose value is outside the IEEE-754 double range, before JSON parsing. A signer MUST NOT sign such a body.
Here the parsers do not merely represent the value differently, they disagree about whether the document exists. ECMAScript JSON.parse decodes 1e309 to Infinity and returns the document; Go’s encoding/json refuses the whole document with a range error. A literal that underflows, such as 1e-400, is unaffected: every IEEE-754 parser decodes it to 0.
The rule lives at the raw gate rather than after parsing because the number profile is a check on decoded values, and a value the parser never produces is a value the profile never sees. JSON member semantics are last-wins, so a duplicate member shadows the literal: {"a":1e309,"a":1} decodes to {"a":1} under a tolerant parser and gets a signature verified over those canonical bytes, while a strict parser rejects the body outright. The check reads number-like characters only outside strings, so {"note":"1e309"} carries no literal and is accepted.
Escaped member names
A receiver MUST reject a signed body whose raw text contains an object member name written with any escape sequence, before JSON parsing. A signer MUST NOT sign an object whose keys contain a quotation mark, a reverse solidus, or a character in U+0000-U+001F, since those are exactly the characters JCS must escape. U+007F is not escaped and stays permitted.
The three rules above address a parser that represents a value differently or refuses a document. This one addresses a parser that returns a different member name than the document contains. V8 sizes the character span for a member name from a pointer into the raw source text using the name’s decoded length, then compares that span against an existing hidden-class transition name and, on a match, adopts the transition’s name without decoding the escape. So {"x":{"\\":1},"y":{"\n":2}} yields a y whose sole member is named \. The wrong name is a real property, so it survives serialization and reaches canonicalization.
Being affected requires the member name’s raw spelling to be longer than its decoded value, which requires an escape. Banning escaped member names removes the precondition outright, whatever a given runtime holds in its transition tables. Detecting the corruption after parsing is not a workable substitute: the recovered names cannot be compared as a set, because {"a":1,"\u0061":2} legitimately collapses to a single member, and a substituted name can coincide with a name the document already contains.
The rule applies to member names only. An escape in a string value or array element is unaffected, so {"note":"line\nbreak"} is accepted. A string is a member name exactly when the next non-whitespace character after its closing quotation mark is a colon.
An implementation whose JSON parser decodes escaped member names correctly MUST enforce this rule anyway. The point is not to protect that implementation but to keep the set of admitted bodies a property of the protocol: an implementation that accepted such a body would disagree with a conforming one about which bytes a signature covers.
Enforcement order
A receiver processes a signed body in this order, rejecting at the first failure: enforce the size cap, reject raw bytes that are not valid UTF-8, reject any unpaired surrogate escape in the raw text, reject any number literal outside the IEEE-754 double range, reject any object member name written with an escape sequence, parse the JSON, apply schema and complexity bounds, then canonicalize and verify the signature.
Every raw-text check runs before the parse step, because once the JSON is parsed the raw provenance is gone. The surrogate scan still runs after the UTF-8 check passes, because a lone surrogate escape is itself valid UTF-8. The number-literal check cannot move after the parse in any form, because a parser that rejects the document never reaches that point and one that does not has already discarded the shadowed literal. The member-name check cannot move after the parse either, because a substituted name is indistinguishable from a name the sender chose.
All four rules are pinned by the cross-implementation corpus: signed-body-utf8 pins raw UTF-8 validity and the number-literal range, jcs-string-safety pins the surrogate rule, and signed-body-member-name pins the escaped member-name rule (see Test Vectors). All four are verified by the TypeScript reference and the Go implementation.