Skip to content

BeamMCP.Signer.MLDSA87: ML-DSA-87 through OTP's :crypto, checked before it is returned - #19

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

HackTuah merged 1 commit into
mainfrom
feat/mldsa87-signer

Conversation

@HackTuah

Copy link
Copy Markdown
Member

Adds BeamMCP.Signer.MLDSA87, an ML-DSA-87 (FIPS 204) signer behind core's unchanged BeamMCP.Signer behaviour. It signs pure ML-DSA-87 through the external interface with an empty context, the form RFC 9964 requires for COSE (algorithm -50). BeamMCP.Signer.Ed25519 is unchanged.

The module

  • Two keys per call: the 32-byte seed (:private_key) and its 2,592-byte public key (:public_key), each as bytes or a zero-arity function. The public key is required because :crypto signs from a seed but cannot derive the public key from one.
  • Checked before it is returned: the signature must verify under the given public key, and must not verify over the bytes with one bit flipped. Otherwise it answers {:error, {:public_key, :does_not_verify}} or {:error, :self_check_failed}, and no signature leaves the module. A seed and public key that are not a pair cannot produce a signature.
  • No prehash, no context: :crypto.sign/4 and :crypto.verify/5 with :mldsa87 and :none as literals and no options argument. The census now covers both modules and pins this.
  • Refusals are answers carrying no key byte, as for Ed25519. On OTP 27, or an OTP linked to OpenSSL before 3.5, every call answers {:error, :crypto_refused}. The package floor stays OTP 27.
  • Hedged: two signatures of the same bytes differ; both verify.

Tests and CI

  • test_helper.exs runs the ML-DSA tests where :crypto offers mldsa87, and the :crypto_refused test where it does not. With MLDSA_REQUIRED=1 (the box leg) a runtime without ML-DSA-87 is refused, so that leg cannot pass by skipping.
  • Vectors: NIST ACVP and Wycheproof ML-DSA-87, every case :crypto can express (ACVP 1 of 60, Wycheproof 234 of 241). The rest are counted by reason (a non-empty context, HashML-DSA, the internal interface), so a case that comes into reach fails the count until it runs. tools/vectors_mldsa87.sh writes the fixtures from pinned commits, checked by sha256. The fixtures are test-only and not in the Hex package.
  • Other implementations: the box leg writes one signature, and the new job "ML-DSA-87 cross-verify (Go crypto/mldsa, aws-lc-rs)" verifies it with Go 1.27.3's crypto/mldsa and with aws-lc-rs 1.18.2 (Cargo.lock, Rust 1.92.0). Each must refuse it over the message with one bit flipped. Go also confirms the public key is the one the seed derives.
  • Latency: the box leg records ML-DSA-87 latency (tools/bench_mldsa.exs), with no threshold. A shared runner's figure is the runner's.
  • One existing test fixed: Ed25519's "nothing kept" test matched ETS tables by name, and ExUnit's per-module tables matched once more test files were compiling. It now refuses any table the call leaves owned by the calling process.

Evidence (local)

  • OTP 28.5.0.8 over OpenSSL 3.5.9 with Elixir 1.20.4: every CI step exits 0; Result: 37 passed, 1 excluded.
  • OTP 28.5.0.8 over OpenSSL 3.0.13 with Elixir 1.18.4: every step exits 0; 38 tests, 0 failures, 11 excluded. With MLDSA_REQUIRED=1 it is refused (exit 1).
  • Go and aws-lc-rs accept the signature and refuse the flipped message; a tampered signature fails both (exit 1).
  • Eleven mutants of the module, each compiling clean: ten killed. The survivor removes the second check, which cannot fail on a correct runtime; it guards against a faulty one.
  • reuse lint: compliant. The ACVP fixtures are under NIST-Software, Wycheproof under Apache-2.0, each with the change noted.

Not yet measured

  • OTP 27 with Elixir 1.17, the floor leg: not installed locally; this run is its measurement.
  • The cross-verify job and the aarch64 latency: this run is their first.

Before merging

  • The ruleset gains "ML-DSA-87 cross-verify (Go crypto/mldsa, aws-lc-rs)" as a required check once it has run green here, so GOVERNANCE's "seven checks" is true when this lands.

…re it is returned

A second module behind core's unchanged BeamMCP.Signer behaviour:
ML-DSA-87 (FIPS 204), pure, external interface, empty context, the form
RFC 9964 requires for COSE. Ed25519 is untouched.

- The host hands in the 32-byte seed under :private_key and its
  2,592-byte public key under :public_key, each as bytes or a zero-arity
  function. The public key is required: :crypto signs from a seed but
  cannot derive the public key from one.
- Every signature is verified under that public key, and must not
  verify over the bytes with one bit flipped, before it is returned.
  Otherwise the answer is {:error, {:public_key, :does_not_verify}} or
  {:error, :self_check_failed}, and no signature leaves the module.
- The :crypto calls are sign/4 and verify/5 with :mldsa87 and :none as
  literals and no options argument, so no prehash and no context; the
  key census now covers both modules and pins that.
- Refusals are answers that carry no key byte, as for Ed25519. On a
  runtime without ML-DSA-87 (OTP 27, or OpenSSL before 3.5) every call
  is {:error, :crypto_refused}; the package's OTP floor stays 27.

Tests:

- test_helper.exs runs the ML-DSA tests where :crypto offers mldsa87
  and the :crypto_refused test where it does not; with MLDSA_REQUIRED=1
  (CI's box leg) a runtime without it is refused.
- The runtime is held to NIST ACVP and Wycheproof ML-DSA-87 vectors:
  every case :crypto can express (ACVP 1 of 60, Wycheproof 234 of 241),
  the rest counted by reason (a context, HashML-DSA, the internal
  interface), so a case that comes into reach fails the count.
  tools/vectors_mldsa87.sh writes the fixtures at pinned commits and
  sha256s.
- CI: the box leg writes a signature, which a new job verifies with
  Go's crypto/mldsa (which also derives the public key from the seed)
  and with aws-lc-rs; each must refuse it over the message with one bit
  flipped. The box leg also records ML-DSA-87 latency, no threshold.
- Ed25519's "nothing kept" test compared table names, and ExUnit's own
  per-module tables matched once more test files compiled; it now
  refuses any table the call leaves owned by the calling process.

Docs: README, SECURITY.md, the assurance case, the architecture page,
the security review checklist, the roadmap (the second algorithm it
said would be a separate module), GOVERNANCE (seven checks),
CONTRIBUTING and the CHANGELOG.

Measured locally:

    OTP 28.5.0.8 over OpenSSL 3.5.9, Elixir 1.20.4: every CI step
      exit 0; Result: 37 passed, 1 excluded
    OTP 28.5.0.8 over OpenSSL 3.0.13, Elixir 1.18.4: every CI step
      exit 0; 38 tests, 0 failures, 11 excluded
    the same, MLDSA_REQUIRED=1: refused (exit 1)
    Go 1.27.3 crypto/mldsa and aws-lc-rs 1.18.2: the signature
      verifies, the flipped message does not, a tampered signature
      fails (exit 1)

Eleven mutants of the module, each compiling clean: ten killed. The
one that survives removes the second check; on a correct runtime a
signature never verifies over different bytes, so no test can see it
go. That check guards against a faulty runtime.

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