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
9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ 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).
# The box leg's ML-DSA-87 signature and N6 carrier, for the cross-verify job to check with
# implementations outside Elixir (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
Expand Down Expand Up @@ -100,10 +101,10 @@ jobs:
with:
go-version: "1.27.3"
cache: false
- name: Go crypto/mldsa
- name: Go crypto/mldsa, and the N6 carrier read with fxamacker/cbor
working-directory: tools/cross_verify/go
run: go run . "$GITHUB_WORKSPACE/mldsa87-sample"
- name: aws-lc-rs (the toolchain and every crate pinned)
- name: aws-lc-rs, and the N6 carrier read with coset and ciborium (the toolchain and every crate pinned)
working-directory: tools/cross_verify/rust
run: |
rustup toolchain install 1.92.0 --profile minimal
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ SPDX-License-Identifier: Apache-2.0

## [Unreleased]

### Added: `BeamMCP.Signer.COSE`, ML-DSA-87 in COSE with the algorithm and key signed

- A third module implementing core's `BeamMCP.Signer` behaviour, unchanged. It returns
deterministic CBOR, the N6 single-signer carrier: an untagged, detached COSE_Sign1 (RFC 9052,
RFC 9964 algorithm -50) whose protected header is `{1: -50, 4: kid, 16: typ}`, over a
statement that names the canonical bytes by SHA-384 and length, with the host's issuer. The
`kid` is the key's SHA-384 COSE Key Thumbprint (RFC 9679), computed from the public key.
- **Why:** `Canonical.signature/3` places `scheme:` and `key_id:` beside a raw signature,
unsigned; swapping either leaves the signature valid. Here both are inside the signed bytes.
- **Options:** `BeamMCP.Signer.MLDSA87`'s two keys, read the same way, and `:issuer`
(required; NFC text or `{:bytes, binary}`). New errors: `:no_issuer`, `{:issuer, :invalid}`;
every other answer is `MLDSA87`'s, which makes and checks the signature.
- **Provisional:** the statement's schema number (0x85000101) and `typ`
(`application/vnd.example.b85.connectome.v0+cose`) are provisional, as is the N6 profile
(v0); they change when the shared registry assigns them.
- **Encoder:** the package's own (private), deterministic CBOR under N6's rules, refusing
floats, tags, other simple values, non-integer keys and non-NFC text by name. Held to RFC 8949
Appendix A and RFC 9964 Appendix A.2 byte for byte (`tools/vectors_cose.sh` writes the A.2
fixture from the RFC); a strict reader in the tests checks every encoding without it.
- **Other implementations:** CI's cross-verify job also reads the box leg's carrier with Go
(fxamacker/cbor, `crypto/mldsa`) and Rust (coset, ciborium, aws-lc-rs), each building
ToBeSigned itself; each refuses the signature with the algorithm, key or `typ` changed.
- `mldsa87_test.exs`: a key-reference test's `send(...) && key` became two expressions, which
Elixir 1.20's type checker no longer warns about.

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

