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
42 changes: 40 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ SPDX-License-Identifier: Apache-2.0

## [Unreleased]

## [0.3.0] - 2026-10-21

Three modules added, ML-DSA-87 among them; nothing an `0.2` host calls changes. `Ed25519` and
core's `BeamMCP.Signer` behaviour are as they were, so moving is the requirement alone:
`{:beam_mcp_signer, "~> 0.3"}`. Core requirement unchanged (`~> 0.7`). ML-DSA-87 needs an OTP
whose `:crypto` is linked to OpenSSL 3.5 or later; elsewhere its modules answer
`{:error, :crypto_refused}` and `Ed25519` is unaffected.

- **Provisional formats.** `BeamMCP.Signer.COSE` and `BeamMCP.Signer.COSE.HashEnvelope` write the
shared N6 profile (v0.1, a draft). Its schema numbers and `typ` values are provisional, and a
change to them comes in a new version of this package.
- **Non-claimed.** The hash envelope is not a claimed path under CNSA 2.0 (see its entry).
- **Pre-release review.** Two independent lanes read every module added since 0.2.1, one on key
handling and error surfaces, one on encoding and cryptographic correctness. They are recorded
here; they are not a review under `docs/security-review.md`, which a person does. Fixed in this
release:
- A key function that returned a function was resolved twice. COSE then computed the kid from
the placeholder and returned a carrier with a 48-byte zero kid. Every key read now requires
the key's bytes at their size (`{:public_key, :not_2592_bytes}`), so the kid is always the
signing key's thumbprint.
- Text followed the runtime's NFC normaliser, not N6's text rule. The rule is now Unicode
15.0.0 tables embedded in the package, from `tools/unicode_tables.sh`. Format and
bidirectional controls, unassigned code points and NFC quick-check MAYBE are refused, and no
normaliser is called. An issuer such as `"a\u202Eb"` was signed and is now
`{:issuer, :invalid}`. Text is at most 65,536 bytes, and so is a byte-string issuer.
- The issuer is judged before either key function is called. A `:crypto` refusal while
hashing the kid or the canonical bytes is answered `:crypto_refused`, not raised with the
public key in the frame.
- An improper list given to the encoder is refused, not raised on.
- `sign_stream/2` refuses a function that is not an enumerable (`:bad_arguments`).
- Documented: `sign_digest/2` cannot know its 48 bytes are a digest; one key per scheme;
"the seed is not held through the stream" holds only for a key function that fetches the
seed when it is called.
- Tests: the census bars the process dictionary, ports and `binary_to_term` in every module.
The 10 GiB test also bounds the VM's peak RSS (VmHWM, reset before the stream), with no
sampling gap.

### Added: `BeamMCP.Signer.COSE.HashEnvelope`, large artifacts signed by their digest (non-claimed)

- A new public module, outside core's `BeamMCP.Signer` behaviour, which is unchanged; `COSE`
Expand Down Expand Up @@ -46,11 +83,12 @@ SPDX-License-Identifier: Apache-2.0
- **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}`;
(required; text under N6's text rule or `{:bytes, binary}`, each at most 65,536 bytes). 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.
(v0.1, a draft); 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
Expand Down
39 changes: 39 additions & 0 deletions LICENSES/Unicode-3.0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
UNICODE LICENSE V3

COPYRIGHT AND PERMISSION NOTICE

Copyright © 1991-2023 Unicode, Inc.

NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.

Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.

THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.

IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.

Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ Roles, stated separately because they are separate:
Script Kitty LLC built it.
Ayla Croft authored it. ORCID 0009-0008-9457-2160

lib/beam_mcp/signer/cose/unicode.ex holds tables generated from the Unicode
Character Database, version 15.0.0, Copyright 2022 Unicode, Inc., under the
Unicode License v3 (LICENSES/Unicode-3.0.txt).

Third-party dependencies retain their own licenses; see mix.lock and the
individual package sources for details.
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ in the COSE protected header, inside the signed bytes.
```

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}`.
the signature. `:issuer` is required: text under N6's text rule (Unicode 15.0.0, no format or bidirectional controls, the NFC quick check YES, at most 65,536 bytes), or `{:bytes, binary}` of at most 65,536 bytes.

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):
everything they sign (the N6 profile, v0.1, a draft; provisional until its v1):

```
{0: 0xFFFF0003, ; the single-signer carrier
Expand Down Expand Up @@ -145,7 +145,16 @@ registry:
(`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.
ML-DSA. It is held to RFC 8949 Appendix A and to RFC 9964 Appendix A.2, byte for byte. Text
follows the profile's text rule against Unicode 15.0.0 tables embedded in the package
(`tools/unicode_tables.sh` fetches the data and pins it by sha256), never the runtime's own
Unicode; the suite checks every code point against the data files with a parser of its own.

**One key per scheme.** Keep a COSE key for `BeamMCP.Signer.COSE` and the hash envelope, and do
not hand it to `BeamMCP.Signer.MLDSA87` for anything but core's canonical bytes.
`MLDSA87.sign/2` signs any binary it is given, and a COSE ToBeSigned is a binary. Core's
canonical bytes begin with `{`, and every ToBeSigned begins `84 6a "Signature1"`, so a raw
signature over the connectome is never a COSE one. Other bytes could be.

## Hash envelope (non-claimed)

Expand All @@ -160,7 +169,8 @@ one chunk and a hash state:
typ: "application/vnd.example.b80.test_hash.v0+cose", preimage_type: "video/mp4")
```

`sign_digest/2` takes a digest the host already has. What comes back is the same carrier as
`sign_digest/2` takes a digest the host already has. It checks only the length, so the carrier
says that the key holder asserts that digest. What comes back is the same carrier as
COSE's, with the digest as its payload:

```
Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ the GitHub Advisory Database and OSV, which `mix hex.audit` reads.

| version | supported |
|---|---|
| `0.2.x` | yes |
| `0.3.x` | yes |
| `0.2.x` | no: fixes land on the latest release, and `0.3.0` changes no `0.2` answer, so moving is the requirement alone |
| `0.1.x` | no, superseded (the key could reach an exception report; see the 0.2.0 CHANGELOG entry) |

Within `0.2.x`, take `0.2.1` or later: `0.2.0` could print the key from options that are an
A host still on `0.2.x` should be on `0.2.1`: `0.2.0` could print the key from options that are an
improper list, from an entry `{:private_key, key, extra}`, or from a key function that raised
(the 0.2.1 CHANGELOG entry).

Expand Down
2 changes: 2 additions & 0 deletions docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ read, kept or misused beyond the host's own call.
| 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 |
| A digest taken for the content, or signed under an unnamed hash (a raw signature over a digest) | yes, for the hash envelope | 258 and 259 are in the protected header, and 258 is in `crit`, so a COSE verifier that does not know RFC 9995 refuses rather than misreads (coset does, in CI); label 3 is never written |
| A large artifact exhausting memory, or hashed and then refused | yes, for the hash envelope | `:crypto.hash_init/update/final` over the host's stream, one chunk at a time (a 10 GiB test holds VM growth under 64 MiB); every option is judged before the first chunk; the seed function is called only after the last |
| A raw ML-DSA-87 signature passing for a COSE one (one key used by both) | partly | core's canonical bytes begin `{`, every ToBeSigned `84 6a "Signature1"`, so a raw signature over the connectome is never a COSE signature; `MLDSA87.sign/2` signs any binary, so the README asks one key per scheme |
| Text a reviewer reads differently from what was signed (a bidirectional or zero-width control, an unassigned or not-NFC code point) | yes | N6's text rule against embedded Unicode 15.0.0 tables, no normaliser; every code point checked in the suite against the data files by a parser of its own |
| 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` |
Expand Down
3 changes: 3 additions & 0 deletions docs/security-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ Each item is answered with what was read, what was tried, and what was found.
For the hash envelope: is the header exactly `{1: -50, 2: [258], 4, 16, 258, 259}` plus an
optional 260, never label 3? Is every option judged before a chunk is read, and the seed
function called only after the last? Does the 10 GiB test's memory bound still hold?
For text: does `lib/beam_mcp/signer/cose/text.ex` apply profile v0.1 §2.4 exactly, against
`lib/beam_mcp/signer/cose/unicode.ex` as `tools/unicode_tables.sh` regenerates it from the
pinned data, with no normaliser called anywhere under `lib/`?
5. **Randomness.** Ed25519 uses none; ML-DSA-87's comes from OpenSSL's DRBG. Confirm that no
key or nonce is generated in `lib/`.
6. **Supply chain and release.** `mix.lock` against `mix hex.audit`; the `beam_mcp` requirement;
Expand Down
30 changes: 21 additions & 9 deletions lib/beam_mcp/signer/cose.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule BeamMCP.Signer.COSE do
@moduledoc """
The canonical bytes signed as a statement whose signed bytes name the algorithm and the key:
ML-DSA-87 in COSE (RFC 9964, algorithm -50), in the form Script Kitty's systems share for
everything they sign (the N6 profile, v0, provisional). The same seam as
everything they sign (the N6 profile, v0.1, a draft, provisional). The same seam as
`BeamMCP.Signer.MLDSA87`, the same two keys, and one more option.

Canonical.signature(graph, BeamMCP.Signer.COSE,
Expand Down Expand Up @@ -41,7 +41,7 @@ defmodule BeamMCP.Signer.COSE do

**Options read:** `:private_key` and `:public_key`, as `BeamMCP.Signer.MLDSA87` reads them
(each the bytes or a zero-arity function; each function called once per signature), and
`:issuer`, the statement's issuer, required: UTF-8 text in NFC, or `{:bytes, binary}`.
`:issuer`, the statement's issuer, required: text under N6's text rule (Unicode 15.0.0, no format or bidirectional controls, the NFC quick check YES, at most 65,536 bytes), or `{:bytes, binary}` of at most 65,536 bytes.
Nothing else: no environment variable, file, configuration or default.
"""

Expand All @@ -64,7 +64,7 @@ defmodule BeamMCP.Signer.COSE do
gives, and

* `:no_issuer` -- the option is absent;
* `{:issuer, :invalid}` -- present, and neither UTF-8 text in NFC nor `{:bytes, binary}`.
* `{:issuer, :invalid}` -- present, and neither of those.

Answers, not raises, as `BeamMCP.Signer.MLDSA87` answers; no answer carries a key byte.
"""
Expand All @@ -74,10 +74,11 @@ defmodule BeamMCP.Signer.COSE do
with {:ok, seed} <- Keys.fetch(opts, :private_key, :no_private_key),
{:ok, public} <- Keys.fetch(opts, :public_key, :no_public_key),
{:ok, issuer} <- Keys.fetch(opts, :issuer, :no_issuer),
{:ok, seed} <- Keys.read(seed, :private_key),
{:ok, public} <- Keys.read(public, :public_key),
{:ok, body} <- body(canonical_bytes, issuer),
{:ok, protected} <- header(Keys.kid(public)),
{:ok, public} <- Keys.read(public, :public_key),
{:ok, seed} <- Keys.read(seed, :private_key),
{:ok, kid} <- Keys.kid(public),
{:ok, protected} <- header(kid),
{:ok, to_be_signed} <-
CBOR.encode(["Signature1", {:bytes, protected}, {:bytes, ""}, {:bytes, body}]),
{:ok, signature} <-
Expand All @@ -92,21 +93,32 @@ defmodule BeamMCP.Signer.COSE do

def sign(_canonical_bytes, _opts), do: {:error, :bad_arguments}

# The issuer first, so a key function is never called for a call that is refused anyway.
defp body(canonical_bytes, issuer) do
with {:ok, issuer} <- issuer(issuer) do
with {:ok, issuer} <- issuer(issuer),
{:ok, digest} <- sha384(canonical_bytes) do
CBOR.encode(%{
0 => @schema,
1 => issuer,
2 => %{
0 => [@sha384, {:bytes, :crypto.hash(:sha384, canonical_bytes)}],
0 => [@sha384, {:bytes, digest}],
1 => byte_size(canonical_bytes),
2 => @media_type
}
})
end
end

defp issuer({:bytes, b} = issuer) when is_binary(b), do: {:ok, issuer}
defp sha384(bytes) do
{:ok, :crypto.hash(:sha384, bytes)}
catch
_kind, _reason -> {:error, :crypto_refused}
end

# N6's text limit (E9) for a text issuer comes with the encoder; a byte-string issuer is held to
# the same 65,536 bytes, so a carrier stays far below the 1,048,576 bytes a decoder admits.
defp issuer({:bytes, b} = issuer) when is_binary(b) and byte_size(b) <= 65_536,
do: {:ok, issuer}

defp issuer(t) when is_binary(t) do
case CBOR.encode(t) do
Expand Down
33 changes: 20 additions & 13 deletions lib/beam_mcp/signer/cose/cbor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@

defmodule BeamMCP.Signer.COSE.CBOR do
@moduledoc false
# N6's encoding rules, the encode side only (N6 profile v0 r3 §2, E1-E8): RFC 8949 §4.2.1
# core deterministic encoding -- every head the shortest for its argument, definite lengths,
# map keys sorted by their encoded bytes -- narrowed to what N6 admits: integers in
# [-2^63, 2^64-1], byte strings, UTF-8 text in NFC, arrays, maps with unsigned keys below
# 2^32, false, true and null. Everything else is refused by name and nothing is encoded:
# floats, tags, other simple values, other keys, non-NFC text, any other term.
# N6's encoding rules, the encode side only (N6 profile v0.1 §2, E1-E8, and E9's text limit):
# RFC 8949 §4.2.1 core deterministic encoding -- every head the shortest for its argument,
# definite lengths, map keys sorted by their encoded bytes -- narrowed to what N6 admits:
# integers in [-2^63, 2^64-1], byte strings, text under the profile's text rule
# (`BeamMCP.Signer.COSE.Text`: Unicode 15.0.0, no format controls, the NFC quick check YES,
# at most 65,536 bytes), proper arrays, maps with unsigned keys below 2^32, false, true and
# null. Everything else is refused by name and nothing is encoded: floats, tags, other simple
# values, other keys, text the rule refuses, an improper list, any other term.
#
# Terms: an integer; `{:bytes, binary}` for a byte string; a binary for text; a list; a map;
# `false`, `true`, `nil`. In-house, as N6 asks of every language; no library encodes this.

import Bitwise

alias BeamMCP.Signer.COSE.Text

@type value ::
integer()
| {:bytes, binary()}
Expand All @@ -28,8 +32,7 @@ defmodule BeamMCP.Signer.COSE.CBOR do
:int_out_of_range
| :float_forbidden
| :key_not_uint
| :invalid_utf8
| :not_nfc
| BeamMCP.Signer.COSE.Text.reason()
| :not_encodable

@spec encode(term()) :: {:ok, binary()} | {:error, reason()}
Expand All @@ -48,7 +51,7 @@ defmodule BeamMCP.Signer.COSE.CBOR do
defp enc(nil), do: <<0xF6>>
defp enc({:bytes, b}) when is_binary(b), do: [head(2, byte_size(b)), b]
defp enc(t) when is_binary(t), do: [head(3, byte_size(t)), text(t)]
defp enc(l) when is_list(l), do: [head(4, length(l)) | Enum.map(l, &enc/1)]
defp enc(l) when is_list(l), do: items(l, [], 0)

defp enc(m) when is_map(m) do
pairs =
Expand All @@ -64,11 +67,15 @@ defmodule BeamMCP.Signer.COSE.CBOR do
defp key(k) when is_integer(k) and k >= 0 and k < 1 <<< 32, do: head(0, k)
defp key(_k), do: refuse(:key_not_uint)

# An improper list has no length to write: refused, never raised on.
defp items([], acc, n), do: [head(4, n) | Enum.reverse(acc)]
defp items([x | rest], acc, n), do: items(rest, [enc(x) | acc], n + 1)
defp items(_improper_tail, _acc, _n), do: refuse(:not_encodable)

defp text(t) do
cond do
not String.valid?(t) -> refuse(:invalid_utf8)
:unicode.characters_to_nfc_binary(t) != t -> refuse(:not_nfc)
true -> t
case Text.check(t) do
:ok -> t
{:error, reason} -> refuse(reason)
end
end

Expand Down
Loading
Loading