Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).
14 changes: 8 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
|
Expand All @@ -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.
Expand Down
37 changes: 23 additions & 14 deletions docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

Expand All @@ -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` |
Expand All @@ -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

Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 40 additions & 19 deletions lib/beam_mcp/signer/ed25519.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading