Repository navigation
BeamMCP.Signer.COSE: ML-DSA-87 in COSE, the algorithm and the key inside the signed bytes - #21
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A third signer module. Nothing in core changes, and the callback stays one
sign/2.Why.
Canonical.signature/3placesscheme:andkey_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.COSEreturns 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.{1: -50, 4: kid, 16: typ}.kidis the SHA-384 COSE Key Thumbprint (RFC 9679) of the public key, computed from the key on every call, never taken from the host.issuer. The canonical bytes are unchanged and travel beside the carrier, as any signature's do.["Signature1", protected, h'', body], made and checked byBeamMCP.Signer.MLDSA87. This module's only:cryptocall is SHA-384.typand 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
kid.tools/vectors_cose.shwrites the fixture from the RFC text, pinned by sha256.alg,kid,typor the body changed, the signature is refused, here and in both cross-verifiers.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.MLDSA87.sign/2; the encoder calls no:crypto.Not yet measured