Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,50 @@ jobs:
- run: mix credo --strict
- run: mix hex.audit
- run: mix test
# The box leg's ML-DSA-87 signature, for the cross-verify job to check with two other
# implementations (tools/mldsa_sample.exs; the key pair is a published ACVP test vector).
- name: an ML-DSA-87 signature for other implementations to verify
if: matrix.leg == 'box'
run: mix run tools/mldsa_sample.exs mldsa87-sample
# Recorded, not asserted: a shared runner's latency is the runner's, never a box's figure.
- name: ML-DSA-87 latency on this runner (recorded, no threshold)
if: matrix.leg == 'box'
run: mix run tools/bench_mldsa.exs 500
- if: matrix.leg == 'box'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mldsa87-sample
path: mldsa87-sample/
if-no-files-found: error
retention-days: 7

# BeamMCP.Signer.MLDSA87's output, verified by two ML-DSA implementations other than the
# OpenSSL that made it: Go's crypto/mldsa (which also derives the public key from the seed,
# as OTP cannot) and AWS-LC through aws-lc-rs. Each must accept the signature and refuse it
# over the message with one bit flipped (tools/cross_verify/).
cross-verify:
name: ML-DSA-87 cross-verify (Go crypto/mldsa, aws-lc-rs)
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: mldsa87-sample
path: mldsa87-sample
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.3"
cache: false
- name: Go crypto/mldsa
working-directory: tools/cross_verify/go
run: go run . "$GITHUB_WORKSPACE/mldsa87-sample"
- name: aws-lc-rs (the toolchain and every crate pinned)
working-directory: tools/cross_verify/rust
run: |
rustup toolchain install 1.92.0 --profile minimal
RUSTFLAGS="-D warnings" cargo +1.92.0 run --locked --release -- "$GITHUB_WORKSPACE/mldsa87-sample"