- A second module implementing core's `BeamMCP.Signer` behaviour, unchanged: ML-DSA-87
Expand Down
5 changes: 3 additions & 2 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ 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 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.
leg's ML-DSA-87 signature and COSE carrier verified by two other implementations (Go's
`crypto/mldsa` with fxamacker/cbor, and aws-lc-rs with coset), 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
Expand Down
11 changes: 11 additions & 0 deletions LICENSES/BSD-3-Clause.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Copyright (c) <year> <owner>.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ SPDX-License-Identifier: Apache-2.0
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14775/badge)](https://www.bestpractices.dev/projects/14775)

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`
bytes, through OTP's `:crypto`. Three modules, each implementing core's `BeamMCP.Signer`
behaviour (`sign/2`) and nothing else:

- **`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.
- **`BeamMCP.Signer.COSE`**: the same ML-DSA-87 signature inside COSE (RFC 9964, algorithm
-50), with the algorithm and the key's thumbprint in the signed bytes; see [COSE](#cose)
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:
Expand Down Expand Up @@ -89,10 +92,63 @@ true = :crypto.verify(:mldsa87, :none, BeamMCP.Connectome.Canonical.encode!(grap
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
## COSE

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.
`BeamMCP.Signer.COSE` fixes what a raw signature cannot: `Canonical.signature/3` places
`scheme:` and `key_id:` beside the signature, asserted by the host and verified by nobody, so
swapping either leaves a raw signature valid. Here the algorithm and the key's thumbprint sit
in the COSE protected header, inside the signed bytes.

```elixir
{:ok, %{signature: carrier}} =
BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.COSE,
private_key: fn -> seed end, public_key: fn -> public_key end, issuer: "box-17")
```

The keys are `BeamMCP.Signer.MLDSA87`'s, read the same way, and that module makes and checks
the signature. `:issuer` is required: UTF-8 text in NFC, or `{:bytes, binary}`.

What comes back is one binary, deterministic CBOR, in the form Script Kitty's systems share for
everything they sign (the N6 profile, v0; provisional until its v1):

```
{0: 0xFFFF0003, ; the single-signer carrier
1: h'<body>',
2: [h'<protected>', {}, null, h'<signature>']} ; COSE_Sign1, untagged, detached

protected = {1: -50, ; alg: ML-DSA-87
4: h'<48 bytes>', ; kid: SHA-384 COSE Key Thumbprint
16: "application/vnd.example.b85.connectome.v0+cose"} ; typ
body = {0: 0x85000101, ; the statement's schema
1: "<issuer>",
2: {0: [-43, h'<SHA-384 of the canonical bytes>'], 1: <their length>,
2: "application/json"}}
```

**To verify**, with the canonical bytes beside the carrier and the public key from your own key
registry:

1. Decode the carrier, the protected header and the body, refusing anything that is not
deterministic CBOR.
2. Require the protected header to be exactly `{1: -50, 4: kid, 16: typ}`, with `kid` the
SHA-384 of `a3 01 07 03 38 31 20 59 0a 20` followed by the 2,592-byte public key (RFC 9679
over the AKP key).
3. Verify the signature, pure ML-DSA-87 with an empty context, over
`["Signature1", protected, h'', body]` (RFC 9052 section 4.4), the protected bytes as
received.
4. Require the body's digest and length to be the canonical bytes'.

`tools/cross_verify/` does exactly this in Go (`fxamacker/cbor` and `crypto/mldsa`) and in Rust
(`coset`, `ciborium` and aws-lc-rs), and CI runs both on the box leg's carrier on every change.
The schema number and `typ` are provisional and change when the shared registry assigns them.
The encoder is this package's own: no Elixir library writes deterministic CBOR or COSE with
ML-DSA. It is held to RFC 8949 Appendix A and to RFC 9964 Appendix A.2, byte for byte.

## Any module

This package reads the key options, and for COSE the issuer, and nothing else
(`opts[:private_key]`, for ML-DSA-87 `opts[:public_key]`, for COSE `opts[:issuer]`) -- 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
Expand Down
10 changes: 8 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ 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]` (and `BeamMCP.Signer.MLDSA87` also the public key, from
`opts[:public_key]`) on each call and from nowhere else: no environment variable, file,
`opts[:private_key]` (and `BeamMCP.Signer.MLDSA87` and `BeamMCP.Signer.COSE` also the
public key, from `opts[:public_key]`, and COSE the issuer, from `opts[:issuer]`) 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 @@ -56,6 +57,11 @@ The security requirements a host can rely on, each held by a test in `test/`:
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`).
- **The COSE carrier names what signed it, inside what is signed.** `BeamMCP.Signer.COSE`
returns deterministic CBOR whose ML-DSA-87 signature covers the algorithm (-50), the key's
SHA-384 thumbprint (computed from the public key, never taken from the host) and a statement
naming the canonical bytes by SHA-384 and length; change any of them and the signature is
invalid (`test/beam_mcp/signer/cose_test.exs`; Go and Rust verifiers in CI).
- **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.
Expand Down
17 changes: 13 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ SPDX-License-Identifier: Apache-2.0

# Architecture

Two modules, one function each, one dependency direction. Ed25519 is drawn below; ML-DSA-87
takes the same path with two keys and a check before it returns.
Three modules, one function each, one dependency direction. Ed25519 is drawn below; ML-DSA-87
takes the same path with two keys and a check before it returns; COSE builds a statement around
the bytes and has ML-DSA-87 sign that.

```text
host application
Expand Down Expand Up @@ -46,10 +47,18 @@ takes the same path with two keys and a check before it returns.
:self_check_failed}` when that verifies too) before returning it. The public key is the
host's because `:crypto` derives none from a seed. On a runtime without ML-DSA-87 (OTP 27,
or OpenSSL before 3.5) every call is `{:error, :crypto_refused}`.
- **`BeamMCP.Signer.COSE`** (`lib/beam_mcp/signer/cose.ex`): the same behaviour, MLDSA87's
two keys read the same way (each function called once), and `opts[:issuer]`. It builds a
statement about the canonical bytes (their SHA-384 and length, and the issuer) and a COSE
protected header naming the algorithm (-50) and the key (its SHA-384 thumbprint, computed
from the public key), has `BeamMCP.Signer.MLDSA87` sign and check the COSE ToBeSigned over
them, and returns the N6 single-signer carrier. Its one `:crypto` call is SHA-384; it calls
no signing primitive. Its encoder, `BeamMCP.Signer.COSE.CBOR` (private), writes
deterministic CBOR under N6's rules and refuses anything else by name.
- **`beam_mcp`** (a dependency, `~> 0.7`): defines the behaviour and produces the bytes. It
holds no key, calls no signing primitive and does not depend on this package.
- **Erlang/OTP `:crypto`**: performs Ed25519 and ML-DSA-87, through OpenSSL. This package
implements no cryptography itself.
- **Erlang/OTP `:crypto`**: performs Ed25519, ML-DSA-87 and SHA-384, through OpenSSL. This
package implements no cryptography itself; the CBOR encoder is an encoding, not a primitive.

