Skip to content

feat(s138): deterministic CBOR and COSE_Sign1 with ML-DSA-87 - #133

Merged
HackTuah merged 3 commits into
mainfrom
slice/138-n6-cbor-cose
Oct 20, 2026
Merged

HackTuah merged 3 commits into
mainfrom
slice/138-n6-cbor-cose

Conversation

@HackTuah

Copy link
Copy Markdown
Member

Slice 138. Deterministic CBOR and COSE_Sign1 with ML-DSA-87, in the N6 profile, as one boundary with no dependency on the rest of Trinity. It is groundwork: nothing Trinity signs changes, and no statement uses the new format yet. Stacked on #132 (it reuses that slice's :mldsa87_runtime tag and the fips job's mode-off step); retarget to main once #132 merges.

The profile implemented is v0, draft r3, provisional. Every rule may change until a frozen v1 is published with its own vectors, and the pin (Trinity.N6.Profile.pin/0) names the draft and the SHA-384 of its text.

What it does

  • The codec (Trinity.N6.CBOR). An encoder that can only produce RFC 8949 section 4.2.1's deterministic encoding, for integers in [-2^63, 2^64-1], tagged byte and text strings (text valid UTF-8 in NFC), arrays, maps with keys 0..2^32-1, and false, true, null. A decoder that reads the input once, stops at the first defect and names it with its byte offset, then re-encodes and compares. Limits: depth 16, 65,536 items, 64 KiB of text, 1 MiB in all, each counted as the moduledoc states. A forged count larger than the input is refused before anything is read.
  • Bodies (Trinity.N6.Body). Key 0 is the schema version; a body is checked against its declared schema with no unknown key, and a field declared a set must be sorted by encoded bytes with no duplicate.
  • Digests (Trinity.N6.Digest). SHA-384 over foreign bytes exactly as given, as [-43, digest] and a reference carrying the exact length.
  • COSE_Sign1 (Trinity.N6.COSE). The protected header is closed to alg -50 (ML-DSA-87), kid and typ, plus CWT claims for a SCITT-renderable typ. kid is the RFC 9679 SHA-384 thumbprint over RFC 9964's key members. ToBeSigned is RFC 9052's, with an empty external_aad. Signing takes the private key as the 32-byte seed or the expanded key. Verification answers the profile's exit classes; a runtime that cannot run ML-DSA-87 answers no trust (5, alg_unavailable), never a crash and never a pass. Both exchange forms, detached and tagged-attached, render from the same three parts.
  • A census of every hashing and signing call in lib/, each classified. Four sites re-encode data from elsewhere before hashing it; they are declared there with what it would take to hash the exact bytes, and none changes in this PR.

Vectors

scripts/n6_vectors.exs writes test/fixtures/n6/ (338 files, 67 KB) in the layout the draft gives. Every expected encoding is a hex literal, copied from RFC 8949 Appendix A or built by hand from CBOR heads, so no vector is the encoder's own output. The thumbprint vector uses NIST ACVP's published ML-DSA-87 keyGen case; no key of this project's is published. sha384sum -c SHA384SUMS checks the set from its directory, and a test holds the checked-in set to the generator byte for byte.

Evidence

  • Every positive vector encodes and decodes; every negative vector is refused with its code at the top level, in an array and as a map value, and by the one pass alone, never left to the round trip. A property over generated values; each limit at its value and one past it.
  • Planted violations, each restored and compared byte for byte afterwards: a decoder that accepts floats turned exactly the nine float and NaN vectors red; one that skips the duplicate-key check turned exactly the two duplicate-key vectors red; a wrong thumbprint byte, a changed vector byte, a verifier that accepts every signature, an unclassified hashing call and a call out of the boundary each turned their own tests red.
  • On OTP 28.5.0.8 with OpenSSL 3.5.9, mode off: signing with NIST's test key as a seed and as an expanded key, both verifying; one flipped byte in the header, the payload or the signature is invalid, and a flipped key id is no trust. The fips job's mode-off step runs these.
  • An opt-in run (mix test --only n6_corpus): 1,000,000 generated bodies round-tripped, and 13,000,000 mutants from thirteen operators were all refused with their operators' codes (34.6 s).
  • Decode time at the limits, median of 20: 1 MiB byte string 38 µs, 65,535-item array 11.1 ms, 32,767-pair map 18.4 ms.

Not in this PR

Receipts keep their slice 024 format; moving them is a later slice. Two-signer carriers, the attribution exit classes (6, 7), the RFC 9995 hash envelope, and any classical algorithm in N6 are outside it.

…h ML-DSA-87)

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…ML-DSA-87 with :error, never a crash

Signed-off-by: Ayla Croft <aylacroft@proton.me>
Base automatically changed from slice/137-toolchain-floor to main October 20, 2026 00:35
Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit 06da183 into main Oct 20, 2026
12 checks passed
@HackTuah
HackTuah deleted the slice/138-n6-cbor-cose branch October 20, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant