Skip to content

ADR 0027 — Cross-language signing contract (Go ↔ Node byte-identical canonical encoding)

  • Status: Accepted (formalized 2026-05-12 with v3.7.0 — Go + Node verifiers byte-match in CI)
  • Date: 2026-05-11 (locked), 2026-05-12 (formalized)
  • Deciders: TLSStress.Art project
  • Targets: v3.7.0 (RSA-4096-PSS-SHA-384 real, ML-DSA-65 stub); Go 1.27 (ML-DSA-65 stdlib)
  • Patent claim family: claim #18 — cross-language canonical encoding + dual-signature envelope (anchor for ZTP-prem camada 1)
  • Umbrella ADR: 0026

Context

ZTP-prem camada 1 places licence-envelope signing in a Cloud HSM operator's environment: keys never leave the cloud, the operator on the customer's premises cannot mint a valid licence. The customer's bench needs to verify that the licence presented by the dashboard was signed in the cloud, with two cryptographic materials simultaneously (RSA-4096-PSS-SHA-384 today, ML-DSA-65 post-quantum tomorrow), and that the verification works without trusting the bench-local Go or Node runtime to canonicalise the JSON the same way.

Standard JSON Marshal is a non-canonical encoder — key order, whitespace, integer rendering, and Unicode normalisation differ between languages and even between minor versions of the same language. A signature computed in Go on serialised bytes will not re-verify in Node unless both sides reach an identical intermediate byte buffer first. That's not a hypothetical: every prior project that tried "we'll just sign the JSON" eventually shipped a silent rotation-day outage when one side bumped its parser.

Patent #18 anchors the specific canonical encoding + dual-algorithm envelope as the cross-language contract.

Decision

Adopt a canonical encoding contract with three properties:

  1. Sorted-keys at every object depth — recursive
  2. Byte-stable primitive rendering — integers as decimal, no superfluous trailing zeros, no Unicode normalisation surprises
  3. Single intermediate buffer (no streaming) — both sides produce one byte slice of identical length, with identical SHA-256

Implementation pair:

  • Signer (Go)pkg/ztp-prem-signctl/canonical.go runs in the trusted Cloud HSM environment. Produces 295-byte canonical form for the reference envelope; signs it with RSA-4096-PSS-SHA-384 (real today) + ML-DSA-65 (stub until Go 1.27 ships crypto/mldsa stdlib).
  • Verifier (Node)dashboard/src/lib/license/envelope.ts runs on the customer's bench. Produces the same 295-byte canonical form; verifies the dual signature.

Cross-language byte-match assertion is a first-class CI gate. pkg/ztp-prem-signctl/canonical_test.go reads a fixture envelope, canonicalises it Go-side, and asserts the SHA-256 matches the Node-side hash that the dashboard test suite produced in a prior CI run. A drift on either side breaks the gate.

Consequences

Pros - Customer can verify offline; no Cloud HSM call needed at boot - Dual signature gives PQC migration headroom before RSA-4096 is broken (post-quantum compliance is now a recurring procurement question) - Single byte-stable contract means licence rotation is a signature-file swap, not a code release

Cons - New tool (ztp-signctl) is moat-closed (Tier B) — garbled in CI, never ships to customer DC - Canonical encoder MUST be hand-maintained in two languages — a third language (Rust agent? Python automation?) adds a third copy - ML-DSA-65 stub means today's PQC posture is "we will when stdlib lands"; the contract is in place, the algorithm isn't yet

Reversibility: very low. Once envelopes are minted with this canonical form, every existing licence in customer hands depends on it. Replacing the canonical encoding would require a forced re-issue across the customer base. Algorithm swaps inside the envelope (e.g. ML-DSA-65 → ML-DSA-87 future) are reversible because the envelope is a JSON object with a signatures[] array.


Last verified against shipping code: v3.7.0 (2026-05-12).