reuse:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ erl_crash.dump
/config/*.secret.exs
.elixir_ls/
*.tar

# the cross-verify tool's build directory (tools/cross_verify/rust)
/tools/cross_verify/rust/target/
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@ SPDX-License-Identifier: Apache-2.0

## [Unreleased]

### Added: `BeamMCP.Signer.MLDSA87`, an ML-DSA-87 signer

- A second module implementing core's `BeamMCP.Signer` behaviour, unchanged: ML-DSA-87
(FIPS 204), pure, external interface, empty context, the form RFC 9964 requires for COSE.
`Ed25519` is untouched.
- **Two keys on every call:** 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
because `:crypto` cannot derive one from a seed.
- **Every signature is checked before it is returned:** it must verify under the given public
key (`{:error, {:public_key, :does_not_verify}}` otherwise, so a seed and public key that
are not a pair produce no signature) and must not verify over bytes one bit away
(`{:error, :self_check_failed}` otherwise).
- **Hedged signing:** two signatures of the same bytes differ.
- **Where it runs:** OTP 28.1 or later with `:crypto` linked to OpenSSL 3.5 or later. Elsewhere,
OTP 27 included, every call answers `{:error, :crypto_refused}`; the package's floor stays
OTP 27 for Ed25519.
- **Tests:** the ML-DSA-87 tests run on CI's box leg, which fails if `:crypto` lacks the
algorithm. The suite holds the runtime to NIST ACVP and Wycheproof ML-DSA-87 vectors, every
case `:crypto` can express, and counts the rest by reason (`tools/vectors_mldsa87.sh` writes
the fixtures at pinned commits). A new CI job verifies the box leg's signature with Go's
`crypto/mldsa` and aws-lc-rs (`tools/cross_verify/`); it is the seventh required check.
- **Docs:** the README, `SECURITY.md`, the assurance case, the architecture page, the security
review checklist and the roadmap describe both modules.

### Changed: a test of Ed25519's "nothing kept" claim

- `ed25519_test.exs` looked for an ETS table named like "signer" or "key" after a call; with
more test files compiling, ExUnit's per-module tables (named after the test modules) matched.
It now compares the tables before and after the call and refuses any new one owned by the
calling process, which is what a table made by the call would be.

### Added: build provenance for each release

- `.github/workflows/provenance.yml`: on a release tag, CI builds the tarball with
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ mix test
Erlang/OTP 27 or newer and Elixir 1.17 or newer, the same floor as `beam_mcp`. CI runs
`mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict`,
`mix hex.audit` and `mix test` on four OTP/Elixir legs, after checking the runtime is at or above
its line's patched OTP floor (`tools/otp_floor.sh`); run the same before you push.
its line's patched OTP floor (`tools/otp_floor.sh`); run the same before you push. The
ML-DSA-87 tests run only where `:crypto` offers the algorithm (OpenSSL 3.5 or later); CI's box
leg requires it (`MLDSA_REQUIRED=1`), and a job verifies that leg's signature with Go's
`crypto/mldsa` and aws-lc-rs (`tools/cross_verify/`).

## The rules, all enforced

Expand Down
7 changes: 4 additions & 3 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ date, so the bus factor is two, and his reviews are the public record of it.

1. A branch and a pull request; nothing is pushed to `main` directly.
2. CI on four OTP/Elixir legs (the floor, the pinned line, the newest, and the box: OTP
28.5.0.8 with Elixir 1.20.4 on aarch64, over an OpenSSL that offers ML-DSA-87), the REUSE
lint and the DCO check.
28.5.0.8 with Elixir 1.20.4 on aarch64, over an OpenSSL that offers ML-DSA-87), the box
leg's ML-DSA-87 signature verified by two other implementations (Go's `crypto/mldsa` and
aws-lc-rs), the REUSE lint and the DCO check.
3. The maintainer's review, then a rebase merge.

From 2026-09-26 a repository ruleset on `main` holds steps 1 to 3 rather than leaving them to
habit, as `beam_mcp`'s does: no deletion or force-push, changes by pull request only, a rebase
merge and linear history, and the six checks above required and up to date, with no bypass.
merge and linear history, and the seven checks above required and up to date, with no bypass.
4. A release is a signed tag and a hex.pm publish by the maintainer, recorded in the
CHANGELOG; `docs/verifying-releases.md` says how to check one.

Expand Down
28 changes: 28 additions & 0 deletions LICENSES/NIST-Software.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
NIST-developed software is provided by NIST as a public service.
You may use, copy, and distribute copies of the software in any
medium, provided that you keep intact this entire notice. You may
improve, modify, and create derivative works of the software or any
portion of the software, and you may copy and distribute such
modifications or works. Modified works should carry a notice stating
that you changed the software and should note the date and nature of
any such change. Please explicitly acknowledge the National Institute
of Standards and Technology as the source of the software.

NIST-developed software is expressly provided "AS IS." NIST MAKES NO
WARRANTY OF ANY KIND, EXPRESS, IMPLIED, IN FACT, OR ARISING BY OPERATION
OF LAW, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTY OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND DATA ACCURACY. NIST
NEITHER REPRESENTS NOR WARRANTS THAT THE OPERATION OF THE SOFTWARE WILL BE
UNINTERRUPTED OR ERROR-FREE, OR THAT ANY DEFECTS WILL BE CORRECTED. NIST DOES
NOT WARRANT OR MAKE ANY REPRESENTATIONS REGARDING THE USE OF THE SOFTWARE OR
THE RESULTS THEREOF, INCLUDING BUT NOT LIMITED TO THE CORRECTNESS, ACCURACY,
RELIABILITY, OR USEFULNESS OF THE SOFTWARE.

You are solely responsible for determining the appropriateness of using and
distributing the software and you assume all risks associated with its use,
including but not limited to the risks and costs of program errors, compliance
with applicable laws, damage to or loss of data, programs or equipment, and the
unavailability or interruption of operation. This software is not intended to be
used in any situation where a failure could cause risk of injury or damage to
property. The software developed by NIST employees is not subject to copyright
protection within the United States.
72 changes: 63 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ SPDX-License-Identifier: Apache-2.0

[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14775/badge)](https://www.bestpractices.dev/projects/14775)

An Ed25519 signer for [`beam_mcp`](https://github.com/ScriptKittyOS/beam_mcp)'s canonical
connectome bytes, through OTP's `:crypto`. One module, `BeamMCP.Signer.Ed25519`, implementing
core's `BeamMCP.Signer` behaviour (`sign/2`).
Signers for [`beam_mcp`](https://github.com/ScriptKittyOS/beam_mcp)'s canonical connectome
bytes, through OTP's `:crypto`. Two modules, each implementing core's `BeamMCP.Signer`
behaviour (`sign/2`) and nothing else:

**What it signs.** Exactly the bytes `BeamMCP.Connectome.Canonical.encode/2` produces, as
`Canonical.signature/3` hands them over; the 64-byte signature comes back beside them. Nothing
else: no envelope byte moves, no verdict or receipt is added.
- **`BeamMCP.Signer.Ed25519`**: Ed25519 (RFC 8032), a 64-byte signature.
- **`BeamMCP.Signer.MLDSA87`**: ML-DSA-87 (FIPS 204), the post-quantum signature CNSA 2.0
names, a 4,627-byte signature. It needs an OTP whose `:crypto` is linked to OpenSSL 3.5 or
later; see [ML-DSA-87](#ml-dsa-87) below.

**What they sign.** Exactly the bytes `BeamMCP.Connectome.Canonical.encode/2` produces, as
`Canonical.signature/3` hands them over; the signature comes back beside them. Nothing else:
no envelope byte moves, no verdict or receipt is added.

## Ed25519

**Who holds the key.** The host. It passes the 32-byte Ed25519 private key on every call, as
a zero-arity function that returns it:
Expand All @@ -39,14 +46,61 @@ entry), a key function that raises, throws or exits is answered
`{:error, :crypto_refused}`. The key function's own exception is dropped with it, so a host
that wants it logged logs it inside that function, without the key.

This package reads `opts[:private_key]` and nothing else -- no environment variable, no file,
no application config, no default. Where the key lives between calls is the host's decision.
## ML-DSA-87

`BeamMCP.Signer.MLDSA87` signs pure ML-DSA-87 through the external interface with an empty
context: the form RFC 9964 requires for COSE (algorithm -50). The host hands in two keys,
each as bytes or, better, as a zero-arity function returning them:

```elixir
{:ok, %{signature: sig}} =
BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.MLDSA87,
private_key: fn -> seed end, public_key: fn -> public_key end, scheme: :mldsa87)

true = :crypto.verify(:mldsa87, :none, BeamMCP.Connectome.Canonical.encode!(graph), sig, public_key)
```

- **`:private_key` is the 32-byte seed**, RFC 9964's one private-key form.
- **`:public_key` is the 2,592-byte public key** that seed derives. It is required because
`:crypto` signs from a seed but cannot derive the public key from one. The host provisions
the pair outside the VM and hands in both.
- **Every signature is checked before it is returned.** It must verify under the public key
given, or the answer is `{:error, {:public_key, :does_not_verify}}` and no signature leaves
the module: that is how a seed and a public key that are not a pair are caught. The same
signature over bytes one bit away must not verify, or the answer is
`{:error, :self_check_failed}`.
- **Signing is hedged.** OpenSSL draws fresh randomness for each signature, and `:crypto`
cannot select the deterministic variant: two signatures of the same bytes differ, and both
verify.
- **Where it runs.** OTP 28.1 or later, with `:crypto` linked to OpenSSL 3.5 or later
(`:mldsa87 in :crypto.supports(:public_keys)`). OTP 27, or an OTP linked to an older
OpenSSL, answers every call `{:error, :crypto_refused}`. CI runs the ML-DSA tests on the
box leg (OTP 28.5.0.8 on Ubuntu 26.04's arm64 runner, OpenSSL 3.5.5) and fails that leg if
`:crypto` does not offer the algorithm.
- **FIPS mode** with a provider that approves ML-DSA needs `default_properties = fips=yes` in
OpenSSL's own configuration; OTP's `fips_mode` alone leaves the signature unavailable. No
provider in this package's CI approves ML-DSA, so FIPS mode is not tested here.
- **Vectors.** The suite holds the runtime to NIST's ACVP and C2SP's Wycheproof ML-DSA-87
vectors, every case `:crypto` can express (it passes no context and has no HashML-DSA or
internal interface); the rest are counted by reason, so one that comes into reach fails the
count until it runs.
- **Other implementations.** On every CI run, the box leg's ML-DSA-87 signature is verified by
Go's `crypto/mldsa` and by AWS-LC (aws-lc-rs), neither of them OpenSSL; each must refuse it
over the message with one bit flipped, and Go also confirms the public key is the one the
seed derives (`tools/cross_verify/`).

## Either module

This package reads the key options and nothing else (`opts[:private_key]`, and for ML-DSA-87
`opts[:public_key]`) -- no environment variable, no file, no application config, no default.
Where the keys live between calls is the host's decision.

**Core is a separate package.** `beam_mcp` holds no key and calls no signing primitive; it is
not a dependency of this package in the other direction, and it never will be. A host that
wants signatures adds this package and attaches the module; one that does not, does not.

Apache-2.0. Erlang/OTP 27 or newer, Elixir 1.17 or newer -- the same floor as core.
Apache-2.0. Erlang/OTP 27 or newer, Elixir 1.17 or newer -- the same floor as core (ML-DSA-87
as above).

**The project.** Reference documentation is on [HexDocs](https://hexdocs.pm/beam_mcp_signer).
What a host can rely on, and how to report a vulnerability: [`SECURITY.md`](SECURITY.md). Why
Expand Down
22 changes: 15 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ No paid bounty and no guaranteed fix deadline.
The security requirements a host can rely on, each held by a test in `test/`:

- **The key is read from one place.** `sign/2` reads the private key from
`opts[:private_key]` on each call and from nowhere else: no environment variable, file,
`opts[:private_key]` (and `BeamMCP.Signer.MLDSA87` also the public key, from
`opts[:public_key]`) on each call and from nowhere else: no environment variable, file,
application config, default key or store. A two-layer census holds it
(`test/beam_mcp/signer/no_key_source_test.exs`).
- **The key does not leave in a result or an exception.** Every return value is a signature or
Expand All @@ -45,23 +46,30 @@ The security requirements a host can rely on, each held by a test in `test/`:
or core: not an exception raised earlier in core's `Canonical.signature/3` on a mistyped
call, not a log line that inspects the options. Passed as bytes, the key is as safe as every
piece of code that handles the options; tests pin both halves
(`test/beam_mcp/signer/ed25519_test.exs`).
(`test/beam_mcp/signer/ed25519_test.exs`, `test/beam_mcp/signer/mldsa87_refusals_test.exs`).
- **The signature is standard Ed25519** (RFC 8032, FIPS 186-5) over exactly the canonical bytes
`beam_mcp` produced, computed by Erlang/OTP's `:crypto` (OpenSSL), and verifiable by any
Ed25519 implementation.
- **No randomness is needed or used.** Ed25519 signing is deterministic; the package generates
no key and no nonce.
- **The ML-DSA-87 signature is standard ML-DSA-87** (FIPS 204, pure, empty context, as RFC
9964 requires for COSE) over exactly the canonical bytes, computed by `:crypto` (OpenSSL 3.5
or later), and **it verifies under the public key the host handed in**: every signature is
verified under that key, and must not verify over bytes one bit away, before it is returned;
otherwise the answer is a named error and no signature
(`test/beam_mcp/signer/mldsa87_test.exs`).
- **Randomness.** Ed25519 signing is deterministic and uses none. ML-DSA-87 signing is hedged:
OpenSSL's DRBG supplies per-signature randomness, and FIPS 204's construction keeps the
signature secure if that randomness fails. The package generates no key and no nonce.

What it does **not** do: store, load, rotate or protect the key between calls (the host's
decision, `docs/assurance-case.md` says why), verify signatures, or decide what a signature
authorizes.
decision, `docs/assurance-case.md` says why), verify others' signatures (the ML-DSA-87 module
checks only its own, before returning it), or decide what a signature authorizes.

## Severity, in this package's terms

| level | what it means here |
|---|---|
| **Critical** | On a call with the documented types: the key is read from anywhere but the call's options, or reaches a log, an error, a crash report or a return value; or a signature is produced over bytes other than those given. |
| **High** | A signature that standard Ed25519 verification rejects, or accepts over different bytes. |
| **High** | A signature that standard Ed25519 or ML-DSA-87 verification rejects, or accepts over different bytes; an ML-DSA-87 signature returned that does not verify under the public key given. |
| **Medium** | A documented behaviour the package does not match, with a security consequence a host can work around. |
| **Low** | Wrong error terms or documentation that could mislead. |

Expand Down
Loading
Loading