From 44f4d14eadd3c9699059caf142734cc6cf455610 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 15:15:21 -0400 Subject: [PATCH] BeamMCP.Signer.COSE: ML-DSA-87 in COSE, the algorithm and the key inside the signed bytes Canonical.signature/3 places scheme: and key_id: beside a raw signature, asserted by the host and signed by nobody: swap either and the signature still verifies. This module returns a COSE signature whose protected header names the algorithm and the key, so a verifier that changes or misreads either finds it invalid. - lib/beam_mcp/signer/cose.ex: a third BeamMCP.Signer, sign/2 only. It returns deterministic CBOR, the N6 single-signer carrier {0: 0xFFFF0003, 1: body, 2: [protected, {}, null, signature]}: an untagged, detached COSE_Sign1 (RFC 9052, RFC 9964 algorithm -50). The protected header is {1: -50, 4: kid, 16: typ}, kid the SHA-384 COSE Key Thumbprint (RFC 9679) computed from the public key. The body names the canonical bytes by SHA-384 and length, with the host's issuer. MLDSA87 signs and checks the ToBeSigned; this module's one :crypto call is SHA-384. Schema 0x85000101 and the typ are provisional. - lib/beam_mcp/signer/cose/cbor.ex (private): deterministic CBOR under N6's rules; floats, tags, other simple values, non-integer keys and non-NFC text are refused by name. - Tests: RFC 8949 Appendix A rows and every head boundary, byte for byte; RFC 9964 Appendix A.2's protected header and ToBeSigned reproduced exactly, its signature verified, and SHA-256 over the thumbprint input equal to its kid; a strict reader that judges canonical form without the encoder; the carrier, header and body read back; alg, kid, typ or body changed and the signature refused; the census extended to both modules. tools/vectors_cose.sh writes the A.2 fixture from the RFC text, pinned by sha256 (BSD-3-Clause, IETF TLP section 4.e). - CI: the box leg's sample adds a carrier; the cross-verify job reads it with Go (fxamacker/cbor v2.9.6, crypto/mldsa) and Rust (coset 0.4.2, ciborium 0.2.2, aws-lc-rs), each building ToBeSigned itself and refusing it with alg, kid or typ changed. The job keeps its name. - Docs: README (a COSE section with how to verify), SECURITY.md, the assurance case, architecture, roadmap, the review checklist, GOVERNANCE.md, CHANGELOG. - mldsa87_test.exs: send(...) && key became two expressions, which Elixir 1.20's type checker no longer warns about. Measured locally: the new tests fail before the modules exist (16 failures, each an undefined module) and pass after; the suite passes on OTP 28.5.0.8 over OpenSSL 3.5.9 (Elixir 1.20.4, three runs) and over 3.0.13 (Elixir 1.18.4, the ML-DSA tests excluded, the refusal run). Eleven mutants of the two modules each fail the suite; the first run's one survivor (map keys left unsorted, invisible below 33 keys because small Erlang maps enumerate in order) is killed by a 40-key test. Both cross-verifiers pass the carrier and refuse a copy with one signature byte flipped. Signed-off-by: Ayla Croft --- .github/workflows/ci.yml | 9 +- CHANGELOG.md | 25 ++ GOVERNANCE.md | 5 +- LICENSES/BSD-3-Clause.txt | 11 + README.md | 64 ++++- SECURITY.md | 10 +- docs/architecture.md | 17 +- docs/assurance-case.md | 10 +- docs/roadmap.md | 6 + docs/security-review.md | 10 +- lib/beam_mcp/signer/cose.ex | 151 ++++++++++ lib/beam_mcp/signer/cose/cbor.ex | 82 ++++++ mix.exs | 4 +- test/beam_mcp/signer/cose_cbor_test.exs | 148 ++++++++++ test/beam_mcp/signer/cose_test.exs | 262 ++++++++++++++++++ test/beam_mcp/signer/mldsa87_test.exs | 12 +- test/beam_mcp/signer/no_key_source_test.exs | 55 +++- test/fixtures/cose/rfc9964_a2_ml_dsa_87.json | 1 + .../cose/rfc9964_a2_ml_dsa_87.json.license | 7 + test/support/strict_cbor.exs | 97 +++++++ test/test_helper.exs | 2 + tools/cross_verify/go/carrier.go | 154 ++++++++++ tools/cross_verify/go/go.mod | 4 + tools/cross_verify/go/go.sum | 4 + tools/cross_verify/go/go.sum.license | 2 + tools/cross_verify/go/main.go | 2 + tools/cross_verify/rust/Cargo.lock | 152 ++++++++++ tools/cross_verify/rust/Cargo.toml | 2 + tools/cross_verify/rust/src/carrier.rs | 137 +++++++++ tools/cross_verify/rust/src/main.rs | 12 +- tools/mldsa_sample.exs | 17 +- tools/vectors_cose.sh | 35 +++ 32 files changed, 1476 insertions(+), 33 deletions(-) create mode 100644 LICENSES/BSD-3-Clause.txt create mode 100644 lib/beam_mcp/signer/cose.ex create mode 100644 lib/beam_mcp/signer/cose/cbor.ex create mode 100644 test/beam_mcp/signer/cose_cbor_test.exs create mode 100644 test/beam_mcp/signer/cose_test.exs create mode 100644 test/fixtures/cose/rfc9964_a2_ml_dsa_87.json create mode 100644 test/fixtures/cose/rfc9964_a2_ml_dsa_87.json.license create mode 100644 test/support/strict_cbor.exs create mode 100644 tools/cross_verify/go/carrier.go create mode 100644 tools/cross_verify/go/go.sum create mode 100644 tools/cross_verify/go/go.sum.license create mode 100644 tools/cross_verify/rust/src/carrier.rs create mode 100755 tools/vectors_cose.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a54a5b..5bd1438 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,8 +63,9 @@ jobs: - run: mix credo --strict - run: mix hex.audit - run: mix test - # The box leg's ML-DSA-87 signature, for the cross-verify job to check with two other - # implementations (tools/mldsa_sample.exs; the key pair is a published ACVP test vector). + # The box leg's ML-DSA-87 signature and N6 carrier, for the cross-verify job to check with + # implementations outside Elixir (tools/mldsa_sample.exs; the key pair is a published ACVP + # test vector). - name: an ML-DSA-87 signature for other implementations to verify if: matrix.leg == 'box' run: mix run tools/mldsa_sample.exs mldsa87-sample @@ -100,10 +101,10 @@ jobs: with: go-version: "1.27.3" cache: false - - name: Go crypto/mldsa + - name: Go crypto/mldsa, and the N6 carrier read with fxamacker/cbor working-directory: tools/cross_verify/go run: go run . "$GITHUB_WORKSPACE/mldsa87-sample" - - name: aws-lc-rs (the toolchain and every crate pinned) + - name: aws-lc-rs, and the N6 carrier read with coset and ciborium (the toolchain and every crate pinned) working-directory: tools/cross_verify/rust run: | rustup toolchain install 1.92.0 --profile minimal diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3e291..f758224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ SPDX-License-Identifier: Apache-2.0 ## [Unreleased] +### Added: `BeamMCP.Signer.COSE`, ML-DSA-87 in COSE with the algorithm and key signed + +- A third module implementing core's `BeamMCP.Signer` behaviour, unchanged. It returns + deterministic CBOR, the N6 single-signer carrier: an untagged, detached COSE_Sign1 (RFC 9052, + RFC 9964 algorithm -50) whose protected header is `{1: -50, 4: kid, 16: typ}`, over a + statement that names the canonical bytes by SHA-384 and length, with the host's issuer. The + `kid` is the key's SHA-384 COSE Key Thumbprint (RFC 9679), computed from the public key. +- **Why:** `Canonical.signature/3` places `scheme:` and `key_id:` beside a raw signature, + unsigned; swapping either leaves the signature valid. Here both are inside the signed bytes. +- **Options:** `BeamMCP.Signer.MLDSA87`'s two keys, read the same way, and `:issuer` + (required; NFC text or `{:bytes, binary}`). New errors: `:no_issuer`, `{:issuer, :invalid}`; + every other answer is `MLDSA87`'s, which makes and checks the signature. +- **Provisional:** the statement's schema number (0x85000101) and `typ` + (`application/vnd.example.b85.connectome.v0+cose`) are provisional, as is the N6 profile + (v0); they change when the shared registry assigns them. +- **Encoder:** the package's own (private), deterministic CBOR under N6's rules, refusing + floats, tags, other simple values, non-integer keys and non-NFC text by name. Held to RFC 8949 + Appendix A and RFC 9964 Appendix A.2 byte for byte (`tools/vectors_cose.sh` writes the A.2 + fixture from the RFC); a strict reader in the tests checks every encoding without it. +- **Other implementations:** CI's cross-verify job also reads the box leg's carrier with Go + (fxamacker/cbor, `crypto/mldsa`) and Rust (coset, ciborium, aws-lc-rs), each building + ToBeSigned itself; each refuses the signature with the algorithm, key or `typ` changed. +- `mldsa87_test.exs`: a key-reference test's `send(...) && key` became two expressions, which + Elixir 1.20's type checker no longer warns about. + ### Added: `BeamMCP.Signer.MLDSA87`, an ML-DSA-87 signer - A second module implementing core's `BeamMCP.Signer` behaviour, unchanged: ML-DSA-87 diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 913c44e..d3ccda7 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -29,8 +29,9 @@ date, so the bus factor is two, and his reviews are the public record of it. 1. A branch and a pull request; nothing is pushed to `main` directly. 2. CI on four OTP/Elixir legs (the floor, the pinned line, the newest, and the box: OTP 28.5.0.8 with Elixir 1.20.4 on aarch64, over an OpenSSL that offers ML-DSA-87), the box - leg's ML-DSA-87 signature verified by two other implementations (Go's `crypto/mldsa` and - aws-lc-rs), the REUSE lint and the DCO check. + leg's ML-DSA-87 signature and COSE carrier verified by two other implementations (Go's + `crypto/mldsa` with fxamacker/cbor, and aws-lc-rs with coset), the REUSE lint and the DCO + check. 3. The maintainer's review, then a rebase merge. From 2026-09-26 a repository ruleset on `main` holds steps 1 to 3 rather than leaving them to diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt new file mode 100644 index 0000000..ea890af --- /dev/null +++ b/LICENSES/BSD-3-Clause.txt @@ -0,0 +1,11 @@ +Copyright (c) . + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 670028f..e0dd200 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,16 @@ SPDX-License-Identifier: Apache-2.0 [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14775/badge)](https://www.bestpractices.dev/projects/14775) Signers for [`beam_mcp`](https://github.com/ScriptKittyOS/beam_mcp)'s canonical connectome -bytes, through OTP's `:crypto`. Two modules, each implementing core's `BeamMCP.Signer` +bytes, through OTP's `:crypto`. Three modules, each implementing core's `BeamMCP.Signer` behaviour (`sign/2`) and nothing else: - **`BeamMCP.Signer.Ed25519`**: Ed25519 (RFC 8032), a 64-byte signature. - **`BeamMCP.Signer.MLDSA87`**: ML-DSA-87 (FIPS 204), the post-quantum signature CNSA 2.0 names, a 4,627-byte signature. It needs an OTP whose `:crypto` is linked to OpenSSL 3.5 or later; see [ML-DSA-87](#ml-dsa-87) below. +- **`BeamMCP.Signer.COSE`**: the same ML-DSA-87 signature inside COSE (RFC 9964, algorithm + -50), with the algorithm and the key's thumbprint in the signed bytes; see [COSE](#cose) + below. **What they sign.** Exactly the bytes `BeamMCP.Connectome.Canonical.encode/2` produces, as `Canonical.signature/3` hands them over; the signature comes back beside them. Nothing else: @@ -89,10 +92,63 @@ true = :crypto.verify(:mldsa87, :none, BeamMCP.Connectome.Canonical.encode!(grap over the message with one bit flipped, and Go also confirms the public key is the one the seed derives (`tools/cross_verify/`). -## Either module +## COSE -This package reads the key options and nothing else (`opts[:private_key]`, and for ML-DSA-87 -`opts[:public_key]`) -- no environment variable, no file, no application config, no default. +`BeamMCP.Signer.COSE` fixes what a raw signature cannot: `Canonical.signature/3` places +`scheme:` and `key_id:` beside the signature, asserted by the host and verified by nobody, so +swapping either leaves a raw signature valid. Here the algorithm and the key's thumbprint sit +in the COSE protected header, inside the signed bytes. + +```elixir +{:ok, %{signature: carrier}} = + BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.COSE, + private_key: fn -> seed end, public_key: fn -> public_key end, issuer: "box-17") +``` + +The keys are `BeamMCP.Signer.MLDSA87`'s, read the same way, and that module makes and checks +the signature. `:issuer` is required: UTF-8 text in NFC, or `{:bytes, binary}`. + +What comes back is one binary, deterministic CBOR, in the form Script Kitty's systems share for +everything they sign (the N6 profile, v0; provisional until its v1): + +``` +{0: 0xFFFF0003, ; the single-signer carrier + 1: h'', + 2: [h'', {}, null, h'']} ; COSE_Sign1, untagged, detached + +protected = {1: -50, ; alg: ML-DSA-87 + 4: h'<48 bytes>', ; kid: SHA-384 COSE Key Thumbprint + 16: "application/vnd.example.b85.connectome.v0+cose"} ; typ +body = {0: 0x85000101, ; the statement's schema + 1: "", + 2: {0: [-43, h''], 1: , + 2: "application/json"}} +``` + +**To verify**, with the canonical bytes beside the carrier and the public key from your own key +registry: + +1. Decode the carrier, the protected header and the body, refusing anything that is not + deterministic CBOR. +2. Require the protected header to be exactly `{1: -50, 4: kid, 16: typ}`, with `kid` the + SHA-384 of `a3 01 07 03 38 31 20 59 0a 20` followed by the 2,592-byte public key (RFC 9679 + over the AKP key). +3. Verify the signature, pure ML-DSA-87 with an empty context, over + `["Signature1", protected, h'', body]` (RFC 9052 section 4.4), the protected bytes as + received. +4. Require the body's digest and length to be the canonical bytes'. + +`tools/cross_verify/` does exactly this in Go (`fxamacker/cbor` and `crypto/mldsa`) and in Rust +(`coset`, `ciborium` and aws-lc-rs), and CI runs both on the box leg's carrier on every change. +The schema number and `typ` are provisional and change when the shared registry assigns them. +The encoder is this package's own: no Elixir library writes deterministic CBOR or COSE with +ML-DSA. It is held to RFC 8949 Appendix A and to RFC 9964 Appendix A.2, byte for byte. + +## Any module + +This package reads the key options, and for COSE the issuer, and nothing else +(`opts[:private_key]`, for ML-DSA-87 `opts[:public_key]`, for COSE `opts[:issuer]`) -- no +environment variable, no file, no application config, no default. Where the keys live between calls is the host's decision. **Core is a separate package.** `beam_mcp` holds no key and calls no signing primitive; it is diff --git a/SECURITY.md b/SECURITY.md index 8f7ee47..b165c8b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,8 +32,9 @@ No paid bounty and no guaranteed fix deadline. The security requirements a host can rely on, each held by a test in `test/`: - **The key is read from one place.** `sign/2` reads the private key from - `opts[:private_key]` (and `BeamMCP.Signer.MLDSA87` also the public key, from - `opts[:public_key]`) on each call and from nowhere else: no environment variable, file, + `opts[:private_key]` (and `BeamMCP.Signer.MLDSA87` and `BeamMCP.Signer.COSE` also the + public key, from `opts[:public_key]`, and COSE the issuer, from `opts[:issuer]`) on each call + and from nowhere else: no environment variable, file, application config, default key or store. A two-layer census holds it (`test/beam_mcp/signer/no_key_source_test.exs`). - **The key does not leave in a result or an exception.** Every return value is a signature or @@ -56,6 +57,11 @@ The security requirements a host can rely on, each held by a test in `test/`: verified under that key, and must not verify over bytes one bit away, before it is returned; otherwise the answer is a named error and no signature (`test/beam_mcp/signer/mldsa87_test.exs`). +- **The COSE carrier names what signed it, inside what is signed.** `BeamMCP.Signer.COSE` + returns deterministic CBOR whose ML-DSA-87 signature covers the algorithm (-50), the key's + SHA-384 thumbprint (computed from the public key, never taken from the host) and a statement + naming the canonical bytes by SHA-384 and length; change any of them and the signature is + invalid (`test/beam_mcp/signer/cose_test.exs`; Go and Rust verifiers in CI). - **Randomness.** Ed25519 signing is deterministic and uses none. ML-DSA-87 signing is hedged: OpenSSL's DRBG supplies per-signature randomness, and FIPS 204's construction keeps the signature secure if that randomness fails. The package generates no key and no nonce. diff --git a/docs/architecture.md b/docs/architecture.md index 6710f41..49d9631 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,8 +5,9 @@ SPDX-License-Identifier: Apache-2.0 # Architecture -Two modules, one function each, one dependency direction. Ed25519 is drawn below; ML-DSA-87 -takes the same path with two keys and a check before it returns. +Three modules, one function each, one dependency direction. Ed25519 is drawn below; ML-DSA-87 +takes the same path with two keys and a check before it returns; COSE builds a statement around +the bytes and has ML-DSA-87 sign that. ```text host application @@ -46,10 +47,18 @@ takes the same path with two keys and a check before it returns. :self_check_failed}` when that verifies too) before returning it. The public key is the host's because `:crypto` derives none from a seed. On a runtime without ML-DSA-87 (OTP 27, or OpenSSL before 3.5) every call is `{:error, :crypto_refused}`. +- **`BeamMCP.Signer.COSE`** (`lib/beam_mcp/signer/cose.ex`): the same behaviour, MLDSA87's + two keys read the same way (each function called once), and `opts[:issuer]`. It builds a + statement about the canonical bytes (their SHA-384 and length, and the issuer) and a COSE + protected header naming the algorithm (-50) and the key (its SHA-384 thumbprint, computed + from the public key), has `BeamMCP.Signer.MLDSA87` sign and check the COSE ToBeSigned over + them, and returns the N6 single-signer carrier. Its one `:crypto` call is SHA-384; it calls + no signing primitive. Its encoder, `BeamMCP.Signer.COSE.CBOR` (private), writes + deterministic CBOR under N6's rules and refuses anything else by name. - **`beam_mcp`** (a dependency, `~> 0.7`): defines the behaviour and produces the bytes. It holds no key, calls no signing primitive and does not depend on this package. -- **Erlang/OTP `:crypto`**: performs Ed25519 and ML-DSA-87, through OpenSSL. This package - implements no cryptography itself. +- **Erlang/OTP `:crypto`**: performs Ed25519, ML-DSA-87 and SHA-384, through OpenSSL. This + package implements no cryptography itself; the CBOR encoder is an encoding, not a primitive. ## Properties the arrangement keeps diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 5ce5c74..93a629d 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -20,6 +20,12 @@ ML-DSA-87 signature (FIPS 204, pure, empty context) over exactly the bytes given under that public key, or no signature at all; it reads the keys from nowhere else, keeps no copy of them, and returns nothing that contains them.** +**Given the same two keys and an issuer, `BeamMCP.Signer.COSE.sign/2` returns deterministic +CBOR, the N6 single-signer carrier, whose ML-DSA-87 signature covers a statement naming the +given bytes by SHA-384 and length together with a protected header naming the algorithm (-50) +and the key (its SHA-384 thumbprint, computed from the public key); changing any of them makes +the signature invalid. It reads the keys and the issuer from nowhere else and keeps nothing.** + ## Threat model The asset is the host's private key; the goal is that this package adds no way for it to be @@ -33,6 +39,8 @@ read, kept or misused beyond the host's own call. | The key printed in an exception or a log line | yes | nothing `sign/2` meets is raised: a mistyped call is answered `{:error, :bad_arguments}` (so is an improper list whose end comes before a `:private_key` entry), a failing key function `{:error, {:private_key, :unreadable}}`, a refusal from `:crypto` `{:error, :crypto_refused}`; the key passed by reference prints as `#Function<...>` wherever the options are printed, core's frames included (tested through core) | | A signature over bytes other than those given, or a non-standard signature | yes | the bytes go to `:crypto.sign/4` unaltered, with the algorithm and `:none` as literals and no options argument (census); tests verify with `:crypto.verify/5` and with core's `encode!/2` bytes | | ML-DSA-87: a signature that does not verify under the key the host publishes (a seed and public key that are not a pair, a faulted signature) | yes | every signature is verified under the given public key, and must not verify over bytes one bit away, before it is returned; otherwise a named error and no signature | +| The algorithm or the key named beside a signature swapped (scheme and key id are host-asserted, outside the signed bytes) | yes, for `BeamMCP.Signer.COSE` | alg and kid are in the COSE protected header, inside ToBeSigned; tests, and Go and Rust verifiers in CI, show the signature fails once either is changed. The two raw-signature modules do not address it, by their shape | +| COSE output a verifier reads differently (non-deterministic CBOR, an extra header label, a tagged or attached Sign1) | yes | the encoder writes RFC 8949 §4.2.1 under N6's rules and refuses everything else by name, held to RFC 8949 Appendix A and RFC 9964 A.2 byte for byte; the header is exactly `{1, 4, 16}`; two non-Elixir libraries (fxamacker/cbor, coset) re-read it in CI | | Weak randomness | yes | Ed25519 uses none (deterministic, RFC 8032). ML-DSA-87 is hedged: OpenSSL's DRBG supplies the per-signature `rnd`; were it constant, signing would be FIPS 204's deterministic variant, which is also approved. No key is generated here | | The host's key storage, the node the host runs, OpenSSL defects | no | the host's and upstream's; stated in `SECURITY.md` | | Code already running in the same BEAM node | no | the BEAM has no in-node isolation; any process there can read the host's memory | @@ -52,7 +60,7 @@ read, kept or misused beyond the host's own call. | principle | here | |---|---| -| Economy of mechanism | two modules, one public function each; remote calls `:crypto.sign/4`, and `:crypto.verify/5` for ML-DSA-87's check, pinned by census | +| Economy of mechanism | three modules, one public function each, and a private encoder; remote calls `:crypto.sign/4`, `:crypto.verify/5` for ML-DSA-87's check, and `:crypto.hash/2` (SHA-384) in COSE, pinned by census | | Fail-safe defaults | no default key; a missing or malformed key is an error, never a signature | | Complete mediation | the key is checked on every call; nothing is cached | | Open design | standard Ed25519 and ML-DSA-87, verifiable by any implementation; the source is Apache-2.0 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ae916f..ca068d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -36,6 +36,12 @@ implementing the same behaviour (`BeamMCP.Signer.MLDSA87`), for CNSA 2.0, which the signature for national security systems acquired from 2027. A third is not planned; if one is ever needed it follows the same rule, decided in the open. +A third module wraps the second rather than adding an algorithm: `BeamMCP.Signer.COSE` signs +the same ML-DSA-87 inside COSE, so the algorithm and the key are named in the signed bytes and +not only beside them, in the deterministic-CBOR form (N6) Script Kitty's systems share. The +RFC 9995 hash envelope, for artifacts too large to hand over whole, is next, as its own mode +and not a claimed one until its standing under CNSA 2.0 is settled. + ## How it changes A change to this page is made in the pull request that changes direction, and recorded in the diff --git a/docs/security-review.md b/docs/security-review.md index 62323e4..f9db26e 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -21,9 +21,9 @@ 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` and - `lib/beam_mcp/signer/mldsa87.ex` in full. Is each key read from its option only - (`:private_key`, and `:public_key` for ML-DSA-87)? Does the census's regex and call list still describe the +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 + 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 file after a call; a reference (`fn -> key end`) called once and dropped. @@ -35,6 +35,10 @@ Each item is answered with what was read, what was tried, and what was found. type `:none` and no options argument, so no prehash and no context. For ML-DSA-87: does the check before return refuse a seed with the wrong public key, and does the suite's vector count still match the fixtures? + For COSE: is the protected header exactly `{1: -50, 4: kid, 16: typ}` with `kid` computed + 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? 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 new file mode 100644 index 0000000..966c6e7 --- /dev/null +++ b/lib/beam_mcp/signer/cose.ex @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +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 + `BeamMCP.Signer.MLDSA87`, the same two keys, and one more option. + + Canonical.signature(graph, BeamMCP.Signer.COSE, + private_key: fn -> seed end, public_key: fn -> public_key end, issuer: "box-17") + + **Why.** `BeamMCP.Connectome.Canonical.signature/3` places `scheme:` and `key_id:` beside a + signature, asserted by the host and verified by nobody: swap either and a raw signature still + verifies. Here the algorithm and the key's thumbprint are in the COSE protected header, inside + the bytes that are signed, so a verifier that changes or misreads either finds the signature + invalid. + + **What comes back** is one binary, the N6 single-signer carrier, deterministic CBOR: + + {0: 0xFFFF0003, ; the single-signer carrier + 1: h'', ; the statement, below + 2: [h'', {}, null, h'']} ; COSE_Sign1, untagged, detached + + * The **protected header** is `{1: -50, 4: kid, 16: typ}` and nothing else. `kid` is the + 48-byte SHA-384 COSE Key Thumbprint (RFC 9679) of the public key, computed from the key on + every call, never taken from the host. `typ` is + `"application/vnd.example.b85.connectome.v0+cose"`. + * The **body** is `{0: 0x85000101, 1: issuer, 2: {0: [-43, SHA-384(canonical_bytes)], + 1: byte_size(canonical_bytes), 2: "application/json"}}`: a statement about the canonical + bytes, naming them by SHA-384 and length. The canonical bytes themselves are not in it and + are not changed; a verifier is handed them beside the carrier and checks the digest. + * The **signature** is pure ML-DSA-87, empty context, over the COSE ToBeSigned + `["Signature1", protected, h'', body]` (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 schema number (block 0x85, beam_mcp's provisional block) and `typ` are provisional, and + are re-issued when the shared registry assigns them; the profile itself is a v0 draft. + + **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}`. + Nothing else: no environment variable, file, configuration or default. + """ + + @behaviour BeamMCP.Signer + + alias BeamMCP.Signer.COSE.CBOR + alias BeamMCP.Signer.MLDSA87 + + @single_schema 0xFFFF0003 + @schema 0x85000101 + @typ "application/vnd.example.b85.connectome.v0+cose" + @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. + + Returns `{:ok, carrier}` or `{:error, reason}`: every reason `BeamMCP.Signer.MLDSA87.sign/2` + gives, and + + * `:no_issuer` -- the option is absent; + * `{:issuer, :invalid}` -- present, and neither UTF-8 text in NFC nor `{:bytes, binary}`. + + Answers, not raises, as `BeamMCP.Signer.MLDSA87` answers; no answer carries a key byte. + """ + @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), + {:ok, body} <- body(canonical_bytes, issuer), + {:ok, protected} <- protected(public), + {:ok, to_be_signed} <- + CBOR.encode(["Signature1", {:bytes, protected}, {:bytes, ""}, {:bytes, body}]), + {:ok, signature} <- + MLDSA87.sign(to_be_signed, private_key: seed, public_key: public) do + CBOR.encode(%{ + 0 => @single_schema, + 1 => {:bytes, body}, + 2 => [{:bytes, protected}, %{}, nil, {:bytes, signature}] + }) + end + end + + 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(%{ + 0 => @schema, + 1 => issuer, + 2 => %{ + 0 => [@sha384, {:bytes, :crypto.hash(:sha384, canonical_bytes)}], + 1 => byte_size(canonical_bytes), + 2 => @media_type + } + }) + end + end + + defp issuer({:bytes, b} = issuer) when is_binary(b), do: {:ok, issuer} + + defp issuer(t) when is_binary(t) do + case CBOR.encode(t) do + {:ok, _} -> {:ok, t} + {:error, _} -> {:error, {:issuer, :invalid}} + end + end + + 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/cbor.ex b/lib/beam_mcp/signer/cose/cbor.ex new file mode 100644 index 0000000..c266702 --- /dev/null +++ b/lib/beam_mcp/signer/cose/cbor.ex @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +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. + # + # 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 + + @type value :: + integer() + | {:bytes, binary()} + | String.t() + | [value()] + | %{optional(non_neg_integer()) => value()} + | boolean() + | nil + + @type reason :: + :int_out_of_range + | :float_forbidden + | :key_not_uint + | :invalid_utf8 + | :not_nfc + | :not_encodable + + @spec encode(term()) :: {:ok, binary()} | {:error, reason()} + def encode(value) do + {:ok, value |> enc() |> IO.iodata_to_binary()} + catch + {:refused, reason} -> {:error, reason} + end + + defp enc(n) when is_integer(n) and n >= 0 and n < 1 <<< 64, do: head(0, n) + defp enc(n) when is_integer(n) and n < 0 and n >= -(1 <<< 63), do: head(1, -1 - n) + defp enc(n) when is_integer(n), do: refuse(:int_out_of_range) + defp enc(f) when is_float(f), do: refuse(:float_forbidden) + defp enc(false), do: <<0xF4>> + defp enc(true), do: <<0xF5>> + 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(m) when is_map(m) do + pairs = + m + |> Enum.map(fn {k, v} -> {IO.iodata_to_binary(key(k)), v} end) + |> Enum.sort_by(fn {k, _v} -> k end) + + [head(5, map_size(m)) | Enum.map(pairs, fn {k, v} -> [k, enc(v)] end)] + end + + defp enc(_other), do: refuse(:not_encodable) + + 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) + + 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 + end + end + + 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 < 0x100000000, do: <> + defp head(major, n), do: <> + + defp refuse(reason), do: throw({:refused, reason}) +end diff --git a/mix.exs b/mix.exs index 936ffc3..1197fe4 100644 --- a/mix.exs +++ b/mix.exs @@ -20,11 +20,13 @@ defmodule BeamMCP.Signer.Ed25519.MixProject do version: @version, elixir: "~> 1.17", elixirc_options: [warnings_as_errors: true], + # test/support/ holds code the tests load (the strict CBOR reader), not tests. + test_ignore_filters: [&String.starts_with?(&1, "test/support/")], start_permanent: Mix.env() == :prod, deps: deps(), package: package(), description: - "Ed25519 and ML-DSA-87 signers for beam_mcp's canonical connectome bytes, through OTP's :crypto; the host hands the keys in", + "Ed25519 and ML-DSA-87 signers for beam_mcp's canonical connectome bytes, raw or in COSE, through OTP's :crypto; the host hands the keys in", source_url: @source_url, docs: [ main: "readme", diff --git a/test/beam_mcp/signer/cose_cbor_test.exs b/test/beam_mcp/signer/cose_cbor_test.exs new file mode 100644 index 0000000..b9f2541 --- /dev/null +++ b/test/beam_mcp/signer/cose_cbor_test.exs @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.COSE.CBORTest do + # The encoder against bytes written here by hand, never by the encoder: RFC 8949 Appendix A's + # rows that N6 admits, every head-length boundary, map-key order, and a refusal for each + # thing N6 forbids. The strict decoder (test/support/strict_cbor.exs) reads every encoding + # back without the encoder's help. + use ExUnit.Case, async: true + + alias BeamMCP.Signer.COSE.CBOR + alias BeamMCP.Signer.Test.StrictCBOR + + import Bitwise + + defp hex(s), do: Base.decode16!(String.replace(s, " ", ""), case: :lower) + + defp encodes(value, expected_hex) do + expected = hex(expected_hex) + assert CBOR.encode(value) == {:ok, expected}, "#{inspect(value)}" + assert StrictCBOR.decode(expected) == {:ok, value}, "#{inspect(value)} does not read back" + end + + # RFC 8949 Appendix A, Table 6: the rows inside N6 (integers in [-2^63, 2^64-1], byte and text + # strings, arrays, maps with unsigned keys, false, true, null). + test "RFC 8949 Appendix A: every row N6 admits, byte for byte" do + for {value, h} <- [ + {0, "00"}, + {1, "01"}, + {10, "0a"}, + {23, "17"}, + {24, "1818"}, + {25, "1819"}, + {100, "1864"}, + {1000, "1903e8"}, + {1_000_000, "1a000f4240"}, + {1_000_000_000_000, "1b000000e8d4a51000"}, + {18_446_744_073_709_551_615, "1bffffffffffffffff"}, + {-1, "20"}, + {-10, "29"}, + {-100, "3863"}, + {-1000, "3903e7"}, + {false, "f4"}, + {true, "f5"}, + {nil, "f6"}, + {{:bytes, ""}, "40"}, + {{:bytes, <<1, 2, 3, 4>>}, "4401020304"}, + {"", "60"}, + {"a", "6161"}, + {"IETF", "6449455446"}, + {"\"\\", "62225c"}, + {"ü", "62c3bc"}, + {"水", "63e6b0b4"}, + {"\u{10151}", "64f0908591"}, + {[], "80"}, + {[1, 2, 3], "83010203"}, + {[1, [2, 3], [4, 5]], "8301820203820405"}, + {Enum.to_list(1..25), "98190102030405060708090a0b0c0d0e0f101112131415161718181819"}, + {%{}, "a0"}, + {%{1 => 2, 3 => 4}, "a201020304"} + ] do + encodes(value, h) + end + end + + test "every head takes its shortest form, at each boundary" do + for {n, h} <- [ + {23, "17"}, + {24, "1818"}, + {255, "18ff"}, + {256, "190100"}, + {65_535, "19ffff"}, + {65_536, "1a00010000"}, + {4_294_967_295, "1affffffff"}, + {4_294_967_296, "1b0000000100000000"}, + {-24, "37"}, + {-25, "3818"}, + {-(1 <<< 63), "3b7fffffffffffffff"} + ] do + encodes(n, h) + end + + encodes({:bytes, :binary.copy(<<0>>, 24)}, "5818" <> String.duplicate("00", 24)) + encodes(String.duplicate("a", 256), "790100" <> String.duplicate("61", 256)) + end + + test "map keys go in the order of their encoded bytes, whatever order they are given in" do + encodes(%{100 => 0, 1 => 0, 24 => 0, 10 => 0, 4 => 0}, "a5 0100 0400 0a00 181800 186400") + + encodes( + %{258 => -43, 16 => "t", 4 => {:bytes, <<9>>}, 1 => -50}, + "a4 013831 044109 106174 190102382a" + ) + end + + # Up to 32 keys an Erlang map enumerates in key order, which for unsigned keys is already the + # encoded order; past 32 it is a hash map, enumerated in no useful order. So the sort is only + # seen working on a map this size. + test "a map of more than 32 keys still goes in encoded-key order" do + map = 0..39 |> Enum.shuffle() |> Map.new(&{&1, 0}) + + expected = + "b828" <> + Enum.map_join(0..23, &(Base.encode16(<<&1>>, case: :lower) <> "00")) <> + Enum.map_join(24..39, &("18" <> Base.encode16(<<&1>>, case: :lower) <> "00")) + + encodes(map, expected) + end + + test "what N6 forbids is refused by name, and nothing is encoded" do + for {value, reason} <- [ + {1 <<< 64, :int_out_of_range}, + {-(1 <<< 63) - 1, :int_out_of_range}, + {1.0, :float_forbidden}, + {0.0, :float_forbidden}, + {%{"a" => 1}, :key_not_uint}, + {%{-1 => 1}, :key_not_uint}, + {%{(1 <<< 32) => 1}, :key_not_uint}, + {"é", :not_nfc}, + {<<0xFF>>, :invalid_utf8}, + {:undefined, :not_encodable}, + {{:tag, 18, 1}, :not_encodable}, + {[1, 2.5], :float_forbidden}, + {%{1 => [%{2 => 1.5}]}, :float_forbidden} + ] do + assert CBOR.encode(value) == {:error, reason}, "#{inspect(value)}" + end + end + + test "the strict reader refuses what the encoder would never write" do + for {h, reason} <- [ + {"1801", :noncanonical_or_truncated}, + {"190017", :noncanonical_or_truncated}, + {"9f01ff", :indefinite_length}, + {"d201", :tag_forbidden}, + {"f93c00", :float_or_simple_forbidden}, + {"f7", :float_or_simple_forbidden}, + {"a203040102", :noncanonical}, + {"a201010102", :duplicate_key}, + {"a1616101", :key_not_uint}, + {"6365cc81", :not_nfc}, + {"0000", :trailing_bytes}, + {"62c3", :truncated} + ] do + assert StrictCBOR.decode(hex(h)) == {:error, reason}, h + end + end +end diff --git a/test/beam_mcp/signer/cose_test.exs b/test/beam_mcp/signer/cose_test.exs new file mode 100644 index 0000000..0e9d945 --- /dev/null +++ b/test/beam_mcp/signer/cose_test.exs @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.COSETest do + # The carrier read back by the strict decoder (test/support/strict_cbor.exs), never by the + # module's own encoder, and its ToBeSigned rebuilt here byte by byte, so the module is held to + # N6 (r3 §5, the publisher's answers of 2026-10-19 21:24Z and 2026-10-20 17:24Z) and not to + # itself. RFC 9964 Appendix A.2 (test/fixtures/cose/, tools/vectors_cose.sh) holds the + # encoder and the thumbprint input to bytes someone else published. The key pair is ACVP's + # (tgId 3, tcId 51), as in mldsa87_test.exs. + use ExUnit.Case, async: true + + alias BeamMCP.Connectome.{Canonical, Edge, Graph, Node} + alias BeamMCP.Signer.{COSE, MLDSA87} + alias BeamMCP.Signer.COSE.CBOR + alias BeamMCP.Signer.Test.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"]) + + @a2 "test/fixtures/cose/rfc9964_a2_ml_dsa_87.json" |> File.read!() |> Jason.decode!() + + @single_schema 0xFFFF0003 + @schema 0x85000101 + @typ "application/vnd.example.b85.connectome.v0+cose" + # RFC 9679 over the AKP key {1: 7, 3: -50, -1: pub}, encoded under RFC 8949 §4.2.1 (r3 §5). + @thumbprint_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> + + defp opts(extra \\ []), + do: extra ++ [private_key: @seed, public_key: @pub, issuer: "beam_mcp.test"] + + defp graph do + srv = Node.new!(kind: :server, level: :server, identity: {:server, "srv"}) + a = Node.new!(kind: :tool, level: :server, identity: {:tool, "srv", "a"}) + b = Node.new!(kind: :tool, level: :server, identity: {:tool, "srv", "b"}) + e = Edge.new!(from: a.id, to: b.id, kind: :invoke, provenance: :declared, weight: 1) + Graph.new!(nodes: [srv, a, b], edges: [e], schema_version: Graph.schema_version()) + end + + # CBOR heads written here, independently of the encoder under test. + 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), do: <> + defp bstr(b), do: head(2, byte_size(b)) <> b + + defp to_be_signed(protected, payload), + do: <<0x84, 0x6A, "Signature1">> <> bstr(protected) <> <<0x40>> <> bstr(payload) + + defp carrier(bytes) do + {:ok, %{0 => @single_schema, 1 => {:bytes, body}, 2 => sign1} = c} = StrictCBOR.decode(bytes) + assert map_size(c) == 3 + [{:bytes, protected}, %{} = unprotected, nil, {:bytes, signature}] = sign1 + assert unprotected == %{} + %{body: body, protected: protected, signature: signature} + end + + defp verify(%{body: body, protected: protected, signature: signature}), + do: :crypto.verify(:mldsa87, :none, to_be_signed(protected, body), signature, @pub) + + test "implements BeamMCP.Signer, and only sign/2" do + assert BeamMCP.Signer in (COSE.module_info(:attributes)[:behaviour] || []) + assert COSE.__info__(:functions) == [sign: 2] + end + + describe "F8: the algorithm and the key beside a signature" do + @describetag :mldsa87 + + test "today: scheme and key_id are not signed, so swapping them leaves the signature valid" do + {:ok, result} = + Canonical.signature(graph(), MLDSA87, + private_key: @seed, + public_key: @pub, + scheme: :mldsa87, + key_id: "k-mldsa" + ) + + swapped = %{result | scheme: :ed25519, key_id: "k-someone-else"} + {:ok, bytes} = Canonical.encode(graph()) + assert :crypto.verify(:mldsa87, :none, bytes, swapped.signature, @pub) + end + + test "in the carrier: changing alg, kid or typ in the protected header, or the body, breaks the signature" do + {:ok, bytes} = Canonical.encode(graph()) + assert {:ok, out} = COSE.sign(bytes, opts()) + c = carrier(out) + assert verify(c) + + {:ok, header} = StrictCBOR.decode(c.protected) + <> = elem(header[4], 1) + + for changed <- [ + Map.put(header, 1, -49), + Map.put(header, 4, {:bytes, <>}), + Map.put(header, 16, "application/vnd.example.b85.other.v0+cose") + ] do + {:ok, protected} = CBOR.encode(changed) + refute verify(%{c | protected: protected}) + end + + <> = c.body + refute verify(%{c | body: <>}) + end + end + + describe "the carrier" do + @describetag :mldsa87 + + test "{0: 0xFFFF0003, 1: body, 2: [protected, {}, nil, signature]}, strictly canonical" do + {:ok, bytes} = Canonical.encode(graph()) + assert {:ok, out} = COSE.sign(bytes, opts()) + c = carrier(out) + assert byte_size(c.signature) == 4627 + assert verify(c) + end + + test "the protected header is {1: -50, 4: kid48, 16: typ} and nothing else" do + {:ok, out} = COSE.sign("bytes", opts()) + c = carrier(out) + kid = :crypto.hash(:sha384, @thumbprint_prefix <> @pub) + assert StrictCBOR.decode(c.protected) == {:ok, %{1 => -50, 4 => {:bytes, kid}, 16 => @typ}} + end + + test "the body names the canonical bytes by SHA-384 and length, with the host's issuer" do + {:ok, bytes} = Canonical.encode(graph()) + {:ok, out} = COSE.sign(bytes, opts()) + + assert StrictCBOR.decode(carrier(out).body) == + {:ok, + %{ + 0 => @schema, + 1 => "beam_mcp.test", + 2 => %{ + 0 => [-43, {:bytes, :crypto.hash(:sha384, bytes)}], + 1 => byte_size(bytes), + 2 => "application/json" + } + }} + end + + test "an issuer given as {:bytes, b} is a byte string in the body" do + {:ok, out} = COSE.sign("bytes", opts(issuer: {:bytes, <<1, 2, 3>>})) + assert {:ok, %{1 => {:bytes, <<1, 2, 3>>}}} = StrictCBOR.decode(carrier(out).body) + end + + test "hedged: two carriers over the same bytes differ only in the signature" do + {:ok, one} = COSE.sign("bytes", opts()) + {:ok, two} = COSE.sign("bytes", opts()) + {c1, c2} = {carrier(one), carrier(two)} + assert {c1.body, c1.protected} == {c2.body, c2.protected} + refute c1.signature == c2.signature + assert verify(c1) and verify(c2) + end + + test "each key function is called once per signature" do + me = self() + + private = fn -> + send(me, :private) + @seed + end + + public = fn -> + send(me, :public) + @pub + end + + assert {:ok, _} = + COSE.sign("bytes", private_key: private, public_key: public, issuer: "i") + + assert_received :private + assert_received :public + refute_received :private + refute_received :public + end + end + + describe "RFC 9964 Appendix A.2 (ML_DSA_87)" do + test "SHA-256 over the thumbprint input N6 hashes reproduces the RFC's kid (RFC 9679, SHA-256)" do + pub = Base.decode16!(@a2["raw_public_key"], case: :lower) + {:ok, key} = @a2["key"] |> Base.decode16!(case: :lower) |> cose_key() + assert :crypto.hash(:sha256, @thumbprint_prefix <> pub) == key.kid + assert key.pub == pub + end + + test "the encoder reproduces the RFC's protected header and ToBeSigned, byte for byte" do + tbs = Base.decode16!(@a2["raw_to_be_signed"], case: :lower) + {:ok, key} = @a2["key"] |> Base.decode16!(case: :lower) |> cose_key() + + assert {:ok, protected} = CBOR.encode(%{1 => -50, 4 => {:bytes, key.kid}}) + + assert CBOR.encode([ + "Signature1", + {:bytes, protected}, + {:bytes, ""}, + {:bytes, "hello post quantum signatures"} + ]) == {:ok, tbs} + end + + @tag :mldsa87 + test "the RFC's signature verifies under its key, and its all-zero seed is that key's" do + pub = Base.decode16!(@a2["raw_public_key"], case: :lower) + tbs = Base.decode16!(@a2["raw_to_be_signed"], case: :lower) + sig = Base.decode16!(@a2["raw_signature"], case: :lower) + seed = Base.decode16!(@a2["priv"], case: :lower) + + assert :crypto.verify(:mldsa87, :none, tbs, sig, pub) + assert {:ok, _} = MLDSA87.sign(tbs, private_key: seed, public_key: pub) + end + end + + # The RFC's COSE_Key is a map with negative labels (-1, -2), outside N6, so it is read here by + # hand: a5 (five pairs), 02 kid, 01 kty, 03 alg, 20 pub, 21 priv. + defp cose_key( + <<0xA5, 0x02, 0x58, 0x20, kid::binary-32, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, + 0x20, pub::binary-2592, _priv::binary>> + ), + do: {:ok, %{kid: kid, pub: pub}} + + describe "refusals: answers, never raises, and no key byte in any" do + test "no issuer, or an issuer that is neither NFC text nor {:bytes, b}" do + assert COSE.sign("b", private_key: @seed, public_key: @pub) == {:error, :no_issuer} + + for bad <- ["é", <<0xFF>>, 1, :atom, {:bytes, 1}, nil] do + assert COSE.sign("b", opts(issuer: bad)) == {:error, {:issuer, :invalid}}, inspect(bad) + end + end + + test "the key refusals are MLDSA87's, and say nothing of the key" do + assert COSE.sign("b", issuer: "i") == {:error, :no_private_key} + assert COSE.sign("b", private_key: @seed, issuer: "i") == {:error, :no_public_key} + + assert COSE.sign("b", private_key: <<1, 2>>, public_key: @pub, issuer: "i") == + {:error, {:private_key, :not_32_bytes}} + + assert COSE.sign("b", private_key: @seed, public_key: <<1>>, issuer: "i") == + {:error, {:public_key, :not_2592_bytes}} + + assert COSE.sign("b", private_key: fn -> raise "x" end, public_key: @pub, issuer: "i") == + {:error, {:private_key, :unreadable}} + + assert COSE.sign(:not_bytes, opts()) == {:error, :bad_arguments} + assert COSE.sign("b", %{}) == {:error, :bad_arguments} + assert COSE.sign("b", [{:issuer, "i"} | :improper]) == {:error, :bad_arguments} + end + + @tag :mldsa87 + test "a public key that is not the seed's: no carrier" do + {other, _} = :crypto.generate_key(:mldsa87, []) + assert COSE.sign("b", opts(public_key: other)) == {:error, {:public_key, :does_not_verify}} + end + + @tag :no_mldsa87 + test "on a runtime without ML-DSA-87: :crypto_refused" do + assert COSE.sign("b", opts()) == {:error, :crypto_refused} + end + end +end diff --git a/test/beam_mcp/signer/mldsa87_test.exs b/test/beam_mcp/signer/mldsa87_test.exs index b7c70ef..63406fb 100644 --- a/test/beam_mcp/signer/mldsa87_test.exs +++ b/test/beam_mcp/signer/mldsa87_test.exs @@ -93,8 +93,16 @@ defmodule BeamMCP.Signer.MLDSA87Test do test "both keys by reference: each function called once per signature" do me = self() - seed = fn -> send(me, :seed) && @seed end - pub = fn -> send(me, :pub) && @pub end + + seed = fn -> + send(me, :seed) + @seed + end + + pub = fn -> + send(me, :pub) + @pub + end assert {:ok, sig} = MLDSA87.sign("abc", private_key: seed, public_key: pub) assert verify("abc", sig) diff --git a/test/beam_mcp/signer/no_key_source_test.exs b/test/beam_mcp/signer/no_key_source_test.exs index f732f2d..f443af4 100644 --- a/test/beam_mcp/signer/no_key_source_test.exs +++ b/test/beam_mcp/signer/no_key_source_test.exs @@ -7,10 +7,16 @@ defmodule BeamMCP.Signer.NoKeySourceTest do # a call the beam shows. use ExUnit.Case, async: true - alias BeamMCP.Signer.{Ed25519, MLDSA87} + alias BeamMCP.Signer.{COSE, Ed25519, MLDSA87} + alias BeamMCP.Signer.COSE.CBOR @lib Path.wildcard(Path.expand("../../../lib/**/*.ex", __DIR__)) - @source %{Ed25519 => "ed25519.ex", MLDSA87 => "mldsa87.ex"} + @source %{ + Ed25519 => "ed25519.ex", + MLDSA87 => "mldsa87.ex", + COSE => "cose.ex", + CBOR => "cbor.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/ @@ -41,7 +47,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 two signer modules, and no line under lib/ names an environment, file, config or store read" do + 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 assert @lib |> Enum.map(&Path.basename/1) |> Enum.sort() == @source |> Map.values() |> Enum.sort() @@ -88,6 +94,40 @@ 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 + assert remote_calls(COSE) == [ + {CBOR, :encode, 1}, + {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 + assert [{:atom, _, :sha384} | _] = args + end + end + + test "BeamMCP.Signer.COSE.CBOR: pure encoding, no :crypto, no I/O" do + calls = remote_calls(CBOR) + + assert Enum.map(calls, &elem(&1, 0)) |> Enum.uniq() |> Enum.sort() == [ + Enum, + String, + :erlang, + :unicode + ] + + assert {:unicode, :characters_to_nfc_binary, 1} in calls + assert {String, :valid?, 1} in calls + end + test "Ed25519: the private key is 32 bytes, given directly or by a zero-arity reference, and the seed length is a module constant" do src = source(Ed25519) assert src =~ "@private_key_bytes 32" @@ -107,6 +147,15 @@ defmodule BeamMCP.Signer.NoKeySourceTest do assert src =~ "{:seed, seed}", "signs from the seed, never from a stored expanded key" end + defp remote_calls(module) do + module + |> forms() + |> collect_remote_calls() + |> Enum.reject(fn {m, _f, _a} -> m == module end) + |> Enum.uniq() + |> Enum.sort() + end + defp source(module), do: File.read!(Enum.find(@lib, &(Path.basename(&1) == @source[module]))) defp forms(module) do diff --git a/test/fixtures/cose/rfc9964_a2_ml_dsa_87.json b/test/fixtures/cose/rfc9964_a2_ml_dsa_87.json new file mode 100644 index 0000000..d472f79 --- /dev/null +++ b/test/fixtures/cose/rfc9964_a2_ml_dsa_87.json @@ -0,0 +1 @@ +{"source":"RFC 9964 Appendix A.2, Figure 8 (ML_DSA_87); rfc-editor.org/rfc/rfc9964.txt, sha256 8c42035b948301b197d431de8b3f40019bdaa3bb40150dc523306923948f34cf","priv":"0000000000000000000000000000000000000000000000000000000000000000","key":"a5025820d9bc439f97bd6d4093e68f0f3fcf09c9a97adf888ed7308dd565247a166cb4fa010703383120590a20e45ffc8cc73db885dc662e62a18cd8e3803297117fa5658814a985b5ff1db7b468cfc82bb929f1d86b77ed14f5ae16a65368772ce51912410105e0456975ae91fdb643b512f124d5e60bd68b8c7e31fe01c7b0dc65ae470501cc565a6e1dfcfcfd12565433c4afedd511821e2e9610c45275e2836dee35ced69d7efa672fd1e4318bef5eb6e897e8b451aa202ded042b2aaef77a7be3f699146da229a8bdb3ffa496445967e75217bfbc9048f9956443d8731f833eb30de10dac96fffe7cf65ea0445c3e31e8601e133be6a100764fe3196e267726441f31751fbf9a6f5880644f4e7275e57de2b0f105e4db055d50dd1c9c934fddf535b8de28b0c74c0449f222cd2ed0bb8fbc775ccee8c940665b40f712f4f7e00750e9e1e4cd9cff25d1945c3e9bca53ccd4f12eee7581856ebd68f26845956e3e7beb761f0fe75bdd31bfe2fa018113397b387bd59d62a68b8af7fa245ab932e69f778e2ceefd21304fbb8099ea13d8ea57c1813197a2f75ae251075b51dad38f853669e9d5f98a3655098941993a1594860fba71fe530ee5c29f58f2978af688ccb75a5838a359c112e98e25a8583ac8dac1f861fd58e2afba5de5a52e020904f5b42bc0874e35befcf3e6119684768f36e008f04712177cebe627607381e56eaaee161c1729b8de51dbde474d48cc68249ea27162b87993e60c84ed6cc6423cb3676d9eb50b2cab5a3a049ef131381d623fa6fbcbc9db1e7cc025ea0418b9dad2cc6ccd4e95fa2cec24feeca70318a751716b7213f63edbf65a63338357f838f94ec071822c24851248885107b3d1c4e924678c7614ea1af038104619f2ae372940becfa69e29cbb5ff6c3e20a47be4a4f74bac34c133c00a6a706accc6ffd3d8e4fbd69a99704e1283c850d8c58d1e5753cd9587b83c4c346cb9a58137213ec10834c66adfe2bb5c501a8ef2ecadd1b677a3df1a6deb86ebf0722c4f5030e20f9018dd5b6fc53eea24fd92b7b5b4025feae996d3e48fd4c650d82dbad7eaf936639698512f26253d2ef6847c8518e8565cc9a5495c6fff57cde7323882c54a7db470ab2daf8ffd2bf794fa7c692d9e7fbd532eecc1d7880e2ca0b3216128be28b4a9f1d151fac97808b0bd98b7b43a612a9ac865812bfeac6f47460277840b52a3b087f916ca7cedc0f768ea2bd19ea21155f84b4a04c4000ad2ae0587154d560bc0a477a4f9329a8984dd31eb1f2a05e3d918701d630cfca9af61ef088d2c5581acb463e439902e5d425719e956b8d6df7305b28e0ff27d3ad0de2085d292499b19a3390d4396fb3bac9a8d8cbead2a7a4290fc9ac6fca045f98a614a45a39cbe24360f84d14f8e472712aceb74dbf45b53d49a0e4737e476ffc4d5b2f7cd247aa186d3b764ad9e9cfeee456a73c291d8de3912414ac43911c372173ad7b472af35c6853ced2fe7b5fe0a89565ab33baa6f65cdd928319d7065e040e7a5e84f9aa903f7648094bad07136b16927b8ec6dbc2bef0cc2856de1e795923e1412c49f24deeb6c21f6c8a9765c9c7986e0da4b4c67d8e0d0c8d466824fb923d8573148990cd2ef133c78ceecab72ed9dd285c5a3766852d54534207ffd34027f6c76ede8fd1a32d72c30048bbaa797d5df6fde27d087de5721ad7b7fa3e8d3f70d6bfc3ab2e252335368bbfa15acb5cb37d4694e8b23cebe25de9c925a221a183b904d3f85df9929a919c54d6f87457373a0d6ecc1403e4cbbe620999435e80696634cd1a8e4747e9825bfa336e5bbad14f73640f1b9febe800dbaefe1630c61fae635b074c564eaa9db189c9e7302873fc64e6d497bc5c29080987a07a21d4af210703a4fa07f2fd816f12fd1e29b4c0f44afe9bd4a1eaa8a7ae6f02a5b4258f52caf6127f62632a67cf4e8310be56a7c28c86b2e277600c3e92c8d23d42586244c571e90568df202f2f6d81f860a565f9eb91a3c78372e2a8b1be61c5418cf49bf2d6c8955d4a482a9919b7660b3f9a4404ffc454ea073e1e4b2689ab2cca4e46bd7004a6c491fa26ee7a57d60f35edb2b821e6266442c8f335d452d524c772e0353724c23c7dd15b7aa155e91442022140c5fcb0153147edcf3e8952f6f0399a3c88066a72756c9409915de63f64fa797841c57c796c6fc550ef745dfe9f179457f94755ae5a2506a764f327e550be3dc14dd41f3b04b147d454938c63a8d69b2ea4c5710ec0b36e3a6c72571fa5d59dde036c42033df35af056966ff0cd1204008971aa6ba9fb97b685ab9ffa2a9d1778104cd2c3b326de1fcbc242e94d0311c3275b12850ed30ceead3a2ee6d060508411d4396f5421d8b6d067cf7cb5e826785fbe119e05e21bd879b64f57cb0cd1972c2815f20abe7ce6ab34d0f471af44baad179e90644122f5f33288e689ddddc5ce833e9755df1e73c65c5a201c4ede2ffa6b19274927719d2d38fdb7a65aa43708b7fa9a94aa7d3210253d78d3b181e1020d0000bd0a1dc05d447f9f58ebeb84c65b36c8afcb83727a1508994e826957a663b0b9b8a003325ab6d6d6462ee4e106019c0dffe10323b7bde7d82a38f85fd08786e860ba66c161b64b0708c363de5c6af62d8db3c243d1e1b712cb1d59e942b9b6b4295a5a500b182cbd5fd1bc6ce9376d91b47a2284f1fbe0ad1c048cc2cfbb4afa3a9eb9697503b69feca990eba7e9441af9ca44cb3ac6b5ed66e591c201fe30efa8a7c471dc613d6254c263a8e132104bec47f1aacb3b2fcd4051b69b5e3fcb1c147a65c2f90c4b5188bafc521cab03c12a309da50b5a7517727ed41228ed123fe1b152f6a6319cd623bf34ad7b8e064ab993260bcbd405f5b7fff9b2fa40ba5ed5630242539e5d96823e89dc818a13d16675ee3079d976f694f5acc9760ae789e9b3391b289e0e22a7ef17cc6a4577157b6d95c09baa4fd532e3ee0a290810ed35e56bb19d9b61fb98a97c617425b06093d98a5cf0ee2dd127f0eea600b9a0c67fbe761db9b77e5d5bba9701da1b883e521a0cfe88451f57bd36085b67e56f061f84a2e6a152a71bce6e522daab6a0a33ce22e537fa9793d28b617e6c0a4176a83aa3be578afac0f2f5547c5516d218984755b7445c7143afa4e551fce0071bdb873b34e6b9e2b9e79ed0c69d288ed6421f237e860a0c6492ebbdd2a44c2c4f368dbe99941b1e8561d859d3859f496cee3d741f252973f8fcc539c409e35cc80a5ed6df23cc3a65601313f5d681fd9540c5291a9e30a72e38c96413c47c61ff84fde78d011b01b4154d1b920af003f7abb1e1999dea6a766cf9fd2702b3ce0ee57af931b62124b0861b163a3b91aa4bea28076c3432df3b29b6c4e1ba588def420071fc157de90eb2722ecc9ab00df3c669383a61a91bb67bd287ce349b4745ee7a479dbceef166b9acc412eb579fcd6437307edda253d606b7be7599c38092bc52a8598480edab8b82b1d21c565d2137ceae0b6642619b16133d91205d6355029e9cdfeb9a28b373d95916b6b707d4c712c09cf36daf1a511b2bedb1aa70ee58d46a0666bb287784b0a3840c589a7a04d5d6f2216be90aa4a512d5632f5c9bfe7b8b13382f999b95d367c7c46b968074ce315197a5ff3545c7b77a804ade56a95b5c24cdece5937b5c0366d93ad03da9bc5db1b551dfb91e9b343d2b57b763439686d4a32158200000000000000000000000000000000000000000000000000000000000000000","raw_public_key":"e45ffc8cc73db885dc662e62a18cd8e3803297117fa5658814a985b5ff1db7b468cfc82bb929f1d86b77ed14f5ae16a65368772ce51912410105e0456975ae91fdb643b512f124d5e60bd68b8c7e31fe01c7b0dc65ae470501cc565a6e1dfcfcfd12565433c4afedd511821e2e9610c45275e2836dee35ced69d7efa672fd1e4318bef5eb6e897e8b451aa202ded042b2aaef77a7be3f699146da229a8bdb3ffa496445967e75217bfbc9048f9956443d8731f833eb30de10dac96fffe7cf65ea0445c3e31e8601e133be6a100764fe3196e267726441f31751fbf9a6f5880644f4e7275e57de2b0f105e4db055d50dd1c9c934fddf535b8de28b0c74c0449f222cd2ed0bb8fbc775ccee8c940665b40f712f4f7e00750e9e1e4cd9cff25d1945c3e9bca53ccd4f12eee7581856ebd68f26845956e3e7beb761f0fe75bdd31bfe2fa018113397b387bd59d62a68b8af7fa245ab932e69f778e2ceefd21304fbb8099ea13d8ea57c1813197a2f75ae251075b51dad38f853669e9d5f98a3655098941993a1594860fba71fe530ee5c29f58f2978af688ccb75a5838a359c112e98e25a8583ac8dac1f861fd58e2afba5de5a52e020904f5b42bc0874e35befcf3e6119684768f36e008f04712177cebe627607381e56eaaee161c1729b8de51dbde474d48cc68249ea27162b87993e60c84ed6cc6423cb3676d9eb50b2cab5a3a049ef131381d623fa6fbcbc9db1e7cc025ea0418b9dad2cc6ccd4e95fa2cec24feeca70318a751716b7213f63edbf65a63338357f838f94ec071822c24851248885107b3d1c4e924678c7614ea1af038104619f2ae372940becfa69e29cbb5ff6c3e20a47be4a4f74bac34c133c00a6a706accc6ffd3d8e4fbd69a99704e1283c850d8c58d1e5753cd9587b83c4c346cb9a58137213ec10834c66adfe2bb5c501a8ef2ecadd1b677a3df1a6deb86ebf0722c4f5030e20f9018dd5b6fc53eea24fd92b7b5b4025feae996d3e48fd4c650d82dbad7eaf936639698512f26253d2ef6847c8518e8565cc9a5495c6fff57cde7323882c54a7db470ab2daf8ffd2bf794fa7c692d9e7fbd532eecc1d7880e2ca0b3216128be28b4a9f1d151fac97808b0bd98b7b43a612a9ac865812bfeac6f47460277840b52a3b087f916ca7cedc0f768ea2bd19ea21155f84b4a04c4000ad2ae0587154d560bc0a477a4f9329a8984dd31eb1f2a05e3d918701d630cfca9af61ef088d2c5581acb463e439902e5d425719e956b8d6df7305b28e0ff27d3ad0de2085d292499b19a3390d4396fb3bac9a8d8cbead2a7a4290fc9ac6fca045f98a614a45a39cbe24360f84d14f8e472712aceb74dbf45b53d49a0e4737e476ffc4d5b2f7cd247aa186d3b764ad9e9cfeee456a73c291d8de3912414ac43911c372173ad7b472af35c6853ced2fe7b5fe0a89565ab33baa6f65cdd928319d7065e040e7a5e84f9aa903f7648094bad07136b16927b8ec6dbc2bef0cc2856de1e795923e1412c49f24deeb6c21f6c8a9765c9c7986e0da4b4c67d8e0d0c8d466824fb923d8573148990cd2ef133c78ceecab72ed9dd285c5a3766852d54534207ffd34027f6c76ede8fd1a32d72c30048bbaa797d5df6fde27d087de5721ad7b7fa3e8d3f70d6bfc3ab2e252335368bbfa15acb5cb37d4694e8b23cebe25de9c925a221a183b904d3f85df9929a919c54d6f87457373a0d6ecc1403e4cbbe620999435e80696634cd1a8e4747e9825bfa336e5bbad14f73640f1b9febe800dbaefe1630c61fae635b074c564eaa9db189c9e7302873fc64e6d497bc5c29080987a07a21d4af210703a4fa07f2fd816f12fd1e29b4c0f44afe9bd4a1eaa8a7ae6f02a5b4258f52caf6127f62632a67cf4e8310be56a7c28c86b2e277600c3e92c8d23d42586244c571e90568df202f2f6d81f860a565f9eb91a3c78372e2a8b1be61c5418cf49bf2d6c8955d4a482a9919b7660b3f9a4404ffc454ea073e1e4b2689ab2cca4e46bd7004a6c491fa26ee7a57d60f35edb2b821e6266442c8f335d452d524c772e0353724c23c7dd15b7aa155e91442022140c5fcb0153147edcf3e8952f6f0399a3c88066a72756c9409915de63f64fa797841c57c796c6fc550ef745dfe9f179457f94755ae5a2506a764f327e550be3dc14dd41f3b04b147d454938c63a8d69b2ea4c5710ec0b36e3a6c72571fa5d59dde036c42033df35af056966ff0cd1204008971aa6ba9fb97b685ab9ffa2a9d1778104cd2c3b326de1fcbc242e94d0311c3275b12850ed30ceead3a2ee6d060508411d4396f5421d8b6d067cf7cb5e826785fbe119e05e21bd879b64f57cb0cd1972c2815f20abe7ce6ab34d0f471af44baad179e90644122f5f33288e689ddddc5ce833e9755df1e73c65c5a201c4ede2ffa6b19274927719d2d38fdb7a65aa43708b7fa9a94aa7d3210253d78d3b181e1020d0000bd0a1dc05d447f9f58ebeb84c65b36c8afcb83727a1508994e826957a663b0b9b8a003325ab6d6d6462ee4e106019c0dffe10323b7bde7d82a38f85fd08786e860ba66c161b64b0708c363de5c6af62d8db3c243d1e1b712cb1d59e942b9b6b4295a5a500b182cbd5fd1bc6ce9376d91b47a2284f1fbe0ad1c048cc2cfbb4afa3a9eb9697503b69feca990eba7e9441af9ca44cb3ac6b5ed66e591c201fe30efa8a7c471dc613d6254c263a8e132104bec47f1aacb3b2fcd4051b69b5e3fcb1c147a65c2f90c4b5188bafc521cab03c12a309da50b5a7517727ed41228ed123fe1b152f6a6319cd623bf34ad7b8e064ab993260bcbd405f5b7fff9b2fa40ba5ed5630242539e5d96823e89dc818a13d16675ee3079d976f694f5acc9760ae789e9b3391b289e0e22a7ef17cc6a4577157b6d95c09baa4fd532e3ee0a290810ed35e56bb19d9b61fb98a97c617425b06093d98a5cf0ee2dd127f0eea600b9a0c67fbe761db9b77e5d5bba9701da1b883e521a0cfe88451f57bd36085b67e56f061f84a2e6a152a71bce6e522daab6a0a33ce22e537fa9793d28b617e6c0a4176a83aa3be578afac0f2f5547c5516d218984755b7445c7143afa4e551fce0071bdb873b34e6b9e2b9e79ed0c69d288ed6421f237e860a0c6492ebbdd2a44c2c4f368dbe99941b1e8561d859d3859f496cee3d741f252973f8fcc539c409e35cc80a5ed6df23cc3a65601313f5d681fd9540c5291a9e30a72e38c96413c47c61ff84fde78d011b01b4154d1b920af003f7abb1e1999dea6a766cf9fd2702b3ce0ee57af931b62124b0861b163a3b91aa4bea28076c3432df3b29b6c4e1ba588def420071fc157de90eb2722ecc9ab00df3c669383a61a91bb67bd287ce349b4745ee7a479dbceef166b9acc412eb579fcd6437307edda253d606b7be7599c38092bc52a8598480edab8b82b1d21c565d2137ceae0b6642619b16133d91205d6355029e9cdfeb9a28b373d95916b6b707d4c712c09cf36daf1a511b2bedb1aa70ee58d46a0666bb287784b0a3840c589a7a04d5d6f2216be90aa4a512d5632f5c9bfe7b8b13382f999b95d367c7c46b968074ce315197a5ff3545c7b77a804ade56a95b5c24cdece5937b5c0366d93ad03da9bc5db1b551dfb91e9b343d2b57b763439686d4a3","raw_to_be_signed":"846a5369676e6174757265315827a2013831045820d9bc439f97bd6d4093e68f0f3fcf09c9a97adf888ed7308dd565247a166cb4fa40581d68656c6c6f20706f7374207175616e74756d207369676e617475726573","raw_signature":"e132b492fc022d5fd1d2205f52dbf1ad1aaef3ece4622b4e3875696d64d66dccc74df743c1b85552a0f3c1bdaaa8f789a15fdb3ce6329021f5815316cda1da5b012f1ccea4a47ef7e93eff319048ac9e3b6ed46cd58c6557af1b340da3bd7966f1588f8bd88e05383aee12a7248db3aec96ba5d9afd1d79d62865eee04cac9cc2176ae585ae914d614805d916c142b4969be7ad95a44bf9c154d19bd41d8a3882ad6f0b0802d1e037c7579453a0606bbbb31db164fc607646477572c63b71720f8d47bbb7615dd264f5829f726e22740cb3a1e1b5e381c4f692f7ecaa0979ae17aea3139d733491fe213eeddcd5f68e06ee71b80f14ed693f407ce6e199cb3edb048d3e2905ce75b31bd6837a1d4b5eefa35431d0ca407200e60768b2dc5b0370e91a6c03c3d0e5c47225616034f55fb0a30a66fd2074847be3c1230b93650d119492efc20338af0c4cb6a176191d7c5bbc7427f6f0c9bb49a0d73e7f026cf3855547fc7ca9369733313963ffe4647e155a93cfa5403edebfc7842e75dad9ae2accba720487e476ffe3bc0a60cf32271429242023d0d0a8f6e4e77a874afd2074a11ff20bae28d00fd5d990f839ca99c6db28a55da94a785290a6b536893a237224639717ba5cf833d57db2cca0a7aeeed874597c6ee71e0dc35e06851e9d2bd022c37b5fbcd2a4d5e8daea98a44cb9c97df43a0aa512005358c8d5d5db88fba610e47d5a863f53f9ec7a8f3cb0b0ec2f02b1dfe9867a1437e84e941392b149275e868b959c58b9e814fb618c61208cb683881247bb0dcab96e84a77e0195b4e93f693c1e98dcb99e495632f6cd5839d3bcccdfa2bf6b26921759d0a293595a96e6ddd42c83d8d9a7b10a001b34f47c20fd46d1e09100e532e5b1900b89f14400bbcdd5ee0cf61a1ca353398a498da488b0f117effcf999f5aafe4a587deaa3ff78cc431637adcbb4e40ec385fac23e8176b74e0e750460f7d2002bf7465944caa2708835d3849199732090b7c514575311ef9999c9bfcf737a4d906af914d0507f5a7c2e61ff12359999d173f88db9ef85a6d71ac2e3a8074bed9472e00aedac26c48ab9c2d1ad96eeffe6e200686efa17086317a541ffad5c8b5707279aecb12ca48f7e7d755e8cdc2bc990c6391abf9351c2f5305bda2c57bf54e419dce477947a64de07eb0432e5f1cc87234ed673fa810d562095d0d0eed260bef5fc3daed8506756acb9059257b025471d8df2d4e697a3e7c74c47081b569519e1de636a971668a376b4d84d95c30a554894475be8f01bda7c6d78989572aea4c4e976a408b4a04b406e79e176163dcc1ffa7a7e9bd6ef6f854128097aa760ed3d6ad279f7ea4ad6003f43cfb75de79c0bf8113a6f788e35e92f30185695f4abb18e924b29abf218e708977cb2776a7abf46a41e46afe24863eed4fe890916f95d5b1fbe6cea096ceb4ad478fc994214f59c5e68c8b9695c27f8fada32c90ed324925540912da750451f033359177579e4e4a04e5d5b9bfa72616df63df20f17038e7d4bbe61562583046c35e3a71a0f596f119ef183786ed76e1da6d3be98277db1583c8f2c8784c08f5c098abfd31baa9fc6abdc0cc441b6f93961b6630c45b9e7dda60d88be7c9577b6fbc5edf65de4ed0b53f4377ce83b1e55c4d62015569b96ea094644e0f9cbe89cff4c539f77c629a5101c259c56cb9d31e20160dfe28386b37e610c2db9ecf6a000bfb2a85756e585ae6b97915e5113970946df068e6da7f0af0a48802b9e0464bfac7e0c6b7dee953665061ac7486d9eee3bf21137383e97eb393a708e91a94f5012fa1d072c04f5c5ca2bbf894e7b275805fe5d81341d75b9f7fcb89b3ff7da2b623c35d717d0da7180e258384ff39a914c2f30f893af4e1520d64a15bb0997b852f3ec6ca398d245361fc2297c83867f388c8aa35e704d3f7081a961c528751faa64fd7efbcc03bed69e99ae1517d499117227cb08254d7b8aa079530af39fb19b246d45a4d41a9955095636c786dcc3c30c817ee3c8e60689fd9a494c9e774463df7b884a78dfe80f1e247b3f122401b0834e54fdeb57d7835f0409126983e40d8922bbd54981e2b651cc3fdee9193468c041201c2c472749300250628225052f935d37ab9dd8e466b6a3acb63ee93023013443ddeb91347b84eb6ecf37423996d682967a5aab7ccaaa73a690a9cbd45b15ee38f2ae698aac3cb2117ecc9160870993a3e50ea7647b0c03cb5f6daa290492dd693a0b8a9a9759d0d977b662d45c4af5dce95084062f7a0f39d0bc3bdabaa31d8f815047244303e6a6e5d977ec757a56797f0630ef1d4f02f7a0e7680c0865c4404c97bbf7014bfa64d0f89b02a2b981f616c0e16090c4b7fe9998a0232469b0c059d3daff58af6148cee567e52f1a41730b28d6af79358ad3679cf4c3dec0df780eccc91d004a4ec0a4d20cc6771dbb3f42da69dc2140994c228a60adc0e97438a2332db657e91e5b7f5029541d13ce8456d93bb4933522c94e16ee4766af28b5754b5a74c71efb6ff445dc1aed5f3e21cfe512cf9862489582925c763251376279f69e633a43aaadcf4c104744cfca747f477c1af8c18f65109b06680cd392d14e3e0ed0ba117643c8794bc8d036e85222d543063a01f91cb9bceb80d884b1305d0065ef3555d6f64be4893816e0e9be111c65c9cb1840774ddd4ed5906ae2a73c7ca6103ecd3bb506747681e192dd6c259b66dc63c39dbdc33dac1d564b54db4d8de935370abca642e05c6e95ee827fb71d6086691bc6d1be2a0c7e491ce22163d3e8d48541cd7aca76eecc93f4e02ac0179cf7073d5000b3ff338d096bc87eada3f4019fdf1a5d12470a33f65c2990c217680710069de75e0338d1e26c179b1cf9ef853edaaab2c0519026b3d9f574b82b5bd316686b26c9e9e87870041fdfa1a5c538cb98cb39200856b4c9bdacaa1c7717a22b883c06ca0f78229cd59362614361cc5ec76d836d2dafc1ed51066afa7297db869508cc80543b1efcddc62eb7c4ffabe3fafbc02f4802b4992ff0be194ae123880ceee6187e08c7db96b22438ac4bdabadd8aba7af68b8e2ac0c60c0f9aad5e4e1bf886db18212e9cb46d8bc201e4a1a6fb231d91a309dbca30b6b1269ec31bda1bb6b6d8ff41d84baf7db7ac30de5ad5d9259398bbfb5ffbb89cce88a15f0f7413c9c71487b56eea23573697a583fa57d3537588ba5558363d8abd679f2968dbca3ff00b141d68d5baed53fa66480029f46f6b195e3dc99a0e09f990841c62735db8e4b6b867ee416cd946b65fae48acd2c580c5ee461fdc78c2d671abb657f9296f976cb74c0249676a111e7608785ede2acde6f8af297f88ce9ad0e6b4cfdac1c586519042ac700a4178f5c23add6634c26588f47bfb7ec9e244ae8c71382042c24606410ef598ccfcaa459923e748d608caf3c82dd141c8eb32a500a03bb7fbd5d628b625fecd3d9399254d7c569ab1418c6af0db64009f09d457e287b0e8b83bf40d529379464df999d7519a454bb3fa9684f398c965c4f980061d33fd8883c5ad2964806f27fdfb09458b1bb2daaced0fbc0c68d46d62224f63725932b58ae0a694ec7fa0d4e5fcdc75840b467e12514f1cd006befcf3410cf7a5c81adf3d29cd93ef5680c1a953daa0645273d0f5fdc8e6e0f8c632b67a674fb4f390c392720ec6d8e3f1234e84f8420b91217ad71f406b1a4c7f2c438fff7844a097cdfb00a2f1a94ee6bdd597756a754681111d66040c13a661b978440b05ba8c16e2a4255ebff56e5adada6fb92292d501d30e351f4fb5b907d9f1510e9801489da0a3cf4997eed6df4fb06b86f81af3735781513c6654e030a03e358970fa129fdb8cb49365a86f1cdd1a9b5f966794c8bca163c3af148406c24f0e149da338e6a1fb5f365b1a6bf0fe426ec424823588dce11dfe7de3b3aa740d27fac9b6d9c60909b4afe2f88dde858069e330e6f9a7ecc779022d3925ea0bd73e67041945e04691152683453f3126cb3699b607dd598af05fd441c157bb3b8d69243705cd1e71442b502b7ea987c8837a3bd896e5bf2796052a23d302c70b23a62383278e1f3c878c2bdcb68524c078fc73148f227951566c19248240d972d5547350909c63d6f505ad889884fe9710154d2ec05aa15a4f734e5b88480916ec73e1518fbd2605954580ec2b0a8f9c4bd4d075461b6b3015c344e83382c36b161e57a6c3933e98209ce308190531f85f5d5fd9451d37f40f6f36af830b376ed2d48ab20b2b58b7c6e5956b7d4142b19ccb19a88db70e5829047751950e2975bb4d0e9991fc3bbc4fa5adf2d9d1e25e4ded5396731bd2808b8227a30233cc7a1ab7759623357547e46060a4c6c54a8d24116680186ca97291afd2be4ffdc9bea1c4b81ad80a3e7be17fb5585eeb72dfc030655040993fe12f58d21d3ea499c1ecac70725f2e133450e9c1e75ce657300f85ae0f44e470336dbd5df32fbc0a8ffbe3c66058e45ac5fb0ea3889313214b6b2ec98a91e6414b3dda04d9c41857707bbdcf4763ee3846c7f4df034e1dc8fefc8a2a5dcda9f91940cdcd1f7b98b93c08bc9f1c198e80fcde8a5effe4ea4363a56cae57de4a7248fd8bde5767f1ae699b5bd998d9f613306346472e96954dc32baccd31c3f44b0f11b8e6810bb3f27af7de6a57550288f56015b1b76f1c1e492d5b998493b72a38ba4f2619b891aeccfd96c27fe80b958e08728581ca4d6e7da5f2760b48734a049e8fb29aa6ff373911d712091ef6bbed204da3b1237c1195654c7f66aa6776e950e7a27aed6c9a4ffebe76671ff1dea7b1ee5d0434c976d2d23bb481b6d80d242a2c942329dbe051396d0a9add08068634f6c7f8aadfd55e4109cde60f693229f605d71f896f5e1c9d3b94fb9a497a9b955960307811296f2ed263ad57780c6f2f96b42eb71e817bbed0654000c6bc20d3087f7971b8d517c00cdf9732294285c6faa24b405e3b31e6fb856b57aabae81e72a8876f06cc0fbda5ca4479a5cecdee7b5bd0fff8f8c788e2d803dad28ca110f6013672323540b94eb5a7638116cfec790f2d899d7f6bc075cbb78ad925845bc75b8086078356b0c6dc722283e774cb7a5ee24a6b976ca6dcc40fef3ea10e77cc50a0523ab4df32971a19c9c6e889edb99ea9d863e7f9922d02303b80a29899397f4abacc5ecf6da98574e2ee2afcd18077063d7419e4ee361891085217c905f6cefb2041ee6f49df051511152f45609a0d06d951b0351431651bde5b5434c06b146509727cd5b76f182c1353ee94e6ef98901cdba4b6cfc1dda01628ff86b21e2be53da4a8c2c9fc1b50b28ada2836959ed1398c70018b5f3c35d9f3c8768af0966a0e8ee6b16dd17455acecd377fd259379e7e22f187876db740c3c09a0307891484f9b12da66916d9d4018ac34b9d70a094a655ece0282839a9e60b8cea041316803b262a4928d375889017f3da58b9721ac9d7a4e69b06fb26d46a904b062728286ee2e44c18354be39252482e5135fbfd1ea4f85dfc96b63e0fc8815a3a0f1be7476e60712a566911663159e74838f27a0068b2131aec8653b5f697ede4dd8c769234ba5d018ede320aeace49e263844b93d3c2410af9c5df83c0e7eb617930eea8a272e16a6d58e6ce1ce9a3b42ad94436abe73e01b95839038d5430676ef6a7a3c77cd541fe860d40bd9414133a8983280e139161d85dc0c395eac1ffc6fe52d637fd5f327d112f8ed15172925b0a21334d8b5dbeaea1bdbfcf9bcb8c9bd72b58aa6745f07fae50343191f90983e4d138a278f46433a8c404565c4d15a55c4d61f396444b639bf7191515c558155bf2a09576e7b3376236a23baeb2f7826e1b5e95e100bae7d0b9226864839d04f2145cf0a0c0dbb0194f2224aa63cb144a0038cd63ac6b42bd9c74e7d1eff9cc1419043a8bbec602e5665d45ddfa09c1831c0c04fa116ff8ad7fd93a0d005dedb329407c84b809d4552c6e31174ca01f7fe336dd1759b3d4bafdcf5df63f5bca512caf29a4e645e5315c0777478f1640afa2d30f2e8f5293571d3b94f65be0cc98f24261633ae9b0a44e6048c35ce44eca75d6362ce6b5698806addecf26d1928a2ac14939923ca63d54afa103e7a1c8f23fa9880e381d17ee89b4e65922ebe81d58b3dfe637f554f0dbb499073cb4ca9c2ee30a6bd0c5fc56675e0215a8b4577dd6c1490087bf4b2039b5b52ad49c08212a8171d0cb6fb4f84855cf426d36147f5e46fd3f7722481ce26b0511d6603087fbb9d1382a69011de7aedadd0d29b7c0ffeae5640c8079acf8818de222664728df79967e54962537fa06bde323de630bf63ae92e57e33f242fd508e767da3f2dfa14c0726b12bce09c4310502807ef00b88afbe76ff01a941ce512504c25d552e8863afa3c89bdb53d757d09161a5e65b4c1d6f4fe00879d1e247d8a93f30b252d5d6d7308676a76c4d9ee11131b2c2f7f8092b0d2e417225f6d7c82a4eff0f7204765a4bcd2dafd00000000000000000000000000000a0d1319202b353d","sign1":"d2845827a2013831045820d9bc439f97bd6d4093e68f0f3fcf09c9a97adf888ed7308dd565247a166cb4faa0581d68656c6c6f20706f7374207175616e74756d207369676e617475726573591213e132b492fc022d5fd1d2205f52dbf1ad1aaef3ece4622b4e3875696d64d66dccc74df743c1b85552a0f3c1bdaaa8f789a15fdb3ce6329021f5815316cda1da5b012f1ccea4a47ef7e93eff319048ac9e3b6ed46cd58c6557af1b340da3bd7966f1588f8bd88e05383aee12a7248db3aec96ba5d9afd1d79d62865eee04cac9cc2176ae585ae914d614805d916c142b4969be7ad95a44bf9c154d19bd41d8a3882ad6f0b0802d1e037c7579453a0606bbbb31db164fc607646477572c63b71720f8d47bbb7615dd264f5829f726e22740cb3a1e1b5e381c4f692f7ecaa0979ae17aea3139d733491fe213eeddcd5f68e06ee71b80f14ed693f407ce6e199cb3edb048d3e2905ce75b31bd6837a1d4b5eefa35431d0ca407200e60768b2dc5b0370e91a6c03c3d0e5c47225616034f55fb0a30a66fd2074847be3c1230b93650d119492efc20338af0c4cb6a176191d7c5bbc7427f6f0c9bb49a0d73e7f026cf3855547fc7ca9369733313963ffe4647e155a93cfa5403edebfc7842e75dad9ae2accba720487e476ffe3bc0a60cf32271429242023d0d0a8f6e4e77a874afd2074a11ff20bae28d00fd5d990f839ca99c6db28a55da94a785290a6b536893a237224639717ba5cf833d57db2cca0a7aeeed874597c6ee71e0dc35e06851e9d2bd022c37b5fbcd2a4d5e8daea98a44cb9c97df43a0aa512005358c8d5d5db88fba610e47d5a863f53f9ec7a8f3cb0b0ec2f02b1dfe9867a1437e84e941392b149275e868b959c58b9e814fb618c61208cb683881247bb0dcab96e84a77e0195b4e93f693c1e98dcb99e495632f6cd5839d3bcccdfa2bf6b26921759d0a293595a96e6ddd42c83d8d9a7b10a001b34f47c20fd46d1e09100e532e5b1900b89f14400bbcdd5ee0cf61a1ca353398a498da488b0f117effcf999f5aafe4a587deaa3ff78cc431637adcbb4e40ec385fac23e8176b74e0e750460f7d2002bf7465944caa2708835d3849199732090b7c514575311ef9999c9bfcf737a4d906af914d0507f5a7c2e61ff12359999d173f88db9ef85a6d71ac2e3a8074bed9472e00aedac26c48ab9c2d1ad96eeffe6e200686efa17086317a541ffad5c8b5707279aecb12ca48f7e7d755e8cdc2bc990c6391abf9351c2f5305bda2c57bf54e419dce477947a64de07eb0432e5f1cc87234ed673fa810d562095d0d0eed260bef5fc3daed8506756acb9059257b025471d8df2d4e697a3e7c74c47081b569519e1de636a971668a376b4d84d95c30a554894475be8f01bda7c6d78989572aea4c4e976a408b4a04b406e79e176163dcc1ffa7a7e9bd6ef6f854128097aa760ed3d6ad279f7ea4ad6003f43cfb75de79c0bf8113a6f788e35e92f30185695f4abb18e924b29abf218e708977cb2776a7abf46a41e46afe24863eed4fe890916f95d5b1fbe6cea096ceb4ad478fc994214f59c5e68c8b9695c27f8fada32c90ed324925540912da750451f033359177579e4e4a04e5d5b9bfa72616df63df20f17038e7d4bbe61562583046c35e3a71a0f596f119ef183786ed76e1da6d3be98277db1583c8f2c8784c08f5c098abfd31baa9fc6abdc0cc441b6f93961b6630c45b9e7dda60d88be7c9577b6fbc5edf65de4ed0b53f4377ce83b1e55c4d62015569b96ea094644e0f9cbe89cff4c539f77c629a5101c259c56cb9d31e20160dfe28386b37e610c2db9ecf6a000bfb2a85756e585ae6b97915e5113970946df068e6da7f0af0a48802b9e0464bfac7e0c6b7dee953665061ac7486d9eee3bf21137383e97eb393a708e91a94f5012fa1d072c04f5c5ca2bbf894e7b275805fe5d81341d75b9f7fcb89b3ff7da2b623c35d717d0da7180e258384ff39a914c2f30f893af4e1520d64a15bb0997b852f3ec6ca398d245361fc2297c83867f388c8aa35e704d3f7081a961c528751faa64fd7efbcc03bed69e99ae1517d499117227cb08254d7b8aa079530af39fb19b246d45a4d41a9955095636c786dcc3c30c817ee3c8e60689fd9a494c9e774463df7b884a78dfe80f1e247b3f122401b0834e54fdeb57d7835f0409126983e40d8922bbd54981e2b651cc3fdee9193468c041201c2c472749300250628225052f935d37ab9dd8e466b6a3acb63ee93023013443ddeb91347b84eb6ecf37423996d682967a5aab7ccaaa73a690a9cbd45b15ee38f2ae698aac3cb2117ecc9160870993a3e50ea7647b0c03cb5f6daa290492dd693a0b8a9a9759d0d977b662d45c4af5dce95084062f7a0f39d0bc3bdabaa31d8f815047244303e6a6e5d977ec757a56797f0630ef1d4f02f7a0e7680c0865c4404c97bbf7014bfa64d0f89b02a2b981f616c0e16090c4b7fe9998a0232469b0c059d3daff58af6148cee567e52f1a41730b28d6af79358ad3679cf4c3dec0df780eccc91d004a4ec0a4d20cc6771dbb3f42da69dc2140994c228a60adc0e97438a2332db657e91e5b7f5029541d13ce8456d93bb4933522c94e16ee4766af28b5754b5a74c71efb6ff445dc1aed5f3e21cfe512cf9862489582925c763251376279f69e633a43aaadcf4c104744cfca747f477c1af8c18f65109b06680cd392d14e3e0ed0ba117643c8794bc8d036e85222d543063a01f91cb9bceb80d884b1305d0065ef3555d6f64be4893816e0e9be111c65c9cb1840774ddd4ed5906ae2a73c7ca6103ecd3bb506747681e192dd6c259b66dc63c39dbdc33dac1d564b54db4d8de935370abca642e05c6e95ee827fb71d6086691bc6d1be2a0c7e491ce22163d3e8d48541cd7aca76eecc93f4e02ac0179cf7073d5000b3ff338d096bc87eada3f4019fdf1a5d12470a33f65c2990c217680710069de75e0338d1e26c179b1cf9ef853edaaab2c0519026b3d9f574b82b5bd316686b26c9e9e87870041fdfa1a5c538cb98cb39200856b4c9bdacaa1c7717a22b883c06ca0f78229cd59362614361cc5ec76d836d2dafc1ed51066afa7297db869508cc80543b1efcddc62eb7c4ffabe3fafbc02f4802b4992ff0be194ae123880ceee6187e08c7db96b22438ac4bdabadd8aba7af68b8e2ac0c60c0f9aad5e4e1bf886db18212e9cb46d8bc201e4a1a6fb231d91a309dbca30b6b1269ec31bda1bb6b6d8ff41d84baf7db7ac30de5ad5d9259398bbfb5ffbb89cce88a15f0f7413c9c71487b56eea23573697a583fa57d3537588ba5558363d8abd679f2968dbca3ff00b141d68d5baed53fa66480029f46f6b195e3dc99a0e09f990841c62735db8e4b6b867ee416cd946b65fae48acd2c580c5ee461fdc78c2d671abb657f9296f976cb74c0249676a111e7608785ede2acde6f8af297f88ce9ad0e6b4cfdac1c586519042ac700a4178f5c23add6634c26588f47bfb7ec9e244ae8c71382042c24606410ef598ccfcaa459923e748d608caf3c82dd141c8eb32a500a03bb7fbd5d628b625fecd3d9399254d7c569ab1418c6af0db64009f09d457e287b0e8b83bf40d529379464df999d7519a454bb3fa9684f398c965c4f980061d33fd8883c5ad2964806f27fdfb09458b1bb2daaced0fbc0c68d46d62224f63725932b58ae0a694ec7fa0d4e5fcdc75840b467e12514f1cd006befcf3410cf7a5c81adf3d29cd93ef5680c1a953daa0645273d0f5fdc8e6e0f8c632b67a674fb4f390c392720ec6d8e3f1234e84f8420b91217ad71f406b1a4c7f2c438fff7844a097cdfb00a2f1a94ee6bdd597756a754681111d66040c13a661b978440b05ba8c16e2a4255ebff56e5adada6fb92292d501d30e351f4fb5b907d9f1510e9801489da0a3cf4997eed6df4fb06b86f81af3735781513c6654e030a03e358970fa129fdb8cb49365a86f1cdd1a9b5f966794c8bca163c3af148406c24f0e149da338e6a1fb5f365b1a6bf0fe426ec424823588dce11dfe7de3b3aa740d27fac9b6d9c60909b4afe2f88dde858069e330e6f9a7ecc779022d3925ea0bd73e67041945e04691152683453f3126cb3699b607dd598af05fd441c157bb3b8d69243705cd1e71442b502b7ea987c8837a3bd896e5bf2796052a23d302c70b23a62383278e1f3c878c2bdcb68524c078fc73148f227951566c19248240d972d5547350909c63d6f505ad889884fe9710154d2ec05aa15a4f734e5b88480916ec73e1518fbd2605954580ec2b0a8f9c4bd4d075461b6b3015c344e83382c36b161e57a6c3933e98209ce308190531f85f5d5fd9451d37f40f6f36af830b376ed2d48ab20b2b58b7c6e5956b7d4142b19ccb19a88db70e5829047751950e2975bb4d0e9991fc3bbc4fa5adf2d9d1e25e4ded5396731bd2808b8227a30233cc7a1ab7759623357547e46060a4c6c54a8d24116680186ca97291afd2be4ffdc9bea1c4b81ad80a3e7be17fb5585eeb72dfc030655040993fe12f58d21d3ea499c1ecac70725f2e133450e9c1e75ce657300f85ae0f44e470336dbd5df32fbc0a8ffbe3c66058e45ac5fb0ea3889313214b6b2ec98a91e6414b3dda04d9c41857707bbdcf4763ee3846c7f4df034e1dc8fefc8a2a5dcda9f91940cdcd1f7b98b93c08bc9f1c198e80fcde8a5effe4ea4363a56cae57de4a7248fd8bde5767f1ae699b5bd998d9f613306346472e96954dc32baccd31c3f44b0f11b8e6810bb3f27af7de6a57550288f56015b1b76f1c1e492d5b998493b72a38ba4f2619b891aeccfd96c27fe80b958e08728581ca4d6e7da5f2760b48734a049e8fb29aa6ff373911d712091ef6bbed204da3b1237c1195654c7f66aa6776e950e7a27aed6c9a4ffebe76671ff1dea7b1ee5d0434c976d2d23bb481b6d80d242a2c942329dbe051396d0a9add08068634f6c7f8aadfd55e4109cde60f693229f605d71f896f5e1c9d3b94fb9a497a9b955960307811296f2ed263ad57780c6f2f96b42eb71e817bbed0654000c6bc20d3087f7971b8d517c00cdf9732294285c6faa24b405e3b31e6fb856b57aabae81e72a8876f06cc0fbda5ca4479a5cecdee7b5bd0fff8f8c788e2d803dad28ca110f6013672323540b94eb5a7638116cfec790f2d899d7f6bc075cbb78ad925845bc75b8086078356b0c6dc722283e774cb7a5ee24a6b976ca6dcc40fef3ea10e77cc50a0523ab4df32971a19c9c6e889edb99ea9d863e7f9922d02303b80a29899397f4abacc5ecf6da98574e2ee2afcd18077063d7419e4ee361891085217c905f6cefb2041ee6f49df051511152f45609a0d06d951b0351431651bde5b5434c06b146509727cd5b76f182c1353ee94e6ef98901cdba4b6cfc1dda01628ff86b21e2be53da4a8c2c9fc1b50b28ada2836959ed1398c70018b5f3c35d9f3c8768af0966a0e8ee6b16dd17455acecd377fd259379e7e22f187876db740c3c09a0307891484f9b12da66916d9d4018ac34b9d70a094a655ece0282839a9e60b8cea041316803b262a4928d375889017f3da58b9721ac9d7a4e69b06fb26d46a904b062728286ee2e44c18354be39252482e5135fbfd1ea4f85dfc96b63e0fc8815a3a0f1be7476e60712a566911663159e74838f27a0068b2131aec8653b5f697ede4dd8c769234ba5d018ede320aeace49e263844b93d3c2410af9c5df83c0e7eb617930eea8a272e16a6d58e6ce1ce9a3b42ad94436abe73e01b95839038d5430676ef6a7a3c77cd541fe860d40bd9414133a8983280e139161d85dc0c395eac1ffc6fe52d637fd5f327d112f8ed15172925b0a21334d8b5dbeaea1bdbfcf9bcb8c9bd72b58aa6745f07fae50343191f90983e4d138a278f46433a8c404565c4d15a55c4d61f396444b639bf7191515c558155bf2a09576e7b3376236a23baeb2f7826e1b5e95e100bae7d0b9226864839d04f2145cf0a0c0dbb0194f2224aa63cb144a0038cd63ac6b42bd9c74e7d1eff9cc1419043a8bbec602e5665d45ddfa09c1831c0c04fa116ff8ad7fd93a0d005dedb329407c84b809d4552c6e31174ca01f7fe336dd1759b3d4bafdcf5df63f5bca512caf29a4e645e5315c0777478f1640afa2d30f2e8f5293571d3b94f65be0cc98f24261633ae9b0a44e6048c35ce44eca75d6362ce6b5698806addecf26d1928a2ac14939923ca63d54afa103e7a1c8f23fa9880e381d17ee89b4e65922ebe81d58b3dfe637f554f0dbb499073cb4ca9c2ee30a6bd0c5fc56675e0215a8b4577dd6c1490087bf4b2039b5b52ad49c08212a8171d0cb6fb4f84855cf426d36147f5e46fd3f7722481ce26b0511d6603087fbb9d1382a69011de7aedadd0d29b7c0ffeae5640c8079acf8818de222664728df79967e54962537fa06bde323de630bf63ae92e57e33f242fd508e767da3f2dfa14c0726b12bce09c4310502807ef00b88afbe76ff01a941ce512504c25d552e8863afa3c89bdb53d757d09161a5e65b4c1d6f4fe00879d1e247d8a93f30b252d5d6d7308676a76c4d9ee11131b2c2f7f8092b0d2e417225f6d7c82a4eff0f7204765a4bcd2dafd00000000000000000000000000000a0d1319202b353d"} diff --git a/test/fixtures/cose/rfc9964_a2_ml_dsa_87.json.license b/test/fixtures/cose/rfc9964_a2_ml_dsa_87.json.license new file mode 100644 index 0000000..a406bc0 --- /dev/null +++ b/test/fixtures/cose/rfc9964_a2_ml_dsa_87.json.license @@ -0,0 +1,7 @@ +SPDX-FileCopyrightText: 2026 IETF Trust and the persons identified as the document authors (RFC 9964) +SPDX-License-Identifier: BSD-3-Clause + +Extracted 2026-10-20 by Sudo Apt Holdings LLC from RFC 9964, Appendix A.2, Figure 8, by +tools/vectors_cose.sh, which pins the RFC text by sha256; the values are unchanged, the +`_diag` renderings dropped. Code Components extracted from an IETF document are provided +under the Revised BSD License, per Section 4.e of the IETF Trust Legal Provisions. diff --git a/test/support/strict_cbor.exs b/test/support/strict_cbor.exs new file mode 100644 index 0000000..d507ed5 --- /dev/null +++ b/test/support/strict_cbor.exs @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 + +defmodule BeamMCP.Signer.Test.StrictCBOR do + @moduledoc false + # A strict decoder for N6's encoding rules (E1-E8, E10), for the tests only. It judges + # canonical form as it reads -- every head the shortest for its argument, every length + # definite, every map's keys unsigned and in ascending order of their encoded bytes, every + # text valid UTF-8 in NFC -- rather than by re-encoding with the encoder under test, so a + # defect in that encoder cannot pass as canonical here. Values come back in the encoder's + # own form: integers, `{:bytes, b}`, text as a binary, lists, maps, true, false, nil. + + import Bitwise + + def decode(bytes) when is_binary(bytes) do + case item(bytes) do + {:ok, value, <<>>} -> {:ok, value} + {:ok, _value, _rest} -> {:error, :trailing_bytes} + {:error, _} = error -> error + end + end + + defp item(<>) do + with {:ok, arg, rest} <- argument(info, rest) do + value(major, arg, rest) + end + end + + defp item(<<>>), do: {:error, :truncated} + + defp argument(info, rest) when info < 24, do: {:ok, info, rest} + defp argument(24, <>) when n >= 24, do: {:ok, n, rest} + defp argument(25, <>) when n > 0xFF, do: {:ok, n, rest} + defp argument(26, <>) when n > 0xFFFF, do: {:ok, n, rest} + defp argument(27, <>) when n > 0xFFFFFFFF, do: {:ok, n, rest} + defp argument(info, _rest) when info in 24..27, do: {:error, :noncanonical_or_truncated} + defp argument(31, _rest), do: {:error, :indefinite_length} + defp argument(_info, _rest), do: {:error, :malformed_head} + + defp value(0, n, rest), do: {:ok, n, rest} + defp value(1, n, _rest) when n >= 1 <<< 63, do: {:error, :int_out_of_range} + defp value(1, n, rest), do: {:ok, -1 - n, rest} + + defp value(2, n, rest) do + with {:ok, b, rest} <- take(rest, n), do: {:ok, {:bytes, b}, rest} + end + + defp value(3, n, rest) do + with {:ok, t, rest} <- take(rest, n) do + cond do + not String.valid?(t) -> {:error, :invalid_utf8} + :unicode.characters_to_nfc_binary(t) != t -> {:error, :not_nfc} + true -> {:ok, t, rest} + end + end + end + + defp value(4, n, rest), do: array(n, rest, []) + defp value(5, n, rest), do: map(n, rest, nil, %{}) + defp value(6, _n, _rest), do: {:error, :tag_forbidden} + # Major 7: the argument has been read already, so 20, 21 and 22 arrive here as values. + defp value(7, 20, rest), do: {:ok, false, rest} + defp value(7, 21, rest), do: {:ok, true, rest} + defp value(7, 22, rest), do: {:ok, nil, rest} + defp value(7, _n, _rest), do: {:error, :float_or_simple_forbidden} + + defp take(rest, n) when byte_size(rest) >= n, + do: {:ok, binary_part(rest, 0, n), binary_part(rest, n, byte_size(rest) - n)} + + defp take(_rest, _n), do: {:error, :truncated} + + defp array(0, rest, acc), do: {:ok, Enum.reverse(acc), rest} + + defp array(n, rest, acc) do + with {:ok, v, rest} <- item(rest), do: array(n - 1, rest, [v | acc]) + end + + defp map(0, rest, _last, acc), do: {:ok, acc, rest} + + defp map(n, rest, last, acc) do + with {:ok, key, after_key} <- item(rest), + :ok <- uint_key(key), + key_bytes = binary_part(rest, 0, byte_size(rest) - byte_size(after_key)), + :ok <- ascending(last, key_bytes), + {:ok, v, rest} <- item(after_key) do + map(n - 1, rest, key_bytes, Map.put(acc, key, v)) + end + end + + defp uint_key(k) when is_integer(k) and k >= 0 and k < 1 <<< 32, do: :ok + defp uint_key(_), do: {:error, :key_not_uint} + + defp ascending(nil, _key), do: :ok + defp ascending(last, key) when last < key, do: :ok + defp ascending(last, last), do: {:error, :duplicate_key} + defp ascending(_last, _key), do: {:error, :noncanonical} +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 5418a50..f6d363d 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -12,3 +12,5 @@ if System.get_env("MLDSA_REQUIRED") == "1" and not mldsa87? do end ExUnit.start(exclude: if(mldsa87?, do: [:no_mldsa87], else: [:mldsa87])) + +Code.require_file("support/strict_cbor.exs", __DIR__) diff --git a/tools/cross_verify/go/carrier.go b/tools/cross_verify/go/carrier.go new file mode 100644 index 0000000..977574c --- /dev/null +++ b/tools/cross_verify/go/carrier.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +// SPDX-License-Identifier: Apache-2.0 + +package main + +// Verifies the N6 carrier BeamMCP.Signer.COSE made over the same message (carrier.cbor), read +// with fxamacker/cbor, a CBOR implementation that shares nothing with the Elixir encoder: +// - the carrier, the protected header and the body are each core-deterministic CBOR (the +// library decodes strictly, then its own RFC 8949 §4.2.1 encoder must give back the same +// bytes); +// - the carrier is {0: 0xFFFF0003, 1: body, 2: [protected, {}, null, signature]}; +// - the protected header is {1: -50, 4: kid, 16: typ} and kid is SHA-384 over the RFC 9679 +// thumbprint input of the public key; +// - the body names the message by SHA-384 and length; +// - the signature verifies over ToBeSigned ["Signature1", protected, h'', body], built here, +// and not once alg, kid or typ in the protected header is changed. + +import ( + "bytes" + "crypto/mldsa" + "crypto/sha512" + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +const ( + singleSchema = 0xFFFF0003 + schema = 0x85000101 + typ = "application/vnd.example.b85.connectome.v0+cose" +) + +var ( + strict = mustDec(cbor.DecOptions{ + DupMapKey: cbor.DupMapKeyEnforcedAPF, + IndefLength: cbor.IndefLengthForbidden, + TagsMd: cbor.TagsForbidden, + IntDec: cbor.IntDecConvertSigned, + }) + deterministic = mustEnc(cbor.CoreDetEncOptions()) + thumbprintHead = []byte{0xa3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0a, 0x20} +) + +func mustDec(o cbor.DecOptions) cbor.DecMode { + m, err := o.DecMode() + if err != nil { + panic(err) + } + return m +} + +func mustEnc(o cbor.EncOptions) cbor.EncMode { + m, err := o.EncMode() + if err != nil { + panic(err) + } + return m +} + +// canonical decodes b strictly into a generic value and requires the library's own +// deterministic encoding of that value to be b again. +func canonical(name string, b []byte) any { + var v any + if err := strict.Unmarshal(b, &v); err != nil { + fail("%s: %v", name, err) + } + again, err := deterministic.Marshal(v) + if err != nil || !bytes.Equal(again, b) { + fail("%s: not core-deterministic CBOR", name) + } + return v +} + +func asMap(name string, v any, keys ...int64) map[int64]any { + m, ok := v.(map[any]any) + if !ok || len(m) != len(keys) { + fail("%s: not a map of %d entries", name, len(keys)) + } + out := map[int64]any{} + for k, val := range m { + ki, ok := k.(int64) + if !ok { + fail("%s: key %v is not an integer", name, k) + } + out[ki] = val + } + for _, k := range keys { + if _, ok := out[k]; !ok { + fail("%s: no key %d", name, k) + } + } + return out +} + +func toBeSigned(protected, body []byte) []byte { + tbs, err := deterministic.Marshal([]any{"Signature1", protected, []byte{}, body}) + if err != nil { + fail("ToBeSigned: %v", err) + } + return tbs +} + +func verifyCarrier(pk *mldsa.PublicKey, publicKey, message, carrierBytes []byte) { + c := asMap("carrier", canonical("carrier", carrierBytes), 0, 1, 2) + if c[0] != int64(singleSchema) { + fail("carrier key 0 is %v, not 0xFFFF0003", c[0]) + } + body, ok := c[1].([]byte) + sign1, ok2 := c[2].([]any) + if !ok || !ok2 || len(sign1) != 4 { + fail("carrier: 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("Sign1: not [protected bstr, {}, null, signature bstr]") + } + fmt.Println("ok the carrier is {0: 0xFFFF0003, 1: body, 2: [protected, {}, null, signature]}, core-deterministic") + + h := asMap("protected header", canonical("protected header", protected), 1, 4, 16) + kid := sha512.Sum384(append(bytes.Clone(thumbprintHead), publicKey...)) + if h[1] != int64(-50) || !bytes.Equal(h[4].([]byte), kid[:]) || h[16] != typ { + fail("protected header: not {1: -50, 4: SHA-384 thumbprint, 16: %q}", typ) + } + fmt.Println("ok protected {1: -50, 4: kid, 16: typ}; kid is SHA-384 of the RFC 9679 thumbprint input") + + b := asMap("body", canonical("body", body), 0, 1, 2) + ref := asMap("body key 2", b[2], 0, 1, 2) + digest, ok := ref[0].([]any) + sum := sha512.Sum384(message) + if b[0] != int64(schema) || !ok || len(digest) != 2 || digest[0] != int64(-43) || + !bytes.Equal(digest[1].([]byte), sum[:]) || ref[1] != int64(len(message)) || ref[2] != "application/json" { + fail("body: does not name the message by SHA-384 and length under schema 0x85000101") + } + fmt.Println("ok the body names the message: [-43, SHA-384], its length, application/json") + + if err := mldsa.Verify(pk, toBeSigned(protected, body), signature, nil); err != nil { + fail("the carrier's signature does not verify over ToBeSigned: %v", err) + } + fmt.Println("ok the signature verifies over ToBeSigned built here") + + for _, changed := range []map[int64]any{ + {1: int64(-49), 4: h[4], 16: h[16]}, + {1: h[1], 4: append([]byte{h[4].([]byte)[0] ^ 1}, h[4].([]byte)[1:]...), 16: h[16]}, + {1: h[1], 4: h[4], 16: "application/vnd.example.b85.other.v0+cose"}, + } { + altered, _ := deterministic.Marshal(changed) + if mldsa.Verify(pk, toBeSigned(altered, body), signature, nil) == nil { + fail("the signature also verifies with the protected header changed") + } + } + fmt.Println("ok it does not verify with alg, kid or typ changed") +} diff --git a/tools/cross_verify/go/go.mod b/tools/cross_verify/go/go.mod index b6d5df4..9e827ea 100644 --- a/tools/cross_verify/go/go.mod +++ b/tools/cross_verify/go/go.mod @@ -4,3 +4,7 @@ module beam_mcp_signer/cross_verify go 1.27 + +require github.com/fxamacker/cbor/v2 v2.9.6 + +require github.com/x448/float16 v0.8.4 // indirect diff --git a/tools/cross_verify/go/go.sum b/tools/cross_verify/go/go.sum new file mode 100644 index 0000000..1e9728a --- /dev/null +++ b/tools/cross_verify/go/go.sum @@ -0,0 +1,4 @@ +github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs= +github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= diff --git a/tools/cross_verify/go/go.sum.license b/tools/cross_verify/go/go.sum.license new file mode 100644 index 0000000..f1cc249 --- /dev/null +++ b/tools/cross_verify/go/go.sum.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +SPDX-License-Identifier: Apache-2.0 diff --git a/tools/cross_verify/go/main.go b/tools/cross_verify/go/main.go index 72f2891..792a665 100644 --- a/tools/cross_verify/go/main.go +++ b/tools/cross_verify/go/main.go @@ -62,4 +62,6 @@ func main() { fail("the public key is not the one the seed derives") } fmt.Println("ok the public key is the one the seed derives") + + verifyCarrier(pk, encoded, message, read(dir, "carrier.cbor")) } diff --git a/tools/cross_verify/rust/Cargo.lock b/tools/cross_verify/rust/Cargo.lock index 40a5918..d465843 100644 --- a/tools/cross_verify/rust/Cargo.lock +++ b/tools/cross_verify/rust/Cargo.lock @@ -44,6 +44,33 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cmake" version = "0.1.58" @@ -53,13 +80,31 @@ dependencies = [ "cc", ] +[[package]] +name = "coset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" +dependencies = [ + "ciborium", + "ciborium-io", +] + [[package]] name = "cross_verify" version = "0.1.0" dependencies = [ "aws-lc-rs", + "ciborium", + "coset", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "dunce" version = "1.0.5" @@ -89,6 +134,17 @@ dependencies = [ "r-efi", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -111,24 +167,120 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + [[package]] name = "untrusted" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.1" diff --git a/tools/cross_verify/rust/Cargo.toml b/tools/cross_verify/rust/Cargo.toml index 856f8c5..e1f6b8e 100644 --- a/tools/cross_verify/rust/Cargo.toml +++ b/tools/cross_verify/rust/Cargo.toml @@ -11,3 +11,5 @@ publish = false # pins every crate. [dependencies] aws-lc-rs = "=1.18.2" +ciborium = "=0.2.2" +coset = "=0.4.2" diff --git a/tools/cross_verify/rust/src/carrier.rs b/tools/cross_verify/rust/src/carrier.rs new file mode 100644 index 0000000..57b0b40 --- /dev/null +++ b/tools/cross_verify/rust/src/carrier.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +// SPDX-License-Identifier: Apache-2.0 + +//! Verifies the N6 carrier BeamMCP.Signer.COSE made (carrier.cbor) with coset, a COSE library +//! that shares nothing with the Elixir encoder, ciborium for the carrier map and the body, and +//! aws-lc-rs for ML-DSA-87: coset reads the Sign1, names its algorithm and builds ToBeSigned +//! over the detached body; the kid is SHA-384 over the RFC 9679 thumbprint input; the body names +//! the message by SHA-384 and length; and the signature does not verify once the protected +//! header is changed. + +use aws_lc_rs::digest::{digest, SHA384}; +use aws_lc_rs::signature::{UnparsedPublicKey, ML_DSA_87}; +use ciborium::value::{Integer, Value}; +use coset::{iana, Algorithm, CborSerializable, CoseSign1}; + +use crate::fail; + +const SINGLE_SCHEMA: u64 = 0xFFFF0003; +const SCHEMA: u64 = 0x85000101; +const TYP: &str = "application/vnd.example.b85.connectome.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 entries(name: &str, v: &Value) -> Vec<(u64, Value)> { + let map = v + .as_map() + .unwrap_or_else(|| fail(&format!("{name}: not a map"))); + map.iter() + .map(|(k, v)| { + let k = k + .as_integer() + .and_then(|i| u64::try_from(i).ok()) + .unwrap_or_else(|| fail(&format!("{name}: a key is not an unsigned integer"))); + (k, v.clone()) + }) + .collect() +} + +fn get(name: &str, entries: &[(u64, Value)], key: u64) -> Value { + entries + .iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| fail(&format!("{name}: no key {key}"))) +} + +fn int(v: &Value) -> i128 { + v.as_integer().map(i128::from).unwrap_or(i128::MIN) +} + +pub fn verify(public_key: &[u8], message: &[u8], carrier: &[u8]) { + let c = entries("carrier", &decode("carrier", carrier)); + if c.len() != 3 || int(&get("carrier", &c, 0)) != i128::from(SINGLE_SCHEMA) { + fail("carrier: not {0: 0xFFFF0003, 1: body, 2: Sign1}"); + } + let body = get("carrier", &c, 1) + .into_bytes() + .unwrap_or_else(|_| fail("carrier: key 1 is not a byte string")); + + let mut sign1_bytes = Vec::new(); + ciborium::ser::into_writer(&get("carrier", &c, 2), &mut sign1_bytes) + .unwrap_or_else(|e| fail(&format!("Sign1: {e}"))); + let sign1 = + CoseSign1::from_slice(&sign1_bytes).unwrap_or_else(|e| fail(&format!("Sign1: {e:?}"))); + if sign1.payload.is_some() || !sign1.unprotected.is_empty() { + fail("Sign1: the payload is not null, or the unprotected header is not empty"); + } + println!("ok the carrier is {{0: 0xFFFF0003, 1: body, 2: Sign1}}, and coset reads the Sign1"); + + let header = &sign1.protected.header; + let kid = digest(&SHA384, &[&THUMBPRINT_HEAD[..], public_key].concat()); + let typ_ok = header.rest.len() == 1 + && header.rest[0].0 == coset::Label::Int(16) + && header.rest[0].1 == Value::Text(TYP.into()); + if header.alg != Some(Algorithm::Assigned(iana::Algorithm::ML_DSA_87)) + || header.key_id != kid.as_ref() + || !typ_ok + || header.content_type.is_some() + || !header.crit.is_empty() + { + fail("protected header: not {1: ML-DSA-87, 4: SHA-384 thumbprint, 16: typ}"); + } + println!( + "ok coset reads alg ML-DSA-87 (-50), the kid is SHA-384 of the thumbprint input, and typ" + ); + + let b = entries("body", &decode("body", &body)); + let r = entries("body key 2", &get("body", &b, 2)); + let d = get("body key 2", &r, 0); + let d = d + .as_array() + .unwrap_or_else(|| fail("body: the digest is not an array")); + let sum = digest(&SHA384, message); + if int(&get("body", &b, 0)) != i128::from(SCHEMA) + || d.len() != 2 + || int(&d[0]) != -43 + || d[1].as_bytes().map(|v| v.as_slice()) != Some(sum.as_ref()) + || get("body key 2", &r, 1).as_integer() != Some(Integer::from(message.len() as u64)) + || get("body key 2", &r, 2) != Value::Text("application/json".into()) + { + fail("body: does not name the message by SHA-384 and length under schema 0x85000101"); + } + println!("ok the body names the message: [-43, SHA-384], its length, application/json"); + + let key = UnparsedPublicKey::new(&ML_DSA_87, public_key); + sign1 + .verify_detached_signature(&body, &[], |sig, tbs| key.verify(tbs, sig)) + .unwrap_or_else(|_| { + fail("the carrier's signature does not verify over coset's ToBeSigned") + }); + println!("ok the signature verifies over ToBeSigned built by coset with the detached body"); + + for change in 0..3 { + let mut altered = sign1.clone(); + altered.protected.original_data = None; + match change { + 0 => { + altered.protected.header.alg = Some(Algorithm::Assigned(iana::Algorithm::ML_DSA_65)) + } + 1 => altered.protected.header.key_id[0] ^= 1, + _ => { + altered.protected.header.rest[0].1 = + Value::Text("application/vnd.example.b85.other.v0+cose".into()) + } + } + if altered + .verify_detached_signature(&body, &[], |sig, tbs| key.verify(tbs, sig)) + .is_ok() + { + fail("the signature also verifies with the protected header changed"); + } + } + println!("ok it does not verify with alg, kid or typ changed"); +} diff --git a/tools/cross_verify/rust/src/main.rs b/tools/cross_verify/rust/src/main.rs index 8de170e..d7d9d77 100644 --- a/tools/cross_verify/rust/src/main.rs +++ b/tools/cross_verify/rust/src/main.rs @@ -3,10 +3,13 @@ //! 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. +//! not verify over the message with one bit flipped; and the N6 carrier BeamMCP.Signer.COSE made +//! over the same message (carrier.rs). //! //! cargo run --locked --release -- +mod carrier; + use aws_lc_rs::signature::{UnparsedPublicKey, ML_DSA_87}; use std::{env, fs, path::Path, process}; @@ -14,7 +17,7 @@ fn read(dir: &Path, name: &str) -> Vec { fs::read(dir.join(name)).unwrap_or_else(|e| fail(&format!("{name}: {e}"))) } -fn fail(why: &str) -> ! { +pub(crate) fn fail(why: &str) -> ! { println!("FAIL {why}"); process::exit(1) } @@ -27,7 +30,8 @@ fn main() { let dir = Path::new(&args[1]); let message = read(dir, "message.bin"); let signature = read(dir, "signature.bin"); - let public_key = UnparsedPublicKey::new(&ML_DSA_87, read(dir, "public_key.bin")); + let encoded = read(dir, "public_key.bin"); + let public_key = UnparsedPublicKey::new(&ML_DSA_87, encoded.clone()); if public_key.verify(&message, &signature).is_err() { fail("the signature does not verify"); @@ -40,4 +44,6 @@ fn main() { fail("the signature also verifies over the message with one bit flipped"); } println!("ok it does not verify over the message with one bit flipped"); + + carrier::verify(&encoded, &message, &read(dir, "carrier.cbor")); } diff --git a/tools/mldsa_sample.exs b/tools/mldsa_sample.exs index 226ba12..650a538 100644 --- a/tools/mldsa_sample.exs +++ b/tools/mldsa_sample.exs @@ -1,14 +1,15 @@ # SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 # -# Writes one ML-DSA-87 signature made by BeamMCP.Signer.MLDSA87 for other implementations to -# verify (tools/cross_verify/): the canonical bytes of a fixed graph, the signature over them, -# and the key pair it was made with, ACVP's published keyGen case (tgId 3, tcId 51), so the +# 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 # 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, public_key.bin, seed.bin. +# Writes /message.bin, signature.bin, carrier.cbor, public_key.bin, seed.bin. alias BeamMCP.Connectome.{Canonical, Edge, Graph, Node} [dir] = System.argv() @@ -33,9 +34,17 @@ message = Canonical.encode!(graph, algorithm: :sha384) algorithm: :sha384 ) +{:ok, carrier} = + BeamMCP.Signer.COSE.sign(message, + private_key: fn -> seed end, + public_key: fn -> public_key end, + issuer: "beam_mcp_signer.ci" + ) + for {name, bytes} <- [ {"message.bin", message}, {"signature.bin", signature}, + {"carrier.cbor", carrier}, {"public_key.bin", public_key}, {"seed.bin", seed} ] do diff --git a/tools/vectors_cose.sh b/tools/vectors_cose.sh new file mode 100755 index 0000000..f28d1cd --- /dev/null +++ b/tools/vectors_cose.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Writes test/fixtures/cose/rfc9964_a2_ml_dsa_87.json from RFC 9964 itself: Appendix A.2, +# Figure 8 (ML_DSA_87), fetched from rfc-editor.org and refused unless its sha256 is the one +# recorded here. The figure's hex strings wrap across lines and page breaks; this joins them +# and keeps the fields the suite reads (the `_diag` renderings are dropped). Run by hand; the +# suite reads only the output. +# +# RFC 9964 (IETF Trust; code components under the Revised BSD License, TLP section 4.e) +set -euo pipefail +root=$(git rev-parse --show-toplevel) +out="$root/test/fixtures/cose" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +curl -sSfo "$work/rfc9964.txt" https://www.rfc-editor.org/rfc/rfc9964.txt +echo "8c42035b948301b197d431de8b3f40019bdaa3bb40150dc523306923948f34cf $work/rfc9964.txt" | + sha256sum -c --quiet - || { echo "rfc9964.txt: sha256 differs from the pin; refused"; exit 1; } + +# Figure 8 is the last JSON object before its caption: from the "{" that opens it to the "}" +# that closes it, page headers, footers and form feeds removed, continuation lines joined. +awk '/^A\.2\. COSE$/ {a2 = 1} a2' "$work/rfc9964.txt" | tr -d '\f' | + grep -v '^RFC 9964 \|^Prorock & Steele\|^ *$' | + awk '/Figure 7: ML_DSA_65/ {f8 = 1; next} f8 && /^\{/ {on = 1} + on { if ($0 ~ /^ "/ || $0 ~ /^[{}]/) printf "\n%s", $0; else { gsub(/^ +/, ""); printf "%s", $0 } } + on && /^\}/ {exit}' > "$work/figure8.json" + +mkdir -p "$out" +jq -c '{source: "RFC 9964 Appendix A.2, Figure 8 (ML_DSA_87); rfc-editor.org/rfc/rfc9964.txt, sha256 8c42035b948301b197d431de8b3f40019bdaa3bb40150dc523306923948f34cf", + priv, key, raw_public_key, raw_to_be_signed, raw_signature, sign1}' \ + "$work/figure8.json" > "$out/rfc9964_a2_ml_dsa_87.json" +jq -r '"public key \(.raw_public_key | length / 2) bytes, signature \(.raw_signature | length / 2) bytes, ToBeSigned \(.raw_to_be_signed | length / 2) bytes"' \ + "$out/rfc9964_a2_ml_dsa_87.json"