## Properties the arrangement keeps

Expand Down
10 changes: 9 additions & 1 deletion docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ ML-DSA-87 signature (FIPS 204, pure, empty context) over exactly the bytes given
under that public key, or no signature at all; it reads the keys from nowhere else, keeps no
copy of them, and returns nothing that contains them.**

**Given the same two keys and an issuer, `BeamMCP.Signer.COSE.sign/2` returns deterministic
CBOR, the N6 single-signer carrier, whose ML-DSA-87 signature covers a statement naming the
given bytes by SHA-384 and length together with a protected header naming the algorithm (-50)
and the key (its SHA-384 thumbprint, computed from the public key); changing any of them makes
the signature invalid. It reads the keys and the issuer from nowhere else and keeps nothing.**

## Threat model

The asset is the host's private key; the goal is that this package adds no way for it to be
Expand All @@ -33,6 +39,8 @@ read, kept or misused beyond the host's own call.
| The key printed in an exception or a log line | yes | nothing `sign/2` meets is raised: a mistyped call is answered `{:error, :bad_arguments}` (so is an improper list whose end comes before a `:private_key` entry), a failing key function `{:error, {:private_key, :unreadable}}`, a refusal from `:crypto` `{:error, :crypto_refused}`; the key passed by reference prints as `#Function<...>` wherever the options are printed, core's frames included (tested through core) |
| A signature over bytes other than those given, or a non-standard signature | yes | the bytes go to `:crypto.sign/4` unaltered, with the algorithm and `:none` as literals and no options argument (census); tests verify with `:crypto.verify/5` and with core's `encode!/2` bytes |
| ML-DSA-87: a signature that does not verify under the key the host publishes (a seed and public key that are not a pair, a faulted signature) | yes | every signature is verified under the given public key, and must not verify over bytes one bit away, before it is returned; otherwise a named error and no signature |
| The algorithm or the key named beside a signature swapped (scheme and key id are host-asserted, outside the signed bytes) | yes, for `BeamMCP.Signer.COSE` | alg and kid are in the COSE protected header, inside ToBeSigned; tests, and Go and Rust verifiers in CI, show the signature fails once either is changed. The two raw-signature modules do not address it, by their shape |
| COSE output a verifier reads differently (non-deterministic CBOR, an extra header label, a tagged or attached Sign1) | yes | the encoder writes RFC 8949 §4.2.1 under N6's rules and refuses everything else by name, held to RFC 8949 Appendix A and RFC 9964 A.2 byte for byte; the header is exactly `{1, 4, 16}`; two non-Elixir libraries (fxamacker/cbor, coset) re-read it in CI |
| Weak randomness | yes | Ed25519 uses none (deterministic, RFC 8032). ML-DSA-87 is hedged: OpenSSL's DRBG supplies the per-signature `rnd`; were it constant, signing would be FIPS 204's deterministic variant, which is also approved. No key is generated here |
| The host's key storage, the node the host runs, OpenSSL defects | no | the host's and upstream's; stated in `SECURITY.md` |
| Code already running in the same BEAM node | no | the BEAM has no in-node isolation; any process there can read the host's memory |
Expand All @@ -52,7 +60,7 @@ read, kept or misused beyond the host's own call.

| principle | here |
|---|---|
| Economy of mechanism | two modules, one public function each; remote calls `:crypto.sign/4`, and `:crypto.verify/5` for ML-DSA-87's check, pinned by census |
| Economy of mechanism | three modules, one public function each, and a private encoder; remote calls `:crypto.sign/4`, `:crypto.verify/5` for ML-DSA-87's check, and `:crypto.hash/2` (SHA-384) in COSE, pinned by census |
| Fail-safe defaults | no default key; a missing or malformed key is an error, never a signature |
| Complete mediation | the key is checked on every call; nothing is cached |
| Open design | standard Ed25519 and ML-DSA-87, verifiable by any implementation; the source is Apache-2.0 |
Expand Down
6 changes: 6 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ implementing the same behaviour (`BeamMCP.Signer.MLDSA87`), for CNSA 2.0, which
the signature for national security systems acquired from 2027. A third is not planned; if one
is ever needed it follows the same rule, decided in the open.

A third module wraps the second rather than adding an algorithm: `BeamMCP.Signer.COSE` signs
the same ML-DSA-87 inside COSE, so the algorithm and the key are named in the signed bytes and
not only beside them, in the deterministic-CBOR form (N6) Script Kitty's systems share. The
RFC 9995 hash envelope, for artifacts too large to hand over whole, is next, as its own mode
and not a claimed one until its standing under CNSA 2.0 is settled.

## How it changes

A change to this page is made in the pull request that changes direction, and recorded in the
Expand Down
Loading
Loading