diff --git a/CHANGELOG.md b/CHANGELOG.md index 3016b44..0711aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ SPDX-License-Identifier: Apache-2.0 ## [Unreleased] +### Added + +- `:private_key` may be a zero-arity function returning the 32-byte key + (`private_key: fn -> key end`), and the README now passes it that way. Anything that prints + the options (an exception, a log line, a crash report) prints `#Function<...>` in place of + the key's bytes, including an exception raised in `beam_mcp`'s `Canonical.signature/3` on a + mistyped call. A function that does not return 32 bytes is `{:error, {:private_key, + :not_32_bytes}}`, as a key that is not 32 bytes is. + +### Changed + +- A mistyped call to `sign/2` (bytes that are not a binary, options that are not a list) is + answered `{:error, :bad_arguments}` instead of raising `FunctionClauseError`. Found by this + package's own audit: the raised error printed its arguments, the private key among them + (`sign("bytes", %{private_key: key})` printed all 32 bytes). **How to tell whether you are + affected:** only code that rescued `FunctionClauseError` from `sign/2` sees a difference; + a call through `Canonical.signature/3` with a keyword list never reached that clause. + ### Added: the project's pages and checks (no code change) - `SECURITY.md` (private reporting, commitments, what a host can rely on, one known limit), diff --git a/README.md b/README.md index bc46d5c..322161c 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,25 @@ core's `BeamMCP.Signer` behaviour (`sign/2`). `Canonical.signature/3` hands them over; the 64-byte signature comes back beside them. Nothing else: no envelope byte moves, no verdict or receipt is added. -**Who holds the key.** The host. It passes the 32-byte Ed25519 private key on every call: +**Who holds the key.** The host. It passes the 32-byte Ed25519 private key on every call, as +a zero-arity function that returns it: ```elixir {public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519) # or a key the host keeps {:ok, %{signature: sig}} = - BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: private_key) + BeamMCP.Connectome.Canonical.signature(graph, BeamMCP.Signer.Ed25519, + private_key: fn -> private_key end) true = :crypto.verify(:eddsa, :none, BeamMCP.Connectome.Canonical.encode!(graph), sig, [public_key, :ed25519]) ``` +**Why a function.** Options pass through the host's code and core's, and whatever prints +them (an exception raised on a mistyped call, a debug log line, a crash report) prints a key +passed as bytes, byte by byte. A function prints as `#Function<...>`. The 32 bytes themselves +are still accepted; the function is the form that nothing printed can reveal. A mistyped call +to this module is answered `{:error, :bad_arguments}` rather than raised, for the same reason. + This package reads `opts[:private_key]` and nothing else -- no environment variable, no file, no application config, no default. Where the key lives between calls is the host's decision. @@ -40,6 +48,6 @@ What a host can rely on, and how to report a vulnerability: [`SECURITY.md`](SECU the security requirements hold: [`docs/assurance-case.md`](docs/assurance-case.md), with the design in [`docs/architecture.md`](docs/architecture.md). Checking a release's signature: [`docs/verifying-releases.md`](docs/verifying-releases.md). Contributing: -[`CONTRIBUTING.md`](CONTRIBUTING.md), under the [Code of Conduct](CODE_OF_CONDUCT.md). Who -decides, and what comes next: [`GOVERNANCE.md`](GOVERNANCE.md), +[`CONTRIBUTING.md`](https://github.com/ScriptKittyOS/beam_mcp_signer/blob/main/CONTRIBUTING.md), under the [Code of Conduct](https://github.com/ScriptKittyOS/beam_mcp_signer/blob/main/CODE_OF_CONDUCT.md). Who +decides, and what comes next: [`GOVERNANCE.md`](https://github.com/ScriptKittyOS/beam_mcp_signer/blob/main/GOVERNANCE.md), [`docs/roadmap.md`](docs/roadmap.md). Bugs and ideas: [issues](https://github.com/ScriptKittyOS/beam_mcp_signer/issues). diff --git a/SECURITY.md b/SECURITY.md index a913af5..fef26a2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,12 +35,14 @@ The security requirements a host can rely on, each held by a test in `test/`: `opts[:private_key]` on each call and from nowhere else: no environment variable, file, application config, default key or store. A two-layer census holds it (`test/beam_mcp/signer/no_key_source_test.exs`). -- **The key does not leave in a result.** Every return value is a signature or a fixed error - term; neither carries the key. **One known limit:** a call that breaks the documented types - (bytes that are not a binary, or options that are not a keyword list, here or at - `beam_mcp`'s `Canonical.signature/3`) raises a `FunctionClauseError`, and Elixir prints that - exception with its arguments, the key among them, wherever it is logged. Pass a keyword - list. Closing it for misuse too is open work, stated in `docs/assurance-case.md`. +- **The key does not leave in a result or an exception.** Every return value is a signature or + a fixed error term; neither carries the key. A mistyped call is answered + `{:error, :bad_arguments}`, never raised, because a raised `FunctionClauseError` prints its + arguments. Passed by reference (`private_key: fn -> key end`, the README's form), the key + cannot be printed by anything: not an exception raised earlier in core's + `Canonical.signature/3` on a mistyped call, not a log line that inspects the options. Passed + as bytes, the key is as safe as every piece of code that handles the options; tests pin both + halves (`test/beam_mcp/signer/ed25519_test.exs`). - **The signature is standard Ed25519** (RFC 8032, FIPS 186-5) over exactly the canonical bytes `beam_mcp` produced, computed by Erlang/OTP's `:crypto` (OpenSSL), and verifiable by any Ed25519 implementation. diff --git a/docs/architecture.md b/docs/architecture.md index 581e5aa..57e6570 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,14 +9,14 @@ One module, one function, one dependency direction. ```text host application - | Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: key, ...) + | Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: fn -> key end, ...) v beam_mcp (BeamMCP.Connectome.Canonical) | encodes the graph to canonical bytes, then calls the signer through | the BeamMCP.Signer behaviour: sign(canonical_bytes, opts) v beam_mcp_signer (BeamMCP.Signer.Ed25519.sign/2) - | reads opts[:private_key] (32 bytes) and nothing else + | reads opts[:private_key] (32 bytes, or fn -> bytes end) and nothing else v Erlang/OTP :crypto.sign(:eddsa, :none, canonical_bytes, [key, :ed25519]) (OpenSSL) | @@ -28,8 +28,10 @@ One module, one function, one dependency direction. - **`BeamMCP.Signer.Ed25519`** (`lib/beam_mcp/signer/ed25519.ex`): implements `beam_mcp`'s `BeamMCP.Signer` behaviour. `sign/2` takes the canonical bytes and the host's options, reads - the private key from `opts[:private_key]`, checks it is a 32-byte binary, and returns - `{:ok, signature}`, `{:error, :no_private_key}` or `{:error, {:private_key, :not_32_bytes}}`. + the private key from `opts[:private_key]` (the 32 bytes, or a zero-arity function returning + them, which is called once), checks it is a 32-byte binary, and returns `{:ok, signature}`, + `{:error, :no_private_key}` or `{:error, {:private_key, :not_32_bytes}}`. A mistyped call is + answered `{:error, :bad_arguments}`, never raised, so no exception prints the options. - **`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. This package implements no cryptography itself. diff --git a/docs/assurance-case.md b/docs/assurance-case.md index 629198f..05f0600 100644 --- a/docs/assurance-case.md +++ b/docs/assurance-case.md @@ -10,7 +10,7 @@ where they stop. ## The claim -**Given a 32-byte Ed25519 private key in the call's options, `sign/2` returns a standard +**Given a 32-byte Ed25519 private key in the call's options (or a zero-arity function returning it), `sign/2` returns a standard Ed25519 signature over exactly the bytes given, reads the key from nowhere else, keeps no copy of it, and returns nothing that contains it.** @@ -24,7 +24,7 @@ read, kept or misused beyond the host's own call. | The key read from a place the host did not choose (environment, file, config) | yes | one source, `opts[:private_key]`; census over source text and compiled calls | | The key retained after the call (process state, ETS, persistent term, app env) | yes | no state; a test compares all four before and after a call | | The key returned or placed in an error term | yes | returns are a signature or a fixed atom tuple; tests pin every error term | -| The key printed in an exception when the call breaks the documented types | **yes, not yet met** | see "Open work" below | +| The key printed in an exception or a log line | yes | a mistyped call is answered `{:error, :bad_arguments}`, never raised; 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; tests verify with `:crypto.verify/5` and with core's `encode!/2` bytes | | Weak randomness | yes | none used: Ed25519 is deterministic (RFC 8032), no nonce or 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` | @@ -50,7 +50,7 @@ read, kept or misused beyond the host's own call. | Separation of privilege | the key-holding code is a separate package from the keyless core; the host opts in | | Least privilege | no file, network, environment, OS or application-config access, held by census | | Least common mechanism | no shared state between calls or callers | -| Psychological acceptability | two named error terms a host can match and act on | +| Psychological acceptability | three named error terms a host can match and act on | ## Common implementation weaknesses countered @@ -61,17 +61,26 @@ read, kept or misused beyond the host's own call. | CWE-338 / CWE-330 weak randomness | no randomness is used | | CWE-327 / CWE-326 broken algorithm, short key | Ed25519 only, 32-byte seeds only (about 128-bit security, NIST-approved in FIPS 186-5) | | CWE-347 improper signature verification | not applicable to signing; tests verify every signature with an independent call | -| CWE-209 / CWE-532 key in errors or logs | met for returns; open for exceptions on misuse (below) | - -## Open work - -A call that breaks the documented types (bytes that are not a binary, options that are not a -keyword list) raises `FunctionClauseError`, and Elixir formats that exception with the call's -arguments, so the key can reach a crash log. `beam_mcp`'s `Canonical.signature/3` has the same -shape before it reaches this package. It needs a correctly typed call to be safe today. -Closing it is a contract decision across both packages (answer misuse with an error term that -echoes nothing, or take the key by reference so no exception can carry its bytes) and is on -`docs/roadmap.md`. +| CWE-209 / CWE-532 key in errors or logs | no result echoes input; no clause can raise on its arguments; the reference form keeps the bytes out of every printed term (see "Closed" below) | + +## Closed: the key in an exception report (found 2026-09-23) + +Before this change, a call that broke the documented types (bytes that are not a binary, +options that are not a keyword list) raised `FunctionClauseError`, and Elixir formats that +exception with the call's arguments, so the key reached whatever logged it. Measured: +`sign("bytes", %{private_key: key})` printed the 32 bytes. `beam_mcp`'s +`Canonical.signature/3` raises the same way on mistyped options, before this package is +reached. Two layers close it, both in this package: + +1. `sign/2` is total: a mistyped call is answered `{:error, :bad_arguments}`, which carries + nothing that was passed. +2. `:private_key` may be a zero-arity function returning the key, and the README passes it + that way. A function prints as `#Function<...>`, so a key passed by reference cannot appear + in an exception from core, a crash report or a log line. + +What remains is stated: a host that passes the key as bytes and then calls core with mistyped +options still gets the bytes in core's exception. A test pins that fact, so it fails the day +core answers such a call instead of raising. ## How the case is kept true diff --git a/docs/roadmap.md b/docs/roadmap.md index c06cd43..571491e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,11 +10,11 @@ are not promised; the order is. ## Planned -1. **Close the misuse path** in `docs/assurance-case.md` ("Open work"), so a key cannot reach - an exception report even on a mistyped call, decided together with `beam_mcp`. +1. **Done: the misuse path** (`docs/assurance-case.md`, "Closed"). The key passed by reference + reaches no exception report; a mistyped call is answered, not raised. 2. **Follow `beam_mcp` to `1.0.0`.** The requirement is `~> 0.7` today; when core ships `1.0.0`, this package releases with a `~> 1.0` requirement, and then takes its own `1.0.0` - once its surface (one function, three results) has stood a release unchanged. + once its surface (one function, four results) has stood a release unchanged. 3. **Release tooling as core has it**: the canonical tarball script (in the tree) used for every publish, and a build-provenance attestation on each release tag. 4. **Keep current**: security fixes on `SECURITY.md`'s commitments, dependency updates through diff --git a/lib/beam_mcp/signer/ed25519.ex b/lib/beam_mcp/signer/ed25519.ex index 87e5f26..4dbebec 100644 --- a/lib/beam_mcp/signer/ed25519.ex +++ b/lib/beam_mcp/signer/ed25519.ex @@ -7,15 +7,22 @@ defmodule BeamMCP.Signer.Ed25519 do kept out of `beam_mcp` on purpose: core makes no signature of its own. The host hands the key in on every call, under `:private_key` in the options it passes to - `BeamMCP.Connectome.Canonical.signature/3`: + `BeamMCP.Connectome.Canonical.signature/3`, best as a reference: - Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: seed) + Canonical.signature(graph, BeamMCP.Signer.Ed25519, private_key: fn -> seed end) `seed` is the 32-byte Ed25519 private key -- the second element of `:crypto.generate_key(:eddsa, :ed25519)`, or the private half of a key the host already - keeps. This module reads that one option and nothing else: no environment variable, no - file, no application config, no default key. Where the key lives between calls is the - host's decision, made outside this package. + keeps. `:private_key` is either those 32 bytes or a zero-arity function returning them. + **Pass the function.** Options travel through the host's code and core's, and anything that + prints them -- an exception raised on a mistyped call prints its arguments, a debug log + line, a crash report -- prints the bytes of a key passed as bytes, and `#Function<...>` for + a key passed by reference. This module calls the function once per signature and keeps + nothing. + + This module reads that one option and nothing else: no environment variable, no file, no + application config, no default key. Where the key lives between calls is the host's + decision, made outside this package. The signature is the 64-byte Ed25519 signature of exactly the bytes given, unhashed (Ed25519 hashes internally; `canonical_bytes` are what a verifier re-derives with @@ -27,29 +34,43 @@ defmodule BeamMCP.Signer.Ed25519 do @private_key_bytes 32 - @typedoc "The 32-byte Ed25519 private key (seed) the host hands in under `:private_key`." - @type private_key :: <<_::256>> + @typedoc """ + The 32-byte Ed25519 private key (seed) the host hands in under `:private_key`, or a + zero-arity function returning it (the form that no printed term can reveal). + """ + @type private_key :: <<_::256>> | (-> <<_::256>>) @doc """ Signs `canonical_bytes` with the Ed25519 key at `opts[:private_key]`. - Returns `{:ok, signature}` (64 bytes), or `{:error, :no_private_key}` when the option is - absent, or `{:error, {:private_key, :not_32_bytes}}` when it is present with another - shape. Every other option is the host's and is not read. + Returns `{:ok, signature}` (64 bytes); `{:error, :no_private_key}` when the option is + absent; `{:error, {:private_key, :not_32_bytes}}` when it is present and is not 32 bytes, + or is a function that does not return 32 bytes; and `{:error, :bad_arguments}` when + `canonical_bytes` is not a binary or `opts` is not a list. That last is an answer, not a + raise, on purpose: a `FunctionClauseError` is printed with the call's arguments, and the + options hold the key. No result carries any byte of what was passed. Every other option is + the host's and is not read. """ @impl BeamMCP.Signer @spec sign(binary(), keyword()) :: - {:ok, binary()} | {:error, :no_private_key | {:private_key, :not_32_bytes}} + {:ok, binary()} + | {:error, :no_private_key | {:private_key, :not_32_bytes} | :bad_arguments} def sign(canonical_bytes, opts) when is_binary(canonical_bytes) and is_list(opts) do case Keyword.fetch(opts, :private_key) do - {:ok, key} when is_binary(key) and byte_size(key) == @private_key_bytes -> - {:ok, :crypto.sign(:eddsa, :none, canonical_bytes, [key, :ed25519])} - - {:ok, _other} -> - {:error, {:private_key, :not_32_bytes}} - - :error -> - {:error, :no_private_key} + {:ok, key} -> sign_with(canonical_bytes, resolve(key)) + :error -> {:error, :no_private_key} end end + + def sign(_canonical_bytes, _opts), do: {:error, :bad_arguments} + + # A reference is called here and nowhere else, once per signature; its result is not kept. + defp resolve(key) when is_function(key, 0), do: key.() + defp resolve(key), do: key + + defp sign_with(canonical_bytes, key) + when is_binary(key) and byte_size(key) == @private_key_bytes, + do: {:ok, :crypto.sign(:eddsa, :none, canonical_bytes, [key, :ed25519])} + + defp sign_with(_canonical_bytes, _key), do: {:error, {:private_key, :not_32_bytes}} end diff --git a/test/beam_mcp/signer/ed25519_test.exs b/test/beam_mcp/signer/ed25519_test.exs index 2e42368..0834017 100644 --- a/test/beam_mcp/signer/ed25519_test.exs +++ b/test/beam_mcp/signer/ed25519_test.exs @@ -76,6 +76,67 @@ defmodule BeamMCP.Signer.Ed25519Test do {:error, {:private_key, :not_32_bytes}} end + # A FunctionClauseError is printed with the call's arguments, and the options hold the key: a + # raise here would put the key in the host's crash log. So a mistyped call is answered, never + # raised, and the answer carries no byte of what was passed. + test "a mistyped call is answered {:error, :bad_arguments}, never raised, and echoes nothing" do + {_pub, priv} = keypair() + + assert Ed25519.sign("abc", %{private_key: priv}) == {:error, :bad_arguments} + assert Ed25519.sign(:not_bytes, private_key: priv) == {:error, :bad_arguments} + assert Ed25519.sign(~c"abc", private_key: priv) == {:error, :bad_arguments} + assert Ed25519.sign("abc", nil) == {:error, :bad_arguments} + end + + test "the key may be passed by reference, a zero-arity function returning it, and signs as the bytes do" do + {pub, priv} = keypair() + bytes = :crypto.strong_rand_bytes(211) + + assert {:ok, sig} = Ed25519.sign(bytes, private_key: fn -> priv end) + assert {:ok, sig} == Ed25519.sign(bytes, private_key: priv) + assert :crypto.verify(:eddsa, :none, bytes, sig, [pub, :ed25519]) + + g = graph() + + assert {:ok, %{signature: through_core}} = + Canonical.signature(g, Ed25519, private_key: fn -> priv end) + + assert :crypto.verify(:eddsa, :none, Canonical.encode!(g), through_core, [pub, :ed25519]) + end + + test "a reference that does not give 32 bytes is refused as a key that is not 32 bytes" do + {_pub, priv} = keypair() + + for bad <- [fn -> "short" end, fn -> nil end, fn -> ~c"not a binary" end, fn _ -> priv end] do + assert Ed25519.sign("abc", private_key: bad) == {:error, {:private_key, :not_32_bytes}} + end + end + + # What the reference buys, measured through core: `Canonical.signature/3` given a map for its + # options raises in core before this package is reached, and the exception prints the options. + # Passed by reference, the key is printed as #Function<...>; passed as bytes, the bytes are + # printed. The second assertion is core's to change: the day core answers a mistyped call + # instead of raising, it fails, and the README's advice can soften. + test "passed by reference, the key reaches no exception report, even from a mistyped call into core" do + {_pub, priv} = keypair() + g = graph() + + report = fn opts -> + try do + Canonical.signature(g, Ed25519, opts) + flunk("core answered a mistyped call; the README's advice on references can be revisited") + rescue + e -> Exception.format(:error, e, __STACKTRACE__) + end + end + + by_reference = report.(%{private_key: fn -> priv end}) + refute by_reference =~ inspect(priv, limit: :infinity) + refute by_reference =~ priv |> :binary.bin_to_list() |> Enum.take(8) |> Enum.join(", ") + + assert report.(%{private_key: priv}) =~ inspect(priv, limit: :infinity) + end + test "reads :private_key and nothing else: other keys are neither read nor refused" do {pub, priv} = keypair() diff --git a/test/beam_mcp/signer/no_key_source_test.exs b/test/beam_mcp/signer/no_key_source_test.exs index d10df3d..9f286b0 100644 --- a/test/beam_mcp/signer/no_key_source_test.exs +++ b/test/beam_mcp/signer/no_key_source_test.exs @@ -19,6 +19,8 @@ defmodule BeamMCP.Signer.NoKeySourceTest do :byte_size, :is_binary, :is_list, + # the guard on a key passed by reference: a zero-arity function, called in the module + :is_function, :error, :==, :"=:=", @@ -64,10 +66,11 @@ defmodule BeamMCP.Signer.NoKeySourceTest do end) end - test "the private key is only ever a 32-byte binary, and the seed length is a module constant" do + test "the private key is 32 bytes, given directly or by a zero-arity reference, and the seed length is a module constant" do src = File.read!(hd(@lib)) assert src =~ "@private_key_bytes 32" assert src =~ "byte_size(key) == @private_key_bytes" + assert src =~ "is_function(key, 0), do: key.()" refute src =~ ~r/private_key:\s*<