Audit D-316 — T-001 / T-003 cross-implementation interop bug
Worker: worker-glossary-2026-05-05
Date: 2026-05-05
Triggering finding: T-017 conformance runner (worker-glossary-par-8) classified 5 of 7 envelope-core test vectors as known_interop_gap rather than pass/fail. Root cause documented here in three layers.
TL;DR
When the T-017 runner exercises T-003's reference TypeScript library
against T-001's canonical test vectors, the two implementations produce
different signed bytes for the same logical envelope. There are
three independent root causes; SPEC is ambiguous on two of them and
stale on the third. Until SPEC picks, no two HERALD implementations
will interoperate.
| # | Defect | T-001 chose | T-003 chose | SPEC says |
|---|---|---|---|---|
| 1 | Origin domain-separation tag | herald.envelope.v2 | herald.sig.origin.v1 | (post-D-308) herald.envelope.v1 |
| 2 | Encoding of nonce (16 bytes) in the canonical body | CBOR byte string (major type 2) | CBOR text string (major type 3, hex) | silent |
| 3 | Encoding of id, parent, trace (32-byte sha256s) in the canonical body | CBOR byte string (major type 2) | CBOR text string (major type 3, hex) | silent |
All three need closure. The Phase-1 reference deliverables (T-003,
T-004 Python, T-017 conformance, T-101 CT-log schema) all rest on this
choice.
Layer 1: domain-separation tag — stale .v2 in T-001
T-001 generate.py line 51 (manthan/state/findings/T-001/generate.py:51):
DOM_SEP_ENVELOPE = b"herald.envelope.v2" # SPEC §3.6, origin scope, v2 tagT-001 INTERPRETATION I-1 (manthan/state/findings/T-001/INTERPRETATION.md:9-29):
The task brief states ... dom_sep = "herald.envelope.v1". But thecurrentSPEC.md§3.6 lists ...herald.envelope.v2. ... We use
herald.envelope.v2.That was correct as of T-001's submission timestamp. *D-308 has
since reverted SPEC.md §3.6 back to .v1* (seemanthan/state/findings/D-308/RECEIPT.md). T-001's vectors are now
signed against a stale tag.
T-003 INTERPRETATION (manthan/state/findings/T-003/INTERPRETATION.md:11):
SPEC §3.6 line 212 lists herald.envelope.v1 for originatingenvelopes. EXT §6.3 line 436 maps the origin signature scope toherald.sig.origin.v1 ... this reference signs ordinary originsignatures with herald.sig.origin.v1.Note that T-003 uses herald.sig.origin.v1, notherald.envelope.v1. EXT §13 line 770 says the two strings are
aliases for the origin scope:
herald.envelope.v1 | base envelope origin signature ([SPEC §3]) |also herald.sig.origin.v1 for explicitly-scoped origin sigsBut "alias" at the documentation level does not mean the two
strings produce identical signatures. Ed25519 is signingdom_sep || canonical_body, byte-for-byte. herald.envelope.v1 andherald.sig.origin.v1 differ in 6 bytes; the resulting signatures are
different. *EXT §13 should pick one canonical string for the origin
scope and remove the alias language*, or the registry should declare
them equivalent and require all implementations to sign with the same
string.
Recommended canonical answer: herald.sig.origin.v1 because EXT
§6.3 already maps the scope identifier origin → that tag, which is
the explicit signing-scope registry. SPEC §3.6'sherald.envelope.v1 should be marked as a synonym for "envelope
origin signature in SPEC's pre-multi-sig framing" and pointed at EXT
§6.3 / §13 as canonical. (Alternative: pick herald.envelope.v1
since SPEC §3.6 is normative; either way, pick once.)
Layer 2: nonce encoding
The 16-byte CSPRNG nonce per [SPEC §3.7] line 227 is opaque. SPEC §3.5
says nothing about how it is CBOR-encoded.
T-001 generate.py line 150 (manthan/state/findings/T-001/generate.py:150):
nonce: bytes,— and at line 166–168 it lands in the body dict as bytes, which
goes through T-001's cbor_det() encoder as *major type 2 (byte
string)*. The wire body for vector genesis-alpha-valid therefore
contains the nonce as a 17-byte CBOR atom (1-byte head + 16 bytes
payload).
T-003 envelope.ts line 95
(manthan/state/findings/T-003/src/envelope.ts:95):
nonce: nonceToHex(input.nonce),— see nonceToHex() at line 191–193: returns a 32-character hex
string. This goes through T-003's encodeCbor() atmanthan/state/findings/T-003/src/cbor.ts:24 as *major type 3 (text
string)*. The wire body contains the nonce as a 33-byte CBOR atom.
These produce different canonical bytes; therefore different ids;
therefore different signatures. The runner sees this as tampered
on every non-genesis vector at T-003-side verifyEnvelope line 114
(id mismatch).
SPEC §3.1's JSON example shows "nonce": "<128-bit CSPRNG random>"
which is just JSON's string-rendering of something — it does not
fix the CBOR-side encoding. SPEC §3.5 line 200 says "Map keys MUST be
text strings in v0.2"; values are unspecified.
Recommended canonical answer. CBOR byte string (major type 2).
Three reasons:
1. Compactness. A 16-byte nonce is 17 bytes as a byte string vs
33 bytes as hex text. For a 32-byte sha256 id, 33 vs 65. CBOR's
reason-for-being is compact wire format; choosing text-hex here
discards that benefit on every envelope.
2. Consistency with the CBOR-deterministic profile. SPEC §3.5
already requires byte-level canonicalisation; opaque values
should travel as bytes-typed atoms.
3. JSON display compat is preserved separately. The display-only
JSON form (SPEC §3.1) hex-encodes opaque values as strings; this
is purely a renderer decision and not load-bearing for signing.
T-003 conflates wire and display by storing hex in memory; the
fix is to keep wire types in memory (Uint8Array) and hex-encode
only at JSON-display boundaries.
Layer 3: id / parent / trace encoding
Same defect as Layer 2 but for the 32-byte sha256 hashes.
T-001 generate.py line 295 (and analogous sites):
parent=genesis_eid, # bytes — CBOR major type 2 byte string— with explicit comment confirming the choice. T-001 keeps id,parent, trace as raw bytes throughout the canonical-body
construction.
T-003 envelope.ts line 158:
return bytesToHex(sha256(encodeCbor(body)));— computeId returns hex out of bytesToHex. The hex string is
then stored on the envelope as id, and at line 92–93 parent andtrace are typed string. T-003's wire is text-string-typed for all
three.
Recommended canonical answer: same as Layer 2 — CBOR byte string
(major type 2) for id, parent, trace, nonce. Hex-text only at
the JSON display surface.
What SPEC needs to say
A small addition to SPEC §3.5 closes this. Proposed clause:
§3.5a Opaque-value encoding. The fieldsid,parent,trace,
andnonceare opaque byte sequences (id/parent/traceare
32-byte sha256 outputs; nonce is 16 bytes). They MUST be encodedas CBOR byte strings (major type 2) in the canonical body. JSON
renderings (SPEC §3.1) display these values as lowercase hex text;
the JSON form is non-normative and not used for signing or hashing.
A second tightening for EXT §13:
Remove the "also herald.sig.origin.v1 for explicitly-scoped originsigs" alias language on the herald.envelope.v1 row. Makeherald.sig.origin.v1 the canonical origin-scope signing tag anddeprecate herald.envelope.v1 as a documentation synonym only.(Or pick the other side. The point is to pick.)
What needs to happen after SPEC picks
1. T-001 vector regeneration. Once SPEC commits a domain tag and
an opaque-value encoding, regenerate test-vectors/v0.2/vectors.json
from generate.py. T-001's existing verify.py is internally
consistent with its generate.py, so they'll move together.
2. T-003 reconciliation. If SPEC adopts byte-string encoding (the
recommended answer), T-003 needs:
- Envelope.id, parent, trace, nonce typed Uint8Array on
the canonical-body interface (not string).
- nonceToHex removed from the build path; hex conversion lives
only at the JSON display boundary.
- Approximately one day of careful refactor + test re-derivation.
3. T-017 re-run. All 7 envelope-core vectors should pass
(modulo vector 7 lineage-cycle-birth which is an independent
issue around the missing rejected-lineage-cycle verifier verdict
per T-001 INTERPRETATION I-4).
4. Cross-language interop. T-004 (Python interop) is currently
blocked on T-003. With this reconciliation, T-004 can be
implemented against unambiguous SPEC text without re-discovering
the same encoding question.
What this audit does NOT do
- Does not edit SPEC.md or EXT.md (audits propose; reviewers
promote).
- Does not regenerate T-001's vectors. The vector file is in the
main tree at test-vectors/v0.2/vectors.json; regenerating it
requires the SPEC decisions to land first.
- Does not patch T-003's
lib/herald-core-ts/src/. T-003 is itself
a manthan submission still in findings/; it has not yet been
promoted. The reviewer who promotes T-003 should fold the Layer 2
+ Layer 3 fix into the promotion patch.
- Does not pick between
herald.envelope.v1and
herald.sig.origin.v1. The audit recommends herald.sig.origin.v1
but flags both as defensible; protocol designers decide.
Verification
After the SPEC patches and the T-001 / T-003 reconciliations land,
running npm test from tests/conformance/ (T-017's deliverable)
should produce exit code 0 with all 7 envelope-core vectors passing
(or 6 + 1 skipped if vector 7 stays blocked on therejected-lineage-cycle verdict gap).
The T-019 glossary-check tool (tools/glossary-check.py) is silent on
this divergence — it catches lexical drift in spec prose, not byte-
level wire-format divergence. A future tool that diffs canonical-body
bytes between T-001 vectors and T-003-rebuilt envelopes would catch a
recurrence.
Cross-references
| Source | Cite |
|---|---|
| T-017 conformance runner | manthan/state/findings/T-017/RECEIPT.md (GAP A, GAP B, GAP C) |
| T-001 generate.py | manthan/state/findings/T-001/generate.py:51, :150, :166-168, :295 |
| T-001 INTERPRETATION | manthan/state/findings/T-001/INTERPRETATION.md:9-29 (I-1: domain tag), :75-101 (I-3: deterministic nonces) |
| T-003 envelope.ts | manthan/state/findings/T-003/src/envelope.ts:95, :114, :158, :191-193 |
| T-003 cbor.ts | manthan/state/findings/T-003/src/cbor.ts:24-25 |
| T-003 INTERPRETATION | manthan/state/findings/T-003/INTERPRETATION.md:11 (origin domain tag choice) |
| D-308 | manthan/state/findings/D-308/RECEIPT.md (.v1 is canonical post-D-308) |
| SPEC §3.1 | SPEC.md:138-163 (JSON example, display-only) |
| SPEC §3.5 | SPEC.md:193-204 (canonicalisation profile, silent on opaque-value encoding) |
| SPEC §3.6 | SPEC.md:206-222 (domain-separation table, post-D-308 .v1) |
| SPEC §3.7 | SPEC.md:224-229 (nonce CSPRNG requirement) |
| EXT §13 | EXT.md:764-796 (domain-separation registry, alias language on origin row) |