ADR-014 — Canonical form and signature encoding

Status: Accepted. 6 August 2026.

Context

Every H2A claim reduces to one operation: two parties independently derive the same bytes from the same JSON document, and one signature over those bytes verifies under both. Grants, decision records, attestations, status list credentials and audit chain hashes are all signatures or hashes over a canonicalised document. If two implementations canonicalise differently, a signature written by one is invalid under the other — and the evidence test in IMPL-00 §1.3 ("a stranger verifies it offline") fails by construction.

This ADR exists because the estate had four canonicalisers and two signature encodings, and no document said which was correct. Measured 5–6 August 2026:

Implementation Key ordering Non-ASCII Numbers Signature
hdicr vc-signer.ts (ecdsa-jcs-2019) RFC 8785 JCS (canonicalize npm) literal UTF-8 ECMAScript raw R‖S
h2a TS references ×4 String.prototype.localeCompare literal UTF-8 ECMAScript DER
h2a Python h2a_ref/verify.py:28 sort_keys=True (code point) \uXXXX escaped Python repr DER
Bridle core canonical() + stable() localeCompare literal UTF-8 ECMAScript DER

The two non-conforming implementations are wrong in disjoint ways — the TypeScript hand-roll sorts incorrectly but serialises correctly, and the Python reference sorts correctly but serialises incorrectly. Neither is a subset of the other, and both agree with JCS on the payloads people test with (lowercase ASCII keys, integer values). They diverge on exactly the payloads that matter:

document                         hand-rolled TS                        RFC 8785 JCS
{"Zeta":1,"alpha":2,"Beta":3}    {"alpha":2,"Beta":3,"Zeta":1}         {"Beta":3,"Zeta":1,"alpha":2}
{"10":"ten","2":"two"}           {"2":"two","10":"ten"}                {"10":"ten","2":"two"}

document                         Python h2a_ref                          RFC 8785 JCS
{"subject_ref":"José Muñoz"}     {"subject_ref":"Jos\u00e9 Mu\u00f1oz"}  {"subject_ref":"José Muñoz"}
{"cap":500.0}                    {"cap":500.0}                           {"cap":500}

A performer whose name carries a diacritic, or a lease cap written 500.0 rather than 500, is enough to make a grant unverifiable across the estate. That is the whole failure mode, and it is silent: every implementation reports a valid signature over its own bytes.

Signature encoding is a second, independent split. Measured with a P-256 key over one message:

h2a/Bridle verifier (Node default = DER)  accepts DER      : true
h2a/Bridle verifier (Node default = DER)  accepts raw R‖S  : false   <- a conforming ES256 signer is rejected
JOSE / hdicr verifier (ieee-p1363)        accepts raw R‖S  : true
JOSE / hdicr verifier (ieee-p1363)        accepts DER      : false   <- the estate's own signatures are rejected

SPEC-CORE and ADR-008 declare alg: "ES256". ES256 is defined by RFC 7518 §3.4, which specifies the fixed-width R‖S concatenation, not DER. The estate's own signatures did not satisfy the algorithm they named.

Decision

1. RFC 8785 JCS is the normative canonical form. All H2A signing and hashing operates on the UTF-8 bytes of the RFC 8785 canonical serialisation of the document. Three consequences are called out because each is a live defect somewhere in the estate:

2. alg: "ES256" means the JOSE fixed-width encoding — raw R‖S, 64 bytes, never DER. Each of r and s is left-padded with zeros to exactly 32 bytes. Implementations MUST emit raw R‖S and SHOULD reject DER on the verification path. Where the signing device returns DER — AWS KMS and Node's createSign both do — conversion happens at the boundary, before the signature reaches any H2A document. Reference converters are derToRawSignature / rawToDerSignature; a DER-tolerant verification path is permitted only behind an explicit legacy_der flag, and only for signatures written before this ADR.

3. Detached signature preimage. A grant's signatures[] entries sign the JCS bytes of the grant with the signatures member removed — not emptied, removed. Objects carrying a singular signature member sign the JCS bytes of the object with that member removed. Nothing else is stripped; in particular signature_state MUST NOT appear inside a signed document (see B9).

4. The vectors in interop/vectors/ are normative. vectors.json is part of this decision, not an illustration of it. Where prose and vectors disagree, the vectors are wrong and both get fixed — but an implementation that reproduces every vector byte-for-byte is conformant, and one that does not is not, regardless of what its own tests say. Vector 01-rfc8785-appendix-b is RFC 8785's own published test data, so the file is anchored to the RFC rather than to any single library.

5. One canonicaliser per language, at a published import path. Not one per repository and not one per service. The estate's four hand-rolled copies exist because canonicalisation looked small enough to inline — ~8 lines each, and every copy was wrong in the same two ways.

Consequences

References