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.
Procedure
Implementations MUST:
- Serialize the message body (excluding the
signaturefield itself) using JCS. - Construct the signature base by joining six fields with
\n:PROTOCOL + "\n" + METHOD + "\n" + PATH + "\n" + recipientDid + "\n" + JCS(body) + "\n" + timestamp, wherePROTOCOLis the message’s signedprotocolvalue,ink/0.1by default or a negotiatedink/0.2. - Sign the resulting byte string with the sender’s Ed25519 private key.
Omitting the PROTOCOL 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 two hazards where a lenient JSON parser rewrites the body before canonicalization, so the check MUST run on the raw body rather than the parsed value, because 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.
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, parse the JSON, apply schema and complexity bounds, then canonicalize and verify the signature. The byte gate and the surrogate scan both run 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.
Both rules are pinned by the cross-implementation corpus: signed-body-utf8 pins raw UTF-8 validity and jcs-string-safety pins the surrogate rule (see Test Vectors). Both are verified by the TypeScript reference and the Go implementation.