Skip to content

BeamMCP.Signer.COSE: ML-DSA-87 in COSE, the algorithm and the key inside the signed bytes - #21

Merged
HackTuah merged 1 commit into
mainfrom
feat/cose-signer
Oct 20, 2026
Merged

HackTuah merged 1 commit into
mainfrom
feat/cose-signer

Conversation

@HackTuah

Copy link
Copy Markdown
Member

A third signer module. Nothing in core changes, and the callback stays one sign/2.

Why. Canonical.signature/3 places scheme: and key_id: beside a raw signature. The host asserts them and nobody signs them, so swapping either leaves the signature valid; the first new test shows exactly that. BeamMCP.Signer.COSE returns a COSE signature whose protected header names the algorithm and the key, inside the signed bytes.

What it returns. Deterministic CBOR, the N6 single-signer carrier: {0: 0xFFFF0003, 1: body, 2: [protected, {}, null, signature]}, with the Sign1 untagged and detached.

  • Protected header: {1: -50, 4: kid, 16: typ}. kid is the SHA-384 COSE Key Thumbprint (RFC 9679) of the public key, computed from the key on every call, never taken from the host.
  • Body: names the canonical bytes by SHA-384 and length, with the host's issuer. The canonical bytes are unchanged and travel beside the carrier, as any signature's do.
  • Signature: pure ML-DSA-87 over ["Signature1", protected, h'', body], made and checked by BeamMCP.Signer.MLDSA87. This module's only :crypto call is SHA-384.
  • Provisional: the schema number (0x85000101), the typ and the profile (v0) are provisional until the shared registry assigns them.

The encoder is the package's own and private, because no Elixir library writes deterministic CBOR or COSE with ML-DSA. It writes RFC 8949 §4.2.1 under N6's rules and refuses floats, tags, other simple values, non-integer keys and non-NFC text by name.

Evidence

  • Red first: the new tests fail before the modules exist (16 failures, each an undefined module).
  • Green: OTP 28.5.0.8 over OpenSSL 3.5.9 with Elixir 1.20.4, three runs; and over 3.0.13 with Elixir 1.18.4, where the ML-DSA tests are excluded and the refusal test runs.
  • Encoder: RFC 8949 Appendix A's rows and every head boundary, byte for byte. A strict reader in the tests judges canonical form without the encoder.
  • RFC 9964 Appendix A.2: its protected header and ToBeSigned are reproduced exactly, and its signature verifies. SHA-256 over the thumbprint input equals its kid. tools/vectors_cose.sh writes the fixture from the RFC text, pinned by sha256.
  • Tampering: with alg, kid, typ or the body changed, the signature is refused, here and in both cross-verifiers.
  • Mutants: eleven, and each fails the suite. The first run's one survivor, map keys left unsorted, is invisible below 33 keys because small Erlang maps enumerate in key order; a 40-key test now kills it.
  • Outside Elixir: the cross-verify job reads the box leg's carrier with Go (fxamacker/cbor v2.9.6, crypto/mldsa) and Rust (coset 0.4.2, ciborium 0.2.2, aws-lc-rs). Each builds ToBeSigned itself, each passes locally, and each refuses a copy with one signature byte flipped. The job keeps its name, so the required check is unchanged.
  • Census: the call lists now cover both modules. COSE calls SHA-384, the encoder and MLDSA87.sign/2; the encoder calls no :crypto.

Not yet measured

  • The floor leg (OTP 27, Elixir 1.17) and the cross-verify job on CI. This run is their first with this change.

…ide the signed bytes

Canonical.signature/3 places scheme: and key_id: beside a raw
signature, asserted by the host and signed by nobody: swap either and
the signature still verifies. This module returns a COSE signature
whose protected header names the algorithm and the key, so a verifier
that changes or misreads either finds it invalid.

- lib/beam_mcp/signer/cose.ex: a third BeamMCP.Signer, sign/2 only. It
  returns deterministic CBOR, the N6 single-signer carrier
  {0: 0xFFFF0003, 1: body, 2: [protected, {}, null, signature]}: an
  untagged, detached COSE_Sign1 (RFC 9052, RFC 9964 algorithm -50).
  The protected header is {1: -50, 4: kid, 16: typ}, kid the SHA-384
  COSE Key Thumbprint (RFC 9679) computed from the public key. The body
  names the canonical bytes by SHA-384 and length, with the host's
  issuer. MLDSA87 signs and checks the ToBeSigned; this module's one
  :crypto call is SHA-384. Schema 0x85000101 and the typ are
  provisional.
- lib/beam_mcp/signer/cose/cbor.ex (private): deterministic CBOR under
  N6's rules; floats, tags, other simple values, non-integer keys and
  non-NFC text are refused by name.
- Tests: RFC 8949 Appendix A rows and every head boundary, byte for
  byte; RFC 9964 Appendix A.2's protected header and ToBeSigned
  reproduced exactly, its signature verified, and SHA-256 over the
  thumbprint input equal to its kid; a strict reader that judges
  canonical form without the encoder; the carrier, header and body read
  back; alg, kid, typ or body changed and the signature refused; the
  census extended to both modules. tools/vectors_cose.sh writes the A.2
  fixture from the RFC text, pinned by sha256 (BSD-3-Clause, IETF TLP
  section 4.e).
- CI: the box leg's sample adds a carrier; the cross-verify job reads
  it with Go (fxamacker/cbor v2.9.6, crypto/mldsa) and Rust (coset
  0.4.2, ciborium 0.2.2, aws-lc-rs), each building ToBeSigned itself
  and refusing it with alg, kid or typ changed. The job keeps its name.
- Docs: README (a COSE section with how to verify), SECURITY.md, the
  assurance case, architecture, roadmap, the review checklist,
  GOVERNANCE.md, CHANGELOG.
- mldsa87_test.exs: send(...) && key became two expressions, which
  Elixir 1.20's type checker no longer warns about.

Measured locally: the new tests fail before the modules exist (16
failures, each an undefined module) and pass after; the suite passes
on OTP 28.5.0.8 over OpenSSL 3.5.9 (Elixir 1.20.4, three runs) and over
3.0.13 (Elixir 1.18.4, the ML-DSA tests excluded, the refusal run).
Eleven mutants of the two modules each fail the suite; the first run's
one survivor (map keys left unsorted, invisible below 33 keys because
small Erlang maps enumerate in order) is killed by a 40-key test. Both
cross-verifiers pass the carrier and refuse a copy with one signature
byte flipped.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit 7ef8345 into main Oct 20, 2026
11 checks passed
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