diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bd1438..b07936f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,9 +63,15 @@ jobs: - run: mix credo --strict - run: mix hex.audit - run: mix test - # 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). + # The hash envelope's done-when: a 10 GiB file (sparse, so no disk is written) signed in + # constant memory and verified holding only the digest and the signature, its digest held + # to sha384sum's (test/beam_mcp/signer/cose_hash_envelope_ten_gb_test.exs). + - name: a 10 GiB file signed in constant memory (hash envelope) + if: matrix.leg == 'box' + run: mix test --only ten_gb + # The box leg's ML-DSA-87 signature, N6 carrier and hash envelope, 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 @@ -85,7 +91,8 @@ jobs: # 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/). + # over the message with one bit flipped; then read the N6 carrier and the hash envelope with + # its own CBOR and COSE code, build ToBeSigned itself and verify (tools/cross_verify/). cross-verify: name: ML-DSA-87 cross-verify (Go crypto/mldsa, aws-lc-rs) needs: test @@ -101,10 +108,10 @@ jobs: with: go-version: "1.27.3" cache: false - - name: Go crypto/mldsa, and the N6 carrier read with fxamacker/cbor + - name: Go crypto/mldsa, and the N6 carrier and hash envelope read with fxamacker/cbor working-directory: tools/cross_verify/go run: go run . "$GITHUB_WORKSPACE/mldsa87-sample" - - name: aws-lc-rs, and the N6 carrier read with coset and ciborium (the toolchain and every crate pinned) + - name: aws-lc-rs, and the N6 carrier and hash envelope 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f758224..8970ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ SPDX-License-Identifier: Apache-2.0 ## [Unreleased] +### 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` + still exports only `sign/2`. `sign_stream/2` hashes any enumerable of binaries with + `:crypto.hash_init/update/final`, so memory is one chunk whatever the size. + `sign_digest/2` takes a digest the host already has. +- **Output:** the N6 single-signer carrier, with the digest as its payload, signed as an RFC 9995 + COSE Hash Envelope under ML-DSA-87. The protected header is `{1: -50, 2: [258], 4: kid, + 16: typ, 258: -43 | -44, 259: preimage type, ? 260: location}`; label 3 is never written. +- **Why:** a raw signature over a digest leaves the hash algorithm unsigned and the digest + indistinguishable from content. Here both are in the signed bytes, and `crit` makes a verifier + that does not know hash envelopes refuse. +- **Options:** the two keys, read as `MLDSA87` reads them (the seed function is called after + the last chunk); `:typ` and `:preimage_type` (required); `:location` (optional); + `:payload_hash` (`:sha384`, the default, or `:sha512`). Every option is judged before the + first chunk is read. New errors: `:no_typ`, `{:typ, :invalid | :full_payload}`, + `:no_preimage_type`, `{:preimage_type, :invalid}`, `{:location, :invalid}`, + `{:payload_hash, :invalid}`, `{:digest, :wrong_length}`, `{:stream, :not_binary | + :unreadable}`. +- **Non-claimed:** the signature is pure ML-DSA-87, not HashML-DSA, but no NSA, NIST or IETF text + yet names the construction, so it is not a claimed path. +- **Tests:** a 10 GiB file (sparse), signed on CI's box leg (`mix test --only ten_gb`), with the + VM's memory growth held under 64 MiB and the digest held to `sha384sum`'s; a test-side + verifier that holds only the envelope and the public key. CI's Go and Rust cross-verify jobs + read the box leg's envelope. coset 0.4.2 refuses it over `crit` [258] (it predates RFC 9995), + and the Rust check asserts that refusal, then verifies with coset's ToBeSigned. +- `BeamMCP.Signer.COSE`: the option walk, the key read and the kid moved to a private + `BeamMCP.Signer.COSE.Keys`, shared with the hash envelope; its answers do not change. + ### 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 diff --git a/README.md b/README.md index e0dd200..39f880d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 Signers for [`beam_mcp`](https://github.com/ScriptKittyOS/beam_mcp)'s canonical connectome bytes, through OTP's `:crypto`. Three modules, each implementing core's `BeamMCP.Signer` -behaviour (`sign/2`) and nothing else: +behaviour (`sign/2`) and nothing else, and a fourth for artifacts too large to hold whole: - **`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 @@ -18,8 +18,11 @@ behaviour (`sign/2`) and nothing else: - **`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. +- **`BeamMCP.Signer.COSE.HashEnvelope`** (non-claimed): a file of any size, streamed and + signed by its SHA-384 as an RFC 9995 COSE Hash Envelope under the same ML-DSA-87. It is not + a `BeamMCP.Signer`; see [Hash envelope](#hash-envelope-non-claimed) below. -**What they sign.** Exactly the bytes `BeamMCP.Connectome.Canonical.encode/2` produces, as +**What the three 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. @@ -144,11 +147,55 @@ The schema number and `typ` are provisional and change when the shared registry 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. +## Hash envelope (non-claimed) + +`BeamMCP.Signer.COSE.HashEnvelope` signs an artifact by its digest, so a 10 GB clip, tile +bundle or model file never has to be in memory whole. The host streams it in, and memory stays +one chunk and a hash state: + +```elixir +{:ok, envelope} = + BeamMCP.Signer.COSE.HashEnvelope.sign_stream(chunks, # say, a file read 1 MiB at a time + private_key: fn -> seed end, public_key: fn -> public_key end, + 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 +COSE's, with the digest as its payload: + +``` +{0: 0xFFFF0003, 1: h'', 2: [h'', {}, null, h'']} + +protected = {1: -50, 2: [258], 4: h'', 16: "", + 258: -43, ; payload-hash-alg: SHA-384 + 259: "video/mp4", ; preimage-content-type + 260: ""} ; optional hint, never trusted +``` + +- **What it fixes.** A raw signature over a digest leaves the hash algorithm unsigned and the + digest indistinguishable from content. Here both the hash algorithm and the preimage's type + are in the protected header. +- **How a verifier reads it.** `crit` lists 258, so a COSE verifier that does not know hash + envelopes refuses the signature. One that does needs only the envelope and the public key. + It rebuilds `["Signature1", protected, h'', digest]` and verifies. When it holds the + artifact, it also checks the digest. +- **The `typ` is the host's** and required. It is a lowercase media type ending `+cose` that + names the kind of statement; the connectome's `typ` is refused. The preimage type is per + artifact. `payload_hash: :sha512` gives SHA-512 (-44). +- **Non-claimed.** The signature is pure ML-DSA-87, not HashML-DSA, but no NSA, NIST or IETF + text yet names this construction. A verified envelope is judged non-claimed, and + `BeamMCP.Signer.COSE` stays the claimed path. +- **Tested.** CI's box leg signs a 10 GiB file, holds the VM's memory growth under 64 MiB and + compares the digest with `sha384sum`'s. The Go and Rust verifiers read each run's envelope. + `coset` refuses it over `crit` (it predates RFC 9995), and the Rust check confirms that + refusal before verifying with coset's ToBeSigned. + ## 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. +This package reads the key options, for COSE the issuer, and for the hash envelope its own +options listed above, and nothing else (`opts[:private_key]`, for ML-DSA-87 `opts[:public_key]`, +for COSE `opts[:issuer]`). There is no environment variable, no file and no application config. +No key, issuer or `typ` has a 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 diff --git a/docs/architecture.md b/docs/architecture.md index 49d9631..b25c6ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,8 @@ SPDX-License-Identifier: Apache-2.0 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. +the bytes and has ML-DSA-87 sign that. A fourth, the hash envelope, sits outside the behaviour +and has ML-DSA-87 sign a streamed artifact's digest. ```text host application @@ -55,6 +56,14 @@ the bytes and has ML-DSA-87 sign that. 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. +- **`BeamMCP.Signer.COSE.HashEnvelope`** (`lib/beam_mcp/signer/cose/hash_envelope.ex`), outside + the behaviour and non-claimed. It takes `sign_digest/2` (a digest the host has) or + `sign_stream/2` (any enumerable of binaries, hashed with `:crypto.hash_init/update/final`). + It judges every option, the public key and the hash before reading a chunk. It builds the + RFC 9995 protected header (`{1: -50, 2: [258], 4: kid, 16: typ, 258, 259, ?260}`), has + `BeamMCP.Signer.MLDSA87` sign ToBeSigned over the digest, and returns the same carrier, the + digest as its payload. It and COSE share `BeamMCP.Signer.COSE.Keys` (private) for the option + walk, the key read and the kid. - **`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, ML-DSA-87 and SHA-384, through OpenSSL. This diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 93a629d..7939477 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -26,6 +26,13 @@ given bytes by SHA-384 and length together with a protected header naming the al 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.** +**Given the same two keys, a `typ` and a preimage type, `BeamMCP.Signer.COSE.HashEnvelope` +returns the same carrier, its payload the SHA-384 (or SHA-512) of the stream or digest given. +Its ML-DSA-87 signature covers that digest together with a protected header naming the +algorithm, the key, the `typ`, the hash algorithm (258, listed in `crit`) and the preimage type +(259). Changing any of them makes the signature invalid. Memory is independent of the stream's +length. Every option is judged before the stream is read. It is a non-claimed path.** + ## Threat model The asset is the host's private key; the goal is that this package adds no way for it to be @@ -40,6 +47,8 @@ read, kept or misused beyond the host's own call. | 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 | +| 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 | | 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` | @@ -60,7 +69,7 @@ read, kept or misused beyond the host's own call. | principle | here | |---|---| -| 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 | +| Economy of mechanism | three modules, one public function each; the hash envelope, two; a private encoder and key helpers; remote calls `:crypto.sign/4`, `:crypto.verify/5` for ML-DSA-87's check, `:crypto.hash/2` (SHA-384) in COSE, and the streaming hash calls in the hash envelope, 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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index ca068d7..6688cf7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,8 +39,9 @@ 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. +RFC 9995 hash envelope, for artifacts too large to hand over whole, is its own module beside +it (`BeamMCP.Signer.COSE.HashEnvelope`), outside the behaviour. It is not a claimed path until +its standing under CNSA 2.0 is settled. ## How it changes diff --git a/docs/security-review.md b/docs/security-review.md index f9db26e..8765930 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -22,7 +22,8 @@ boundaries in `docs/assurance-case.md`. Each item is answered with what was read, what was tried, and what was found. 1. **One source for the key.** Read `lib/beam_mcp/signer/ed25519.ex`, - `lib/beam_mcp/signer/mldsa87.ex` and `lib/beam_mcp/signer/cose.ex` in full. Is each key read + `lib/beam_mcp/signer/mldsa87.ex`, `lib/beam_mcp/signer/cose.ex`, `lib/beam_mcp/signer/cose/keys.ex` + and `lib/beam_mcp/signer/cose/hash_envelope.ex` in full. Is each key read from its option only (`:private_key`, and `:public_key` for ML-DSA-87 and COSE)? Does the census's regex and call list still describe the compiled module (`test/beam_mcp/signer/no_key_source_test.exs`)? 2. **Nothing kept.** No process state, table, persistent term, application environment or @@ -39,6 +40,9 @@ Each item is answered with what was read, what was tried, and what was found. from the public key, and does ToBeSigned cover the body the carrier carries? Does the encoder (`lib/beam_mcp/signer/cose/cbor.ex`) refuse every float, tag, non-NFC text and non-integer key, and does a carrier re-read by an outside library re-encode identically? + 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? 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; diff --git a/lib/beam_mcp/signer/cose.ex b/lib/beam_mcp/signer/cose.ex index 966c6e7..4a54701 100644 --- a/lib/beam_mcp/signer/cose.ex +++ b/lib/beam_mcp/signer/cose.ex @@ -47,7 +47,7 @@ defmodule BeamMCP.Signer.COSE do @behaviour BeamMCP.Signer - alias BeamMCP.Signer.COSE.CBOR + alias BeamMCP.Signer.COSE.{CBOR, Keys} alias BeamMCP.Signer.MLDSA87 @single_schema 0xFFFF0003 @@ -56,9 +56,6 @@ defmodule BeamMCP.Signer.COSE do @media_type "application/json" @alg -50 @sha384 -43 - # RFC 9679's thumbprint input for the AKP key {1: 7, 3: -50, -1: pub} under RFC 8949 §4.2.1: - # this head, then the 2,592 public-key bytes (N6 profile v0 r3 §5). - @thumbprint_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> @doc """ Signs `canonical_bytes` as an N6 statement in the single-signer carrier. @@ -74,13 +71,13 @@ defmodule BeamMCP.Signer.COSE do @impl BeamMCP.Signer @spec sign(binary(), keyword()) :: {:ok, binary()} | {:error, term()} def sign(canonical_bytes, opts) when is_binary(canonical_bytes) and is_list(opts) do - with {:ok, seed} <- fetch(opts, :private_key), - {:ok, public} <- fetch(opts, :public_key), - {:ok, issuer} <- fetch(opts, :issuer), - {:ok, seed} <- read(seed, :private_key), - {:ok, public} <- read(public, :public_key), + 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} <- protected(public), + {:ok, protected} <- header(Keys.kid(public)), {:ok, to_be_signed} <- CBOR.encode(["Signature1", {:bytes, protected}, {:bytes, ""}, {:bytes, body}]), {:ok, signature} <- @@ -95,26 +92,6 @@ defmodule BeamMCP.Signer.COSE do def sign(_canonical_bytes, _opts), do: {:error, :bad_arguments} - # The first `{name, value}` entry, walked here for the reason `BeamMCP.Signer.Ed25519` walks its - # own: `Keyword.fetch/2` raises, key in view, on an improper list. - defp fetch([{name, value} | _], name), do: {:ok, value} - defp fetch([_ | rest], name), do: fetch(rest, name) - defp fetch([], :private_key), do: {:error, :no_private_key} - defp fetch([], :public_key), do: {:error, :no_public_key} - defp fetch([], :issuer), do: {:error, :no_issuer} - defp fetch(_improper_tail, _name), do: {:error, :bad_arguments} - - # A reference is called once, here, and the bytes go on to MLDSA87, which checks their sizes - # and reads them as it reads bytes a host passes; what a function raises, throws or exits is - # answered as unreadable, the reason dropped unread. - defp read(key, name) when is_function(key, 0) do - {:ok, key.()} - catch - _kind, _reason -> {:error, {name, :unreadable}} - end - - defp read(key, _name), do: {:ok, key} - defp body(canonical_bytes, issuer) do with {:ok, issuer} <- issuer(issuer) do CBOR.encode(%{ @@ -140,12 +117,5 @@ defmodule BeamMCP.Signer.COSE do defp issuer(_other), do: {:error, {:issuer, :invalid}} - # The thumbprint needs exactly the 2,592-byte key; any other size is MLDSA87's answer to give, - # so a key of the wrong size gets a placeholder kid here and is refused there, before signing. - defp protected(public) when is_binary(public) and byte_size(public) == 2592, - do: header(:crypto.hash(:sha384, @thumbprint_prefix <> public)) - - defp protected(_public), do: header(<<0::384>>) - defp header(kid), do: CBOR.encode(%{1 => @alg, 4 => {:bytes, kid}, 16 => @typ}) end diff --git a/lib/beam_mcp/signer/cose/hash_envelope.ex b/lib/beam_mcp/signer/cose/hash_envelope.ex new file mode 100644 index 0000000..e375e79 --- /dev/null +++ b/lib/beam_mcp/signer/cose/hash_envelope.ex @@ -0,0 +1,266 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.COSE.HashEnvelope do + @moduledoc """ + **Non-claimed.** A large artifact signed by its digest: an RFC 9995 COSE Hash Envelope under + ML-DSA-87 (RFC 9964, algorithm -50), in the N6 profile's single-signer carrier (v0.1, + provisional). Its signatures are pure ML-DSA-87 from CNSA 2.0's algorithms, but no NSA, NIST + or IETF text yet names this construction, so a verified envelope is judged non-claimed, never + a claim. `BeamMCP.Signer.COSE` remains the claimed path, for the connectome. + + # chunks: the host's stream of the artifact, say a file read 1 MiB at a time + HashEnvelope.sign_stream(chunks, + private_key: fn -> seed end, + public_key: fn -> public_key end, + typ: "application/vnd.example.b80.test_hash.v0+cose", + preimage_type: "video/mp4") + + **Why.** `BeamMCP.Signer.COSE.sign/2` and `BeamMCP.Signer.MLDSA87.sign/2` take one binary, so + a 10 GB clip, tile bundle or model file would have to be held in memory whole. A raw + signature over its digest would leave the hash algorithm unsigned, and the digest could pass + for the content. Here the hash algorithm (label 258) and the preimage's media type (259) are + in the protected header, inside the signed bytes, so a verifier needs only the digest and the + signature. It is not HashML-DSA: the signature is ordinary pure ML-DSA-87, empty context, over + the COSE ToBeSigned, whose payload happens to be a digest. + + **What comes back** is one binary, the N6 single-signer carrier, deterministic CBOR: + + {0: 0xFFFF0003, ; the single-signer carrier + 1: h'', ; the artifact's SHA-384 (or SHA-512) + 2: [h'', {}, null, h'']} ; COSE_Sign1, untagged, detached + + * The **protected header** is `{1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: preimage + type}`, with `260: location` when one is given, and nothing else. `crit` (2) lists 258, so a + COSE verifier that does not know hash envelopes refuses the signature instead of taking the + digest for the content. Label 3 (content type) is never written (RFC 9995 §4). `kid` is the + 48-byte SHA-384 COSE Key Thumbprint (RFC 9679) of the public key, computed from the key on + every call. + * The **signature** is over `["Signature1", protected, h'', digest]` (RFC 9052 §4.4), made and + checked by `BeamMCP.Signer.MLDSA87`: it verifies under the public key, and not over bytes + one bit away, or no carrier comes back. + + **The statement's kind is the host's.** A hash envelope has no body, so its `typ` alone names + what the statement is, and a kind belongs to the party that publishes it. So `typ` is + required and never defaulted. It must be a lowercase media type with no parameters, ending + `+cose`. The connectome's `typ` is refused, because that kind is signed in full by + `BeamMCP.Signer.COSE`. The preimage's media type (259) types only the bytes hashed. It is + given per artifact. + + **Options read:** + + * `:private_key`, `:public_key`: as `BeamMCP.Signer.MLDSA87` reads them. The public key is + read first, before any chunk, since the kid needs it. A private key function is called + once, after the last chunk, so the seed is not held through the stream. + * `:typ`: required, as above. + * `:preimage_type`: required. A media type (`type/subtype`, lowercase, no parameters, at + most 255 bytes), or a CoAP content-format number in 0..65535. + * `:location`: optional. A hint of 1 to 2,048 printable ASCII bytes; signed, never trusted. + * `:payload_hash`: `:sha384` (the default, -43) or `:sha512` (-44). + + Nothing else: no environment variable, file or configuration. + """ + + alias BeamMCP.Signer.COSE.{CBOR, Keys} + alias BeamMCP.Signer.MLDSA87 + + @single_schema 0xFFFF0003 + @alg -50 + @crit [258] + @payload_hash_alg 258 + @preimage_content_type 259 + @payload_location 260 + @hashes %{sha384: {-43, 48}, sha512: {-44, 64}} + # `BeamMCP.Signer.COSE`'s kind, signed in full: never a hash envelope. + @full_payload_typs ["application/vnd.example.b85.connectome.v0+cose"] + # N6 profile v0.1 §4: `type "/" subtype`, lowercase, no parameters, 1 to 255 bytes. + @media_type ~r/\A[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\z/ + @location ~r/\A[\x21-\x7e]{1,2048}\z/ + + @typedoc "A refusal. No reason carries a byte of either key." + @type reason :: + :no_private_key + | :no_public_key + | :no_typ + | :no_preimage_type + | {:typ, :invalid | :full_payload} + | {:preimage_type, :invalid} + | {:location, :invalid} + | {:payload_hash, :invalid} + | {:digest, :wrong_length} + | {:stream, :not_binary | :unreadable} + | {:private_key, :not_32_bytes | :unreadable} + | {:public_key, :not_2592_bytes | :unreadable | :does_not_verify} + | :bad_arguments + | :crypto_refused + | :self_check_failed + + @doc """ + Signs an artifact by `digest`, its SHA-384 (48 bytes) or, with `payload_hash: :sha512`, its + SHA-512 (64 bytes), computed by the host. + + Returns `{:ok, carrier}` or `{:error, reason}`. The reasons are every one + `BeamMCP.Signer.MLDSA87.sign/2` gives, and: + + * `:no_typ`, `:no_preimage_type`: the option is absent; + * `{:typ, :invalid}`, `{:typ, :full_payload}`, `{:preimage_type, :invalid}`, + `{:location, :invalid}`, `{:payload_hash, :invalid}`: present, and not as above; + * `{:digest, :wrong_length}`: not the hash's length. + + Answers, not raises, as `BeamMCP.Signer.MLDSA87` answers. + """ + @spec sign_digest(binary(), keyword()) :: {:ok, binary()} | {:error, reason()} + def sign_digest(digest, opts) when is_binary(digest) and is_list(opts) do + with {:ok, signer} <- prepare(opts), + :ok <- digest_length(digest, signer.length), + :ok <- offered() do + sign(digest, signer) + end + end + + def sign_digest(_digest, _opts), do: {:error, :bad_arguments} + + @doc """ + Signs the artifact `chunks` enumerates, any `Enumerable` of binaries, hashed as it is read with + `:crypto.hash_init/1`, `hash_update/2` and `hash_final/1`. Memory is one chunk and the hash + state, whatever the artifact's size. The host opens the artifact, for example + as a file streamed in 1 MiB chunks; this module opens nothing. + + Every option is judged before the first chunk is read, so a long stream is never hashed and + then refused over an option. The reasons are `sign_digest/2`'s, except the digest length, and: + + * `{:stream, :not_binary}`: an element is not a binary; nothing after it is read; + * `{:stream, :unreadable}`: the enumerable raised, threw or exited (a file that could not be + read, say); the reason is dropped unread. + """ + @spec sign_stream(Enumerable.t(), keyword()) :: {:ok, binary()} | {:error, reason()} + def sign_stream(chunks, opts) when is_list(opts) do + if Enumerable.impl_for(chunks) do + with {:ok, signer} <- prepare(opts), + :ok <- offered(), + {:ok, digest} <- hash(chunks, signer.hash) do + sign(digest, signer) + end + else + {:error, :bad_arguments} + end + end + + def sign_stream(_chunks, _opts), do: {:error, :bad_arguments} + + # Every option, the public key and, unless it is a function, the private key, judged before any + # hashing. Each entry point then asks whether ML-DSA-87 is offered at all, so a runtime without + # it refuses before the stream rather than after. + defp prepare(opts) do + with {:ok, seed} <- Keys.fetch(opts, :private_key, :no_private_key), + {:ok, public} <- Keys.fetch(opts, :public_key, :no_public_key), + {:ok, typ} <- Keys.fetch(opts, :typ, :no_typ), + {:ok, preimage_type} <- Keys.fetch(opts, :preimage_type, :no_preimage_type), + {:ok, location} <- Keys.fetch(opts, :location, {:default, nil}), + {:ok, hash} <- Keys.fetch(opts, :payload_hash, {:default, :sha384}), + {:ok, public} <- Keys.read(public, :public_key), + :ok <- size(public, 2592, {:public_key, :not_2592_bytes}), + :ok <- seed_size(seed), + {:ok, {alg, length}} <- payload_hash(hash), + {:ok, protected} <- protected(public, typ, preimage_type, location, alg) do + {:ok, %{seed: seed, public: public, protected: protected, hash: hash, length: length}} + end + end + + defp size(bytes, size, _reason) when is_binary(bytes) and byte_size(bytes) == size, do: :ok + defp size(_bytes, _size, reason), do: {:error, reason} + + defp seed_size(seed) when is_function(seed, 0), do: :ok + defp seed_size(seed), do: size(seed, 32, {:private_key, :not_32_bytes}) + + defp payload_hash(hash) when is_map_key(@hashes, hash), do: {:ok, Map.fetch!(@hashes, hash)} + defp payload_hash(_hash), do: {:error, {:payload_hash, :invalid}} + + defp digest_length(digest, length) when byte_size(digest) == length, do: :ok + defp digest_length(_digest, _length), do: {:error, {:digest, :wrong_length}} + + defp protected(public, typ, preimage_type, location, alg) do + with {:ok, typ} <- typ(typ), + {:ok, preimage_type} <- preimage_type(preimage_type), + {:ok, header} <- location(location, base(public, typ, preimage_type, alg)) do + CBOR.encode(header) + end + end + + defp base(public, typ, preimage_type, alg) do + %{ + 1 => @alg, + 2 => @crit, + 4 => {:bytes, Keys.kid(public)}, + 16 => typ, + @payload_hash_alg => alg, + @preimage_content_type => preimage_type + } + end + + defp typ(typ) when typ in @full_payload_typs, do: {:error, {:typ, :full_payload}} + + defp typ(typ) when is_binary(typ) do + if media_type?(typ) and String.ends_with?(typ, "+cose"), + do: {:ok, typ}, + else: {:error, {:typ, :invalid}} + end + + defp typ(_typ), do: {:error, {:typ, :invalid}} + + defp preimage_type(n) when is_integer(n) and n in 0..0xFFFF, do: {:ok, n} + + defp preimage_type(t) when is_binary(t) do + if media_type?(t), do: {:ok, t}, else: {:error, {:preimage_type, :invalid}} + end + + defp preimage_type(_other), do: {:error, {:preimage_type, :invalid}} + + defp location(nil, header), do: {:ok, header} + + defp location(l, header) when is_binary(l) do + if l =~ @location, + do: {:ok, Map.put(header, @payload_location, l)}, + else: {:error, {:location, :invalid}} + end + + defp location(_other, _header), do: {:error, {:location, :invalid}} + + defp media_type?(t), do: String.valid?(t) and t =~ @media_type + + # Where `:crypto` does not offer ML-DSA-87 (OTP 27, an OpenSSL before 3.5). MLDSA87 still + # answers `:crypto_refused` when it is offered and then will not sign (a FIPS provider). + defp offered do + if :mldsa87 in :crypto.supports(:public_keys), do: :ok, else: {:error, :crypto_refused} + end + + # The enumerable is the host's code, a file read among it: what it raises, throws or exits is + # answered as unreadable, the reason dropped unread. + defp hash(chunks, hash) do + chunks + |> Enum.reduce_while(:crypto.hash_init(hash), fn + chunk, state when is_binary(chunk) -> {:cont, :crypto.hash_update(state, chunk)} + _other, _state -> {:halt, :not_binary} + end) + |> case do + :not_binary -> {:error, {:stream, :not_binary}} + state -> {:ok, :crypto.hash_final(state)} + end + catch + _kind, _reason -> {:error, {:stream, :unreadable}} + end + + defp sign(digest, signer) do + with {:ok, seed} <- Keys.read(signer.seed, :private_key), + {:ok, to_be_signed} <- + CBOR.encode(["Signature1", {:bytes, signer.protected}, {:bytes, ""}, {:bytes, digest}]), + {:ok, signature} <- + MLDSA87.sign(to_be_signed, private_key: seed, public_key: signer.public) do + CBOR.encode(%{ + 0 => @single_schema, + 1 => {:bytes, digest}, + 2 => [{:bytes, signer.protected}, %{}, nil, {:bytes, signature}] + }) + end + end +end diff --git a/lib/beam_mcp/signer/cose/keys.ex b/lib/beam_mcp/signer/cose/keys.ex new file mode 100644 index 0000000..12de41b --- /dev/null +++ b/lib/beam_mcp/signer/cose/keys.ex @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.COSE.Keys do + @moduledoc false + # What `BeamMCP.Signer.COSE` and `BeamMCP.Signer.COSE.HashEnvelope` share: the option walk, + # a key reference read once, and the kid (N6 profile v0.1 §5.2). + + # RFC 9679's thumbprint input for the AKP key {1: 7, 3: -50, -1: pub} under RFC 8949 §4.2.1: + # this head, then the 2,592 public-key bytes. + @thumbprint_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> + + # The first `{name, value}` entry, walked here for the reason `BeamMCP.Signer.Ed25519` walks its + # own: `Keyword.fetch/2` raises, key in view, on an improper list. `missing` is the answer when + # there is none, or `{:default, value}` for an option that has one. + def fetch([{name, value} | _], name, _missing), do: {:ok, value} + def fetch([_ | rest], name, missing), do: fetch(rest, name, missing) + def fetch([], _name, {:default, value}), do: {:ok, value} + def fetch([], _name, missing), do: {:error, missing} + def fetch(_improper_tail, _name, _missing), do: {:error, :bad_arguments} + + # A reference is called once, here, and the bytes go on to MLDSA87, which checks their sizes + # and reads them as it reads bytes a host passes; what a function raises, throws or exits is + # answered as unreadable, the reason dropped unread. + def read(key, name) when is_function(key, 0) do + {:ok, key.()} + catch + _kind, _reason -> {:error, {name, :unreadable}} + end + + def read(key, _name), do: {:ok, key} + + # The thumbprint needs exactly the 2,592-byte key; any other size is MLDSA87's answer to give, + # so a key of the wrong size gets a placeholder kid here and is refused there, before signing. + def kid(public) when is_binary(public) and byte_size(public) == 2592, + do: :crypto.hash(:sha384, @thumbprint_prefix <> public) + + def kid(_public), do: <<0::384>> +end diff --git a/test/beam_mcp/signer/cose_hash_envelope_ten_gb_test.exs b/test/beam_mcp/signer/cose_hash_envelope_ten_gb_test.exs new file mode 100644 index 0000000..1cc8767 --- /dev/null +++ b/test/beam_mcp/signer/cose_hash_envelope_ten_gb_test.exs @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.COSE.HashEnvelopeTenGBTest do + # A 10 GiB file signed in constant memory, and verified holding only the digest and the + # signature. The file is sparse (no disk is written, the kernel reads it back as zeros), so + # the test costs one SHA-384 pass in the VM and one in `sha384sum`, an implementation outside + # it. Excluded by default; run with `mix test --only ten_gb` (CI's box leg runs it). + use ExUnit.Case, async: false + + alias BeamMCP.Signer.COSE.HashEnvelope + alias BeamMCP.Signer.Test.HashEnvelopeVerifier + + @moduletag :ten_gb + @moduletag :mldsa87 + @moduletag timeout: 900_000 + + @pair "test/fixtures/mldsa87/acvp_keygen.json" + |> File.read!() + |> Jason.decode!() + |> Map.fetch!("test") + @seed Base.decode16!(@pair["seed"]) + @pub Base.decode16!(@pair["pk"]) + + @size 10 * 1024 * 1024 * 1024 + @chunk 1_048_576 + @growth_limit 64 * 1_048_576 + + setup do + path = Path.join(System.tmp_dir!(), "hash-envelope-10g-#{System.unique_integer([:positive])}") + {:ok, fd} = :file.open(path, [:write, :raw]) + {:ok, @size} = :file.position(fd, @size) + :ok = :file.truncate(fd) + :ok = :file.close(fd) + on_exit(fn -> File.rm(path) end) + %{path: path} + end + + # The VM's total memory, sampled every 50 ms until told to stop: the peak comes back. + defp sample(peak) do + receive do + {:stop, from} -> send(from, {:peak, peak}) + after + 50 -> sample(max(peak, :erlang.memory(:total))) + end + end + + test "10 GiB through sign_stream/2: memory stays flat, and the digest is sha384sum's", %{ + path: path + } do + assert File.stat!(path).size == @size + :erlang.garbage_collect() + start = :erlang.memory(:total) + sampler = spawn_link(fn -> sample(start) end) + + {micros, result} = + :timer.tc(fn -> + HashEnvelope.sign_stream(File.stream!(path, @chunk), + private_key: @seed, + public_key: @pub, + typ: "application/vnd.example.b80.test_hash.v0+cose", + preimage_type: "application/octet-stream" + ) + end) + + send(sampler, {:stop, self()}) + assert_receive {:peak, peak}, 1_000 + assert {:ok, carrier} = result + + growth = peak - start + + IO.puts( + "\n10 GiB: #{div(micros, 1000)} ms; VM memory at start #{start} B, peak #{peak} B, " <> + "growth #{growth} B (limit #{@growth_limit}); carrier #{byte_size(carrier)} B" + ) + + assert growth < @growth_limit + + # The verifier holds the carrier and the public key, never the file. + assert {:ok, %{digest: digest, payload_hash: -43}} = + HashEnvelopeVerifier.verify(carrier, @pub) + + {out, 0} = System.cmd("sha384sum", [path]) + [hex | _] = String.split(out) + assert Base.encode16(digest, case: :lower) == hex + end +end diff --git a/test/beam_mcp/signer/cose_hash_envelope_test.exs b/test/beam_mcp/signer/cose_hash_envelope_test.exs new file mode 100644 index 0000000..a54696b --- /dev/null +++ b/test/beam_mcp/signer/cose_hash_envelope_test.exs @@ -0,0 +1,372 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.COSE.HashEnvelopeTest do + # The carrier read back by the strict decoder (test/support/strict_cbor.exs) and judged by + # BeamMCP.Signer.Test.HashEnvelopeVerifier, which holds the carrier and the public key and + # never the preimage, so the module is held to the N6 hash-envelope entry point (v0.1 §5.5, + # RFC 9995) and not to itself. The key pair is ACVP's (tgId 3, tcId 51), as in cose_test.exs. + use ExUnit.Case, async: true + + alias BeamMCP.Signer.{COSE, MLDSA87} + alias BeamMCP.Signer.COSE.{CBOR, HashEnvelope} + alias BeamMCP.Signer.Test.{HashEnvelopeVerifier, StrictCBOR} + + @pair "test/fixtures/mldsa87/acvp_keygen.json" + |> File.read!() + |> Jason.decode!() + |> Map.fetch!("test") + @seed Base.decode16!(@pair["seed"]) + @pub Base.decode16!(@pair["pk"]) + + @typ "application/vnd.example.b80.test_hash.v0+cose" + @thumbprint_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> + + defp opts(extra \\ []) do + Keyword.merge( + [private_key: @seed, public_key: @pub, typ: @typ, preimage_type: "video/mp4"], + extra + ) + end + + defp verify(carrier), do: HashEnvelopeVerifier.verify(carrier, @pub) + + # Elements taken from the stream are counted, so a refusal can be shown to read none. + defp counted(chunks) do + me = self() + + Stream.map(chunks, fn chunk -> + send(me, :chunk) + chunk + end) + end + + defp mailbox do + receive do + message -> [message | mailbox()] + after + 0 -> [] + end + end + + describe "PQS-8: a large artifact, today" do + test "no signer takes a stream: each wants the whole artifact as one binary" do + chunks = Stream.repeatedly(fn -> "x" end) |> Stream.take(3) + + assert COSE.sign(chunks, private_key: @seed, public_key: @pub, issuer: "i") == + {:error, :bad_arguments} + + assert MLDSA87.sign(chunks, private_key: @seed, public_key: @pub) == + {:error, :bad_arguments} + end + end + + describe "the carrier, judged holding only the digest and the signature" do + @describetag :mldsa87 + + test "{0: 0xFFFF0003, 1: digest, 2: [protected, {}, nil, signature]}, and it verifies" do + digest = :crypto.hash(:sha384, "a clip") + assert {:ok, out} = HashEnvelope.sign_digest(digest, opts()) + assert {:ok, %{digest: ^digest, payload_hash: -43, typ: @typ}} = verify(out) + + {:ok, %{0 => 0xFFFF0003, 1 => {:bytes, ^digest}, 2 => sign1}} = StrictCBOR.decode(out) + assert [{:bytes, _protected}, %{}, nil, {:bytes, signature}] = sign1 + assert byte_size(signature) == 4627 + end + + test "the protected header is {1: -50, 2: [258], 4: kid48, 16: typ, 258: -43, 259: type}, nothing else" do + {:ok, out} = HashEnvelope.sign_digest(:crypto.hash(:sha384, "a"), opts()) + kid = :crypto.hash(:sha384, @thumbprint_prefix <> @pub) + + assert StrictCBOR.decode(HashEnvelopeVerifier.protected(out)) == + {:ok, + %{ + 1 => -50, + 2 => [258], + 4 => {:bytes, kid}, + 16 => @typ, + 258 => -43, + 259 => "video/mp4" + }} + end + + test "the labels are in canonical order: 1, 2, 4, 16, 258, 259, 260" do + {:ok, out} = + HashEnvelope.sign_digest(:crypto.hash(:sha384, "a"), opts(location: "clips/17.mp4")) + + protected = HashEnvelopeVerifier.protected(out) + # a7: seven pairs. 01 38 31: 1 => -50. 02 81 19 01 02: 2 => [258]. 04 58 30: 4 => 48 bytes. + assert <<0xA7, 0x01, 0x38, 0x31, 0x02, 0x81, 0x19, 0x01, 0x02, 0x04, 0x58, 0x30, + _kid::binary-48, 0x10, rest::binary>> = protected + + typ = <<0x78, byte_size(@typ)>> <> @typ + n = byte_size(typ) + + assert <<^typ::binary-size(^n), 0x19, 0x01, 0x02, 0x38, 0x2A, 0x19, 0x01, 0x03, 0x69, + "video/mp4", 0x19, 0x01, 0x04, 0x6C, "clips/17.mp4">> = rest + end + + test "SHA-512 gives 258 = -44 and a 64-byte payload" do + digest = :crypto.hash(:sha512, "a") + {:ok, out} = HashEnvelope.sign_digest(digest, opts(payload_hash: :sha512)) + assert {:ok, %{digest: ^digest, payload_hash: -44}} = verify(out) + end + + test "259 may be a CoAP content-format number" do + {:ok, out} = HashEnvelope.sign_digest(:crypto.hash(:sha384, "a"), opts(preimage_type: 42)) + assert {:ok, %{259 => 42}} = StrictCBOR.decode(HashEnvelopeVerifier.protected(out)) + assert {:ok, _} = verify(out) + end + + test "changing any protected label, or the digest, breaks the signature" do + {:ok, out} = + HashEnvelope.sign_digest(:crypto.hash(:sha384, "a"), opts(location: "clips/17.mp4")) + + {:ok, header} = StrictCBOR.decode(HashEnvelopeVerifier.protected(out)) + {:bytes, <>} = header[4] + + for changed <- [ + Map.put(header, 1, -49), + Map.delete(header, 2), + Map.put(header, 4, {:bytes, <>}), + Map.put(header, 16, "application/vnd.example.b80.other.v0+cose"), + Map.put(header, 258, -44), + Map.put(header, 259, "application/octet-stream"), + Map.put(header, 260, "clips/18.mp4") + ] do + {:ok, protected} = CBOR.encode(changed) + refute HashEnvelopeVerifier.signature_valid?(out, @pub, protected: protected) + end + + <> = :crypto.hash(:sha384, "a") + + refute HashEnvelopeVerifier.signature_valid?(out, @pub, + digest: <> + ) + + assert HashEnvelopeVerifier.signature_valid?(out, @pub) + end + + test "hedged: two carriers over one digest differ only in the signature" do + digest = :crypto.hash(:sha384, "a") + {:ok, one} = HashEnvelope.sign_digest(digest, opts()) + {:ok, two} = HashEnvelope.sign_digest(digest, opts()) + assert HashEnvelopeVerifier.protected(one) == HashEnvelopeVerifier.protected(two) + refute one == two + assert {:ok, _} = verify(one) + assert {:ok, _} = verify(two) + end + end + + describe "sign_stream/2" do + @describetag :mldsa87 + + test "streamed chunks give the digest of their concatenation, under either hash" do + chunks = for n <- [0, 1, 7, 1000, 65_536, 3], do: :crypto.strong_rand_bytes(n) + whole = IO.iodata_to_binary(chunks) + + for {hash, alg} <- [sha384: -43, sha512: -44] do + {:ok, streamed} = + HashEnvelope.sign_stream(Stream.map(chunks, & &1), opts(payload_hash: hash)) + + {:ok, direct} = + HashEnvelope.sign_digest(:crypto.hash(hash, whole), opts(payload_hash: hash)) + + digest = :crypto.hash(hash, whole) + assert {:ok, %{digest: ^digest, payload_hash: ^alg}} = verify(streamed) + assert HashEnvelopeVerifier.protected(streamed) == HashEnvelopeVerifier.protected(direct) + end + end + + test "a list is a stream too, and the empty stream is the empty preimage" do + {:ok, out} = HashEnvelope.sign_stream([], opts()) + empty = :crypto.hash(:sha384, "") + assert {:ok, %{digest: ^empty}} = verify(out) + + {:ok, out} = HashEnvelope.sign_stream(["ab", "c"], opts()) + abc = :crypto.hash(:sha384, "abc") + assert {:ok, %{digest: ^abc}} = verify(out) + end + + test "a file streamed in 1 MiB chunks gives the file's digest" do + path = Path.join(System.tmp_dir!(), "hash-envelope-#{System.unique_integer([:positive])}") + bytes = :crypto.strong_rand_bytes(3 * 1_048_576 + 17) + File.write!(path, bytes) + + try do + {:ok, out} = HashEnvelope.sign_stream(File.stream!(path, 1_048_576), opts()) + expected = :crypto.hash(:sha384, bytes) + assert {:ok, %{digest: ^expected}} = verify(out) + after + File.rm!(path) + end + end + + test "the private key reference is called once, after the last chunk" do + me = self() + + private = fn -> + send(me, :private) + @seed + end + + chunks = + Stream.map(["a", "b"], fn chunk -> + send(me, {:chunk, chunk}) + chunk + end) + + assert {:ok, _} = HashEnvelope.sign_stream(chunks, opts(private_key: private)) + assert mailbox() == [{:chunk, "a"}, {:chunk, "b"}, :private] + end + end + + describe "refusals: answered, never raised, and no key byte in any" do + test "typ: required, a lowercase media type without parameters ending +cose, never the connectome's" do + digest = :crypto.hash(:sha384, "a") + assert HashEnvelope.sign_digest(digest, Keyword.delete(opts(), :typ)) == {:error, :no_typ} + + for bad <- [ + "", + "application", + "Application/vnd.example.b80.test_hash.v0+cose", + "application/vnd.example.b80.test_hash.v0+cose; v=1", + "application/vnd.example.b80.test_hash.v0+cbor", + "application/+cose", + "application/" <> String.duplicate("a", 127) <> "+cose", + 1, + nil, + {:bytes, @typ} + ] do + assert HashEnvelope.sign_digest(digest, opts(typ: bad)) == {:error, {:typ, :invalid}}, + inspect(bad) + end + + assert HashEnvelope.sign_digest( + digest, + opts(typ: "application/vnd.example.b85.connectome.v0+cose") + ) == {:error, {:typ, :full_payload}} + end + + test "259: required, a media type under the profile's grammar or a uint below 65,536" do + digest = :crypto.hash(:sha384, "a") + + assert HashEnvelope.sign_digest(digest, Keyword.delete(opts(), :preimage_type)) == + {:error, :no_preimage_type} + + for bad <- [ + "", + "video", + "Video/mp4", + "video/mp4; codecs=avc1", + -1, + 65_536, + nil, + {:bytes, "x"} + ] do + assert HashEnvelope.sign_digest(digest, opts(preimage_type: bad)) == + {:error, {:preimage_type, :invalid}}, + inspect(bad) + end + end + + test "260: 1 to 2,048 printable ASCII bytes, when given" do + digest = :crypto.hash(:sha384, "a") + + for bad <- ["", "a b", "é", String.duplicate("a", 2049), 1, {:bytes, "x"}] do + assert HashEnvelope.sign_digest(digest, opts(location: bad)) == + {:error, {:location, :invalid}}, + inspect(bad) + end + end + + test "payload_hash: :sha384 or :sha512, and a digest of that hash's length" do + for bad <- [:sha256, :sha3_384, "sha384", -43] do + assert HashEnvelope.sign_digest(:crypto.hash(:sha384, "a"), opts(payload_hash: bad)) == + {:error, {:payload_hash, :invalid}} + end + + assert HashEnvelope.sign_digest(<<0::256>>, opts()) == {:error, {:digest, :wrong_length}} + + assert HashEnvelope.sign_digest(<<0::384>>, opts(payload_hash: :sha512)) == + {:error, {:digest, :wrong_length}} + + assert HashEnvelope.sign_digest(:not_bytes, opts()) == {:error, :bad_arguments} + end + + # Where ML-DSA-87 is not offered, :crypto_refused comes before the stream is read. + @tag :mldsa87 + test "a stream element that is not a binary, or a stream that raises" do + assert HashEnvelope.sign_stream(["a", ~c"b"], opts()) == {:error, {:stream, :not_binary}} + + raising = Stream.map([1], fn _ -> raise "the disk is gone" end) + assert HashEnvelope.sign_stream(raising, opts()) == {:error, {:stream, :unreadable}} + + assert HashEnvelope.sign_stream("not a stream", opts()) == {:error, :bad_arguments} + assert HashEnvelope.sign_stream([], %{}) == {:error, :bad_arguments} + end + + test "every option is judged before the first chunk is read" do + for {extra, reason} <- [ + {[typ: "x"], {:typ, :invalid}}, + {[preimage_type: "x"], {:preimage_type, :invalid}}, + {[location: ""], {:location, :invalid}}, + {[payload_hash: :md5], {:payload_hash, :invalid}}, + {[public_key: <<1>>], {:public_key, :not_2592_bytes}}, + {[public_key: fn -> raise "x" end], {:public_key, :unreadable}}, + {[private_key: <<1, 2>>], {:private_key, :not_32_bytes}} + ] do + assert HashEnvelope.sign_stream(counted(["a", "b"]), opts(extra)) == {:error, reason} + refute_received :chunk + end + end + + test "the key answers are MLDSA87's" do + digest = :crypto.hash(:sha384, "a") + + assert HashEnvelope.sign_digest(digest, Keyword.delete(opts(), :private_key)) == + {:error, :no_private_key} + + assert HashEnvelope.sign_digest(digest, Keyword.delete(opts(), :public_key)) == + {:error, :no_public_key} + + assert HashEnvelope.sign_digest(digest, [{:typ, @typ} | :improper]) == + {:error, :bad_arguments} + end + + # A private key function is called after the stream, so where ML-DSA-87 is not offered the + # early :crypto_refused comes first. + @tag :mldsa87 + test "a private key function that raises or returns the wrong size" do + digest = :crypto.hash(:sha384, "a") + + assert HashEnvelope.sign_digest(digest, opts(private_key: fn -> raise "x" end)) == + {:error, {:private_key, :unreadable}} + + assert HashEnvelope.sign_digest(digest, opts(private_key: fn -> <<1>> end)) == + {:error, {:private_key, :not_32_bytes}} + end + + @tag :mldsa87 + test "a public key that is not the seed's: no carrier" do + {other, _} = :crypto.generate_key(:mldsa87, []) + + assert HashEnvelope.sign_digest(:crypto.hash(:sha384, "a"), opts(public_key: other)) == + {:error, {:public_key, :does_not_verify}} + end + + @tag :no_mldsa87 + test "on a runtime without ML-DSA-87: :crypto_refused, before the first chunk" do + assert HashEnvelope.sign_stream(counted(["a"]), opts()) == {:error, :crypto_refused} + refute_received :chunk + end + end + + describe "the census" do + test "COSE still exports only sign/2; HashEnvelope is not a BeamMCP.Signer" do + assert COSE.__info__(:functions) == [sign: 2] + refute BeamMCP.Signer in (HashEnvelope.module_info(:attributes)[:behaviour] || []) + assert HashEnvelope.__info__(:functions) == [sign_digest: 2, sign_stream: 2] + end + end +end diff --git a/test/beam_mcp/signer/no_key_source_test.exs b/test/beam_mcp/signer/no_key_source_test.exs index f443af4..2220f43 100644 --- a/test/beam_mcp/signer/no_key_source_test.exs +++ b/test/beam_mcp/signer/no_key_source_test.exs @@ -8,14 +8,16 @@ defmodule BeamMCP.Signer.NoKeySourceTest do use ExUnit.Case, async: true alias BeamMCP.Signer.{COSE, Ed25519, MLDSA87} - alias BeamMCP.Signer.COSE.CBOR + alias BeamMCP.Signer.COSE.{CBOR, HashEnvelope, Keys} @lib Path.wildcard(Path.expand("../../../lib/**/*.ex", __DIR__)) @source %{ Ed25519 => "ed25519.ex", MLDSA87 => "mldsa87.ex", COSE => "cose.ex", - CBOR => "cbor.ex" + CBOR => "cbor.ex", + Keys => "keys.ex", + HashEnvelope => "hash_envelope.ex" } @key_source ~r/System\.(get_env|fetch_env!?|cmd)|:os\.(getenv|cmd)|Application\.(get_env|fetch_env!?|get_all_env)|File\.|Path\.expand|~\/|\.ssh|HEX_API_KEY|_KEY"|:persistent_term|:ets\.|Process\.(get|put)|Code\.eval|:init\.get_argument/ @@ -47,7 +49,7 @@ defmodule BeamMCP.Signer.NoKeySourceTest do # ML-DSA is not HashML-DSA but pure ML-DSA over the digest. @crypto_args %{Ed25519 => {:eddsa, :none}, MLDSA87 => {:mldsa87, :none}} - test "the package is the three signer modules and the encoder, and no line under lib/ names an environment, file, config or store read" do + test "the package is the three signer modules, the hash envelope, the encoder and the key helpers, and no line under lib/ names an environment, file, config or store read" do assert @lib |> Enum.map(&Path.basename/1) |> Enum.sort() == @source |> Map.values() |> Enum.sort() @@ -94,24 +96,76 @@ defmodule BeamMCP.Signer.NoKeySourceTest do end end - # The COSE module signs nothing itself: its one `:crypto` call is SHA-384 (the thumbprint and - # the digest of the canonical bytes), and the signature is MLDSA87's, checked there. Its - # encoder calls no `:crypto` and nothing outside the VM. Both lists are the beams' whole truth. - test "BeamMCP.Signer.COSE: SHA-384, the encoder and MLDSA87.sign/2, and no signing primitive of its own" do + # The COSE module signs nothing itself: its one `:crypto` call is SHA-384 (the digest of the + # canonical bytes), the kid is Keys' (SHA-384 too), and the signature is MLDSA87's, checked + # there. Its encoder calls no `:crypto` and nothing outside the VM. Both lists are the beams' + # whole truth. + test "BeamMCP.Signer.COSE: SHA-384, the encoder, the key helpers and MLDSA87.sign/2, and no signing primitive of its own" do assert remote_calls(COSE) == [ {CBOR, :encode, 1}, + {Keys, :fetch, 3}, + {Keys, :kid, 1}, + {Keys, :read, 2}, {MLDSA87, :sign, 2}, {:crypto, :hash, 2}, {:erlang, :byte_size, 1}, {:erlang, :get_module_info, 2}, {:erlang, :is_binary, 1}, - {:erlang, :is_function, 2}, {:erlang, :is_list, 1} ] - for args <- collect_crypto_args(forms(COSE)) do + for module <- [COSE, Keys], args <- collect_crypto_args(forms(module)) do assert [{:atom, _, :sha384} | _] = args end + + assert remote_calls(Keys) == [ + {:crypto, :hash, 2}, + {:erlang, :byte_size, 1}, + {:erlang, :get_module_info, 2}, + {:erlang, :is_binary, 1}, + {:erlang, :is_function, 2} + ] + end + + # The hash envelope signs nothing itself either: its `:crypto` calls hash the stream and ask + # whether ML-DSA-87 is offered, and the signature is MLDSA87's. Held to what it may reach rather + # than to an exact list, since the regex and map syntax compile to different helper calls + # across the Elixir lines CI runs. + test "BeamMCP.Signer.COSE.HashEnvelope: hashing, the encoder, the key helpers and MLDSA87.sign/2, no I/O" do + calls = remote_calls(HashEnvelope) + + assert for({:crypto, f, a} <- calls, do: {f, a}) == [ + hash_final: 1, + hash_init: 1, + hash_update: 2, + supports: 1 + ] + + assert {MLDSA87, :sign, 2} in calls + assert {CBOR, :encode, 1} in calls + + assert for({BeamMCP.Signer.COSE.Keys, f, a} <- calls, do: {f, a}) == [ + fetch: 3, + kid: 1, + read: 2 + ] + + refute Enum.any?(calls, fn {m, _, _} -> m in [Ed25519, COSE] end) + + refute Enum.any?(calls, fn {m, _, _} -> + m in [ + System, + File, + Application, + Path, + Process, + Code, + :os, + :file, + :ets, + :persistent_term + ] + end) end test "BeamMCP.Signer.COSE.CBOR: pure encoding, no :crypto, no I/O" do diff --git a/test/support/hash_envelope_verifier.exs b/test/support/hash_envelope_verifier.exs new file mode 100644 index 0000000..b336b7c --- /dev/null +++ b/test/support/hash_envelope_verifier.exs @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.Test.HashEnvelopeVerifier do + @moduledoc false + # A verifier for the N6 hash-envelope entry point (v0.1 §5.4, §5.5, §5.6), for the tests only. + # It is handed the carrier and the public key and nothing else: never the preimage. It reads + # with the strict decoder, not the encoder under test, builds ToBeSigned itself with CBOR heads + # written here, and answers the profile's own codes for what it refuses. A verified envelope + # is non-claimed (§5.5), so the answer names no claim. + + import Bitwise + + alias BeamMCP.Signer.Test.StrictCBOR + + @single_schema 0xFFFF0003 + @thumbprint_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> + @required [1, 2, 4, 16, 258, 259] + @allowed [1, 2, 4, 16, 258, 259, 260] + @lengths %{-43 => 48, -44 => 64} + @media ~r/\A[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\z/ + + @doc "The carrier judged: `{:ok, %{digest:, payload_hash:, typ:}}`, or `{:error, code}`." + def verify(carrier, public_key) do + with {:ok, c} <- carrier(carrier), + {:ok, header} <- header(c.protected), + :ok <- payload(c.digest, header[258]), + :ok <- kid(header[4], public_key), + true <- + valid?(c.protected, c.digest, c.signature, public_key) || {:error, :signature_invalid} do + {:ok, %{digest: c.digest, payload_hash: header[258], typ: header[16]}} + end + end + + @doc "The carrier's protected header bytes, as received." + def protected(carrier) do + {:ok, c} = carrier(carrier) + c.protected + end + + @doc "Whether the signature verifies, with the protected bytes or the digest optionally replaced." + def signature_valid?(carrier, public_key, replace \\ []) do + {:ok, c} = carrier(carrier) + + valid?( + Keyword.get(replace, :protected, c.protected), + Keyword.get(replace, :digest, c.digest), + c.signature, + public_key + ) + end + + defp carrier(bytes) do + case StrictCBOR.decode(bytes) do + {:ok, + %{ + 0 => @single_schema, + 1 => {:bytes, digest}, + 2 => [{:bytes, protected}, unprotected, nil, {:bytes, signature}] + } = c} + when map_size(c) == 3 and unprotected == %{} -> + {:ok, %{digest: digest, protected: protected, signature: signature}} + + _ -> + {:error, :carrier_malformed} + end + end + + # H1 to H4, in the profile's order: decode, a map, typ first, then the label scan ascending. + defp header(protected) do + case StrictCBOR.decode(protected) do + {:ok, %{16 => typ} = h} when is_binary(typ) -> + scan(h, Enum.sort(Enum.uniq(Map.keys(h) ++ @required))) + + {:ok, %{16 => _}} -> + {:error, :typ_unexpected} + + {:ok, %{}} -> + {:error, :header_missing} + + _ -> + {:error, :header_noncanonical} + end + end + + defp scan(h, []), do: {:ok, h} + defp scan(h, [16 | rest]), do: scan(h, rest) + + defp scan(h, [label | rest]) do + case label(label, Map.fetch(h, label)) do + :ok -> scan(h, rest) + error -> error + end + end + + defp label(label, :error) when label in @required, do: {:error, :header_missing} + defp label(3, _), do: {:error, :content_type_forbidden} + defp label(label, _) when label not in @allowed, do: {:error, :header_label_forbidden} + defp label(1, {:ok, -50}), do: :ok + defp label(1, {:ok, alg}) when is_integer(alg), do: {:error, :alg_not_permitted} + defp label(2, {:ok, [258]}), do: :ok + defp label(4, {:ok, {:bytes, kid}}) when byte_size(kid) == 48, do: :ok + defp label(258, {:ok, alg}) when alg in [-43, -44], do: :ok + defp label(258, {:ok, alg}) when is_integer(alg), do: {:error, :digest_alg_not_permitted} + defp label(259, {:ok, n}) when is_integer(n) and n >= 0 and n <= 0xFFFF, do: :ok + defp label(259, {:ok, t}) when is_binary(t), do: if(t =~ @media, do: :ok, else: invalid()) + + defp label(260, {:ok, t}) when is_binary(t), + do: if(t =~ ~r/\A[\x21-\x7e]{1,2048}\z/, do: :ok, else: invalid()) + + defp label(_label, _value), do: invalid() + + defp invalid, do: {:error, :header_value_invalid} + + defp payload(digest, alg) do + if byte_size(digest) == @lengths[alg], do: :ok, else: {:error, :digest_length_mismatch} + end + + defp kid({:bytes, kid}, public_key) do + if kid == :crypto.hash(:sha384, @thumbprint_prefix <> public_key), + do: :ok, + else: {:error, :kid_not_thumbprint} + end + + defp valid?(protected, digest, signature, public_key) do + tbs = <<0x84, 0x6A, "Signature1">> <> bstr(protected) <> <<0x40>> <> bstr(digest) + :crypto.verify(:mldsa87, :none, tbs, signature, public_key) + end + + defp bstr(b), do: head(2, byte_size(b)) <> b + + defp head(major, n) when n < 24, do: <> + defp head(major, n) when n < 0x100, do: <> + defp head(major, n) when n < 0x10000, do: <> + defp head(major, n) when n <= 0xFFFFFFFF, do: <> + defp head(major, n) when n < 1 <<< 64, do: <> +end diff --git a/test/test_helper.exs b/test/test_helper.exs index f6d363d..e87054d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -11,6 +11,8 @@ if System.get_env("MLDSA_REQUIRED") == "1" and not mldsa87? do raise "MLDSA_REQUIRED=1 and this runtime's :crypto does not offer mldsa87: refused" end -ExUnit.start(exclude: if(mldsa87?, do: [:no_mldsa87], else: [:mldsa87])) +# The 10 GiB test (cose_hash_envelope_ten_gb_test.exs) runs only when asked for: `--only ten_gb`. +ExUnit.start(exclude: [:ten_gb | if(mldsa87?, do: [:no_mldsa87], else: [:mldsa87])]) Code.require_file("support/strict_cbor.exs", __DIR__) +Code.require_file("support/hash_envelope_verifier.exs", __DIR__) diff --git a/tools/cross_verify/go/hashenvelope.go b/tools/cross_verify/go/hashenvelope.go new file mode 100644 index 0000000..0e53747 --- /dev/null +++ b/tools/cross_verify/go/hashenvelope.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +// Verifies the RFC 9995 hash envelope BeamMCP.Signer.COSE.HashEnvelope made over the same +// message (hash-envelope.cbor), holding only the envelope and the public key until the last +// step: +// - the carrier and the protected header are each core-deterministic CBOR; +// - the carrier is {0: 0xFFFF0003, 1: digest, 2: [protected, {}, null, signature]}; +// - the protected header is {1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: media type}, +// with no label 3, and the digest is 48 bytes; +// - the signature verifies over ToBeSigned ["Signature1", protected, h'', digest], built here, +// and not once 258 or 259 is changed; +// - then, the preimage in hand, the digest is SHA-384 of message.bin. + +import ( + "bytes" + "crypto/mldsa" + "crypto/sha512" + "fmt" +) + +const hashTyp = "application/vnd.example.b80.test_hash.v0+cose" + +func verifyHashEnvelope(pk *mldsa.PublicKey, publicKey, message, envelope []byte) { + c := asMap("hash envelope", canonical("hash envelope", envelope), 0, 1, 2) + if c[0] != int64(singleSchema) { + fail("hash envelope key 0 is %v, not 0xFFFF0003", c[0]) + } + digest, ok := c[1].([]byte) + sign1, ok2 := c[2].([]any) + if !ok || !ok2 || len(sign1) != 4 { + fail("hash envelope: key 1 is not a byte string, or key 2 not a four-element array") + } + protected, ok := sign1[0].([]byte) + unprotected, ok2 := sign1[1].(map[any]any) + signature, ok3 := sign1[3].([]byte) + if !ok || !ok2 || len(unprotected) != 0 || sign1[2] != nil || !ok3 { + fail("hash envelope Sign1: not [protected bstr, {}, null, signature bstr]") + } + fmt.Println("ok the hash envelope is {0: 0xFFFF0003, 1: digest, 2: [protected, {}, null, signature]}, core-deterministic") + + h := asMap("hash envelope header", canonical("hash envelope header", protected), 1, 2, 4, 16, 258, 259) + kid := sha512.Sum384(append(bytes.Clone(thumbprintHead), publicKey...)) + crit, ok := h[2].([]any) + if h[1] != int64(-50) || !ok || len(crit) != 1 || crit[0] != int64(258) || + !bytes.Equal(h[4].([]byte), kid[:]) || h[16] != hashTyp || h[258] != int64(-43) || + h[259] != "application/json" { + fail("hash envelope header: not {1: -50, 2: [258], 4: kid, 16: %q, 258: -43, 259: \"application/json\"}", hashTyp) + } + if len(digest) != 48 { + fail("hash envelope: the payload is %d bytes, not SHA-384's 48", len(digest)) + } + fmt.Println("ok protected {1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: type}, no label 3; a 48-byte digest") + + if err := mldsa.Verify(pk, toBeSigned(protected, digest), signature, nil); err != nil { + fail("the hash envelope's signature does not verify over ToBeSigned: %v", err) + } + fmt.Println("ok the signature verifies over ToBeSigned built here, holding only the digest") + + for _, changed := range []map[int64]any{ + {1: h[1], 2: h[2], 4: h[4], 16: h[16], 258: int64(-44), 259: h[259]}, + {1: h[1], 2: h[2], 4: h[4], 16: h[16], 258: h[258], 259: "application/octet-stream"}, + } { + altered, _ := deterministic.Marshal(changed) + if mldsa.Verify(pk, toBeSigned(altered, digest), signature, nil) == nil { + fail("the hash envelope's signature also verifies with 258 or 259 changed") + } + } + fmt.Println("ok it does not verify with the hash algorithm or the preimage type changed") + + sum := sha512.Sum384(message) + if !bytes.Equal(digest, sum[:]) { + fail("the hash envelope's digest is not SHA-384 of the message") + } + fmt.Println("ok with the preimage in hand: the digest is SHA-384 of the message") +} diff --git a/tools/cross_verify/go/main.go b/tools/cross_verify/go/main.go index 792a665..f9563f9 100644 --- a/tools/cross_verify/go/main.go +++ b/tools/cross_verify/go/main.go @@ -4,7 +4,8 @@ // Verifies an ML-DSA-87 signature made by BeamMCP.Signer.MLDSA87 (tools/mldsa_sample.exs) with // Go's crypto/mldsa, an implementation independent of OpenSSL's: the signature verifies over // the message with an empty context, does not verify over the message with one bit flipped, -// and the public key is the one the seed derives (which OTP's :crypto cannot compute). +// and the public key is the one the seed derives (which OTP's :crypto cannot compute); then the +// N6 carrier (carrier.go) and the hash envelope (hashenvelope.go) made over the same message. // // go run . package main @@ -64,4 +65,5 @@ func main() { fmt.Println("ok the public key is the one the seed derives") verifyCarrier(pk, encoded, message, read(dir, "carrier.cbor")) + verifyHashEnvelope(pk, encoded, message, read(dir, "hash-envelope.cbor")) } diff --git a/tools/cross_verify/rust/src/hash_envelope.rs b/tools/cross_verify/rust/src/hash_envelope.rs new file mode 100644 index 0000000..7a91852 --- /dev/null +++ b/tools/cross_verify/rust/src/hash_envelope.rs @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +// SPDX-License-Identifier: Apache-2.0 + +//! Verifies the RFC 9995 hash envelope BeamMCP.Signer.COSE.HashEnvelope made over the same +//! message (hash-envelope.cbor), holding only the envelope and the public key until the last +//! step. +//! +//! coset 0.4.2 knows no label 258 (its IANA table predates RFC 9995), so it refuses a Sign1 whose +//! `crit` names it: the fail-closed refusal `crit` is there to cause in a verifier that does not +//! know hash envelopes, checked first. The rest is read with ciborium, and coset still builds +//! ToBeSigned (`sig_structure_data`) over the protected bytes as received and the digest, for +//! aws-lc-rs to verify. The signature does not verify once 258 or 259 is changed; then, the +//! preimage in hand, the digest is SHA-384 of message.bin. + +use aws_lc_rs::digest::{digest, SHA384}; +use aws_lc_rs::signature::{UnparsedPublicKey, ML_DSA_87}; +use ciborium::value::{Integer, Value}; +use coset::{ + sig_structure_data, CborSerializable, CoseError, CoseSign1, ProtectedHeader, SignatureContext, +}; + +use crate::fail; + +const SINGLE_SCHEMA: u64 = 0xFFFF0003; +const TYP: &str = "application/vnd.example.b80.test_hash.v0+cose"; +const THUMBPRINT_HEAD: [u8; 10] = [0xa3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0a, 0x20]; + +fn decode(name: &str, bytes: &[u8]) -> Value { + ciborium::de::from_reader(bytes).unwrap_or_else(|e| fail(&format!("{name}: {e}"))) +} + +fn get(name: &str, v: &Value, key: i64) -> Value { + v.as_map() + .unwrap_or_else(|| fail(&format!("{name}: not a map"))) + .iter() + .find(|(k, _)| k.as_integer() == Some(Integer::from(key))) + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| fail(&format!("{name}: no key {key}"))) +} + +fn to_be_signed(protected: &[u8], payload: &[u8]) -> Vec { + let header = ProtectedHeader { + original_data: Some(protected.to_vec()), + ..Default::default() + }; + sig_structure_data(SignatureContext::CoseSign1, header, None, &[], payload) +} + +pub fn verify(public_key: &[u8], message: &[u8], envelope: &[u8]) { + let c = decode("hash envelope", envelope); + let len = c.as_map().map(|m| m.len()).unwrap_or(0); + if len != 3 || get("hash envelope", &c, 0).as_integer() != Some(Integer::from(SINGLE_SCHEMA)) { + fail("hash envelope: not {0: 0xFFFF0003, 1: digest, 2: Sign1}"); + } + let payload = get("hash envelope", &c, 1) + .into_bytes() + .unwrap_or_else(|_| fail("hash envelope: key 1 is not a byte string")); + let sign1 = get("hash envelope", &c, 2); + let parts = sign1 + .as_array() + .filter(|a| a.len() == 4) + .unwrap_or_else(|| fail("hash envelope: key 2 is not a four-element array")); + let protected = parts[0] + .as_bytes() + .cloned() + .unwrap_or_else(|| fail("hash envelope Sign1: the protected header is not a byte string")); + let signature = parts[3] + .as_bytes() + .cloned() + .unwrap_or_else(|| fail("hash envelope Sign1: the signature is not a byte string")); + if !parts[1].as_map().map(|m| m.is_empty()).unwrap_or(false) || !parts[2].is_null() { + fail("hash envelope Sign1: the unprotected header is not empty, or the payload not null"); + } + println!("ok the hash envelope is {{0: 0xFFFF0003, 1: digest, 2: [protected, {{}}, null, signature]}}"); + + let mut sign1_bytes = Vec::new(); + ciborium::ser::into_writer(&sign1, &mut sign1_bytes) + .unwrap_or_else(|e| fail(&format!("hash envelope Sign1: {e}"))); + match CoseSign1::from_slice(&sign1_bytes) { + Err(CoseError::UnregisteredIanaNonPrivateValue) => { + println!("ok coset, which knows no label 258, refuses the Sign1 over crit [258]: fail closed") + } + other => fail(&format!( + "coset did not refuse the Sign1 over crit [258]: {:?}", + other.map(|_| "parsed") + )), + } + + let h = decode("hash envelope header", &protected); + let labels: Vec = h + .as_map() + .unwrap_or_else(|| fail("hash envelope header: not a map")) + .iter() + .map(|(k, _)| { + k.as_integer() + .unwrap_or_else(|| fail("hash envelope header: a label is not an integer")) + }) + .collect(); + let expected: Vec = [1, 2, 4, 16, 258, 259].map(Integer::from).to_vec(); + let kid = digest(&SHA384, &[&THUMBPRINT_HEAD[..], public_key].concat()); + if labels != expected + || get("header", &h, 1) != Value::Integer((-50).into()) + || get("header", &h, 2) != Value::Array(vec![Value::Integer(258.into())]) + || get("header", &h, 4).as_bytes().map(|b| b.as_slice()) != Some(kid.as_ref()) + || get("header", &h, 16) != Value::Text(TYP.into()) + || get("header", &h, 258) != Value::Integer((-43).into()) + || get("header", &h, 259) != Value::Text("application/json".into()) + || payload.len() != 48 + { + fail("hash envelope header: not {1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: type} in that order, or the digest is not 48 bytes"); + } + println!("ok protected {{1: -50, 2: [258], 4: kid, 16: typ, 258: -43, 259: type}} in canonical order, no label 3; a 48-byte digest"); + + let key = UnparsedPublicKey::new(&ML_DSA_87, public_key); + if key + .verify(&to_be_signed(&protected, &payload), &signature) + .is_err() + { + fail("the hash envelope's signature does not verify over coset's ToBeSigned"); + } + println!("ok the signature verifies over ToBeSigned built by coset, holding only the digest"); + + for (label, value) in [ + (258, Value::Integer((-44).into())), + (259, Value::Text("application/octet-stream".into())), + ] { + let mut altered = h.clone(); + if let Value::Map(entries) = &mut altered { + for entry in entries.iter_mut() { + if entry.0.as_integer() == Some(Integer::from(label)) { + entry.1 = value.clone(); + } + } + } + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&altered, &mut bytes).unwrap_or_else(|e| fail(&format!("{e}"))); + if key + .verify(&to_be_signed(&bytes, &payload), &signature) + .is_ok() + { + fail("the hash envelope's signature also verifies with 258 or 259 changed"); + } + } + println!("ok it does not verify with the hash algorithm or the preimage type changed"); + + if payload != digest(&SHA384, message).as_ref() { + fail("the hash envelope's digest is not SHA-384 of the message"); + } + println!("ok with the preimage in hand: the digest is SHA-384 of the message"); +} diff --git a/tools/cross_verify/rust/src/main.rs b/tools/cross_verify/rust/src/main.rs index d7d9d77..824cafa 100644 --- a/tools/cross_verify/rust/src/main.rs +++ b/tools/cross_verify/rust/src/main.rs @@ -3,12 +3,14 @@ //! Verifies an ML-DSA-87 signature made by BeamMCP.Signer.MLDSA87 (tools/mldsa_sample.exs) with //! aws-lc-rs (AWS-LC's ML-DSA, not OpenSSL's): the signature verifies over the message, and does -//! not verify over the message with one bit flipped; and the N6 carrier BeamMCP.Signer.COSE made -//! over the same message (carrier.rs). +//! not verify over the message with one bit flipped; the N6 carrier BeamMCP.Signer.COSE made over +//! the same message (carrier.rs); and the hash envelope BeamMCP.Signer.COSE.HashEnvelope made over +//! it (hash_envelope.rs). //! //! cargo run --locked --release -- mod carrier; +mod hash_envelope; use aws_lc_rs::signature::{UnparsedPublicKey, ML_DSA_87}; use std::{env, fs, path::Path, process}; @@ -46,4 +48,5 @@ fn main() { println!("ok it does not verify over the message with one bit flipped"); carrier::verify(&encoded, &message, &read(dir, "carrier.cbor")); + hash_envelope::verify(&encoded, &message, &read(dir, "hash-envelope.cbor")); } diff --git a/tools/mldsa_sample.exs b/tools/mldsa_sample.exs index 650a538..243e9e4 100644 --- a/tools/mldsa_sample.exs +++ b/tools/mldsa_sample.exs @@ -1,15 +1,17 @@ # SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 # -# Writes one ML-DSA-87 signature made by BeamMCP.Signer.MLDSA87, and one N6 carrier made by -# BeamMCP.Signer.COSE, for other implementations to verify (tools/cross_verify/): the canonical -# bytes of a fixed graph, the signature over them, the carrier over them, and the key pair both -# were made with, ACVP's published keyGen case (tgId 3, tcId 51), so the +# Writes one ML-DSA-87 signature made by BeamMCP.Signer.MLDSA87, one N6 carrier made by +# BeamMCP.Signer.COSE and one hash envelope made by BeamMCP.Signer.COSE.HashEnvelope, for other +# implementations to verify (tools/cross_verify/): the canonical bytes of a fixed graph, the +# signature, the carrier and the hash envelope over them, and the key pair all three were made with, ACVP's published keyGen case (tgId 3, tcId 51), so the # seed is a test vector and no secret. Run on CI's box leg: # # MIX_ENV=test mix run tools/mldsa_sample.exs # -# Writes /message.bin, signature.bin, carrier.cbor, public_key.bin, seed.bin. +# Writes /message.bin, signature.bin, carrier.cbor, hash-envelope.cbor, public_key.bin, +# seed.bin. The hash envelope is streamed from the message in 7-byte chunks, so the verifiers' +# digest check also checks the stream. alias BeamMCP.Connectome.{Canonical, Edge, Graph, Node} [dir] = System.argv() @@ -41,10 +43,24 @@ message = Canonical.encode!(graph, algorithm: :sha384) issuer: "beam_mcp_signer.ci" ) +{:ok, hash_envelope} = + BeamMCP.Signer.COSE.HashEnvelope.sign_stream( + Stream.unfold(message, fn + <<>> -> nil + <> -> {chunk, rest} + tail -> {tail, <<>>} + end), + private_key: fn -> seed end, + public_key: fn -> public_key end, + typ: "application/vnd.example.b80.test_hash.v0+cose", + preimage_type: "application/json" + ) + for {name, bytes} <- [ {"message.bin", message}, {"signature.bin", signature}, {"carrier.cbor", carrier}, + {"hash-envelope.cbor", hash_envelope}, {"public_key.bin", public_key}, {"seed.bin", seed} ] do