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
35 changes: 35 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,21 @@ jobs:

- run: mix gate

# Slice 137, AC2: a real boot on an Erlang runtime below the CVE-2026-55737 floor refuses.
# OTP 28.5.0.3 (erts 16.4.0.3) is the release just before the fix, prebuilt by Hex for this
# runner's OS and pinned by the sha256 builds.hex.pm publishes in builds.txt. The tree is the
# one `mix gate` just compiled on the pinned OTP; only the runtime is older. `--only` with a
# tag that selects nothing makes `mix test` exit non-zero, so this cannot pass by running
# no test.
- name: A boot on OTP 28.5.0.3, below the floor, refuses
run: |
curl -fsSL -o /tmp/otp-28.5.0.3.tar.gz https://builds.hex.pm/builds/otp/ubuntu-24.04/OTP-28.5.0.3.tar.gz
echo "d4116a141da5081b849bd057a30679b48f229fb41dbe88da171739883a4797fb /tmp/otp-28.5.0.3.tar.gz" | sha256sum -c -
mkdir -p /tmp/otp-28.5.0.3
tar -xzf /tmp/otp-28.5.0.3.tar.gz -C /tmp/otp-28.5.0.3 --strip-components=1
(cd /tmp/otp-28.5.0.3 && ./Install -minimal "$PWD" > /dev/null)
TRINITY_BELOW_FLOOR_OTP=/tmp/otp-28.5.0.3/bin mix test --only below_floor_otp test/trinity/toolchain_node_test.exs

# Slice 120 AC1. `reuse lint` is a Python tool and this is the only leg that installs one,
# deliberately: `mix gate` has to stay runnable inside the FIPS container and on a developer's
# machine without a Python environment, so the REUSE check is a step here rather than a row in
Expand Down Expand Up @@ -360,3 +375,23 @@ jobs:
run: |
mix test --trace test/fips
mix test --cover | grep -E 'Result:|\| *Total'

# Slice 137, AC5 and AC7: ML-DSA-87 with the mode off. This image links OpenSSL 3.5, the only
# runtime in CI that signs ML-DSA-87 (the gate runners link 3.0; in the mode, Red Hat's
# provider refuses it), so the ML-DSA-87 path runs here end to end: the planted-step cases,
# a regulated node that boots with ML-DSA-87 receipts and whose boot receipt verifies, and the
# signer's key shapes. TRINITY_REQUIRE_MLDSA87_RUNTIME makes test_helper raise if the runtime
# cannot sign, rather than excluding the cases it came to run.
#
# It runs last, from a fresh suite state. The suite keeps its receipt keys in tmp/test_keys
# and its receipts in trinity_test_receipts.db across runs, so a run with the mode off leaves
# Ed25519 and ML-DSA-87 keys and rows that the mode refuses to verify. Run before `mix gate`,
# it turned five of the gate's tests red on PR #132 (`:notsup`, "Unsupported algorithm in
# FIPS mode"; three receipt keys in one export). Nothing in the mode runs after this step.
- name: ML-DSA-87 end to end (FIPS mode off)
env:
ERL_AFLAGS: "-eval application:load(crypto)"
TRINITY_REQUIRE_MLDSA87_RUNTIME: "1"
run: |
rm -rf tmp trinity_test.db* trinity_test_receipts.db*
mix test test/trinity/crypto/preflight_test.exs test/trinity/toolchain_node_test.exs test/trinity/receipts/signer_test.exs
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@ evidence for each increment is retained by the maintainers and summarised here.
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
the public record of delivered work.

### `slice/137`: Trinity refuses a runtime it should not trust

A node now refuses to start, before anything else runs, on an Erlang runtime that still carries
CVE-2026-55737 (heap corruption when a malformed external term is decoded) or on Elixir older than
1.20.x. The floor is the release that fixed it on each Erlang/OTP line (27.3.4.15, 28.5.0.4,
29.0.4), whose rows of Erlang/OTP's own version table are checked in verbatim: the runtime and every
loaded Erlang/OTP application, ssl and crypto among them, are compared with that release's
versions. Every pinned build is above it; a release copied onto an older runtime is what it stops.

Whether ML-DSA-87 can be used is now answered by doing it at boot, against published answers (a
NIST key-generation case and three Wycheproof verification cases) and then with a fresh key,
rather than by asking whether the runtime lists the algorithm.
Erlang/OTP lists no ML-DSA type in FIPS mode whatever the cryptographic provider can do, so the
listing would have told a FIPS node with a capable provider that it could not sign. A regulated
node configured to sign receipts with ML-DSA-87 that fails the check refuses to boot and says
which step failed; a default node keeps running with effects denied, as it does for any signer
fault. The boot receipt records the runtime's erts and Elixir versions and the check's answer.
ML-DSA-87 receipt keys made by Erlang/OTP 28.5.0.8, which returns the private key in a different
shape from earlier releases, no longer stop the node from starting.

Verified by the test suite: a real boot on Erlang/OTP 28.5.0.3, the release before the fix,
refusing in its own operating system process; the floor at every boundary and a property over
generated versions; every step of the ML-DSA-87 check failing by name; and, on the FIPS image with
the mode off, the planted failures against the real cryptographic library.

## 2026-10-08

### `slice/136`: the web pages ask who is there
Expand Down
17 changes: 17 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,20 @@ path = ["test/support/fixtures/nist/**"]
precedence = "override"
SPDX-FileCopyrightText = "National Institute of Standards and Technology"
SPDX-License-Identifier = "CC0-1.0"

# Slice 137. **Not ours.** The ML-DSA-87 known-answer vectors the boot preflight checks against.
# `priv/kat/acvp/` is one case from NIST's ACVP-Server (`usnistgov/ACVP-Server`), a work of the US
# Government, annotated CC0-1.0 as the NIST catalogues above are. `priv/kat/wycheproof/` is three
# cases from C2SP's Wycheproof, Apache-2.0. Each file names its source commit and the SHA-256 of the
# file it was cut from. The `**/*.json` line above would otherwise assign them to this project.
[[annotations]]
path = ["priv/kat/acvp/**"]
precedence = "override"
SPDX-FileCopyrightText = "National Institute of Standards and Technology"
SPDX-License-Identifier = "CC0-1.0"

[[annotations]]
path = ["priv/kat/wycheproof/**"]
precedence = "override"
SPDX-FileCopyrightText = "Google LLC and the C2SP Wycheproof contributors"
SPDX-License-Identifier = "Apache-2.0"
24 changes: 24 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,30 @@ where the runtime carries it; the boot receipt names the choice and the authorit
hash in unsigned metadata (the R21 default). `bin/verify_receipt.exs` verifies an export with `elixir` alone,
from an empty directory, with the exit vocabulary 0, 1, 2, 5, 6.

## The runtime floor, and ML-DSA-87 by doing it (Slice 137, as built)

Before anything else starts, and in every profile, `Trinity.Application` refuses a runtime below
the fix for CVE-2026-55737 ("heap corruption when an invalidly encoded tuple with an arity of 2^31
or larger is decoded from Erlang's External Term Format"). The defect is in erts, so erts is what
is compared: `Trinity.Toolchain.floor/0` holds the fixed erts per OTP line, read from
`otp_versions.table`. An assembled release has no `OTP_VERSION` file, and `:erlang.system_info(:version)`
answers in every runtime. The same releases fix CVEs in ssl, public_key, crypto, ssh and stdlib,
so erts is not enough: `priv/otp_floor.table` holds the three fixing releases' rows of
`otp_versions.table` verbatim, with their source commit and SHA-256, and every loaded application
the row names must be at or above its version there. Elixir outside 1.20.x is refused the same way.

`crypto:supports/0` cannot say whether ML-DSA-87 is usable in FIPS mode: OTP's
`build_pkey_type_list/3` omits every ML-DSA and SLH-DSA type whenever the mode is on, whatever the
loaded provider can do. `Trinity.Crypto.Preflight.mldsa87/1` asks the provider by doing the
operation: NIST's ACVP keyGen case (the published secret key must yield the published public key),
Wycheproof's valid, bit-flipped and malformed-hints verify cases, then a fresh key. The ML-DSA-87
receipts signer reports itself available exactly when that passes.
Under `:regulated`, a node configured for ML-DSA-87 receipts that fails it refuses to boot with
`{:mldsa87_preflight_failed, step, reason}`; under `:default` the receipts supervisor stays
fail-open. Where it signs today: a runtime linked to OpenSSL 3.5 or later with the mode off. In the
mode, on the image's current FIPS provider, it does not; a provider validated with ML-DSA-87
(CMVP #5523) is what would change that.

## The effect population, published (Slice 027, as built)

The set of things that can cause an effect is small, derived from the tree, and published as
Expand Down
2 changes: 2 additions & 0 deletions docs/regulated/control-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,13 @@ register does not claim is never Trinity's.
| `SC-12` | Keys pass through one behaviour, so a KMS or HSM adapter is a module and a configuration line; none ships in this tree | `lib/trinity/keys.ex` | `test/trinity/keys/signer_seam_test.exs` "a sealed key still signs, which is the point of sealing it" | shared | A KMS or HSM adapter, where one is required | CRM "A KMS or HSM adapter" |
| `SC-12` | Custody of the signing key is not addressed: a key in a file shows the records were not altered by anything without read access to it, and nothing about who held it | `docs/10-assurance-case.md` | none | the deployment's | Custody of the signing key | CRM "Custody of the signing key" |
| `SC-13` | Signing picks its algorithm by the runtime's mode: Ed25519 by default, ECDSA P-384 with SHA-384 in FIPS mode, where Ed25519 reports itself unavailable. The gate and the suite run in FIPS mode on every push | `lib/trinity/receipts/signer.ex`, `docs/fips-leg.md`, `docs/regulated/crypto-inventory.md` | `test/trinity/receipts/signer_test.exs` "outside FIPS mode the default is Ed25519; in FIPS mode (the fips leg) it is P-384"; `test/fips/receipts_test.exs` "the mode is on, Ed25519 reports unavailable, P-384 is selected and the boot receipt names it" | shared | A validated cryptographic module on the host, established from its certificate and the running binary | Register "FIPS 140-3 validated cryptography in FIPS mode": :unknown; Register "FIPS 140-2 certificates on the Historical list from 2026-09-22": real-world dependency |
| `SC-13` | ML-DSA-87 is offered for receipts only where a known-answer check passes through `:crypto` in the mode the node is in: NIST's ACVP keyGen case (the published secret key yields the published public key), Wycheproof's verify cases (a valid signature, a bit-flipped one and one with malformed hints), then a fresh key. Not where the algorithm is merely listed: OTP lists no ML-DSA type in FIPS mode whatever the provider can do. A regulated node configured for ML-DSA-87 that fails the check refuses to boot naming the step; the boot receipt records the check's answer | `lib/trinity/crypto/preflight.ex`, `priv/kat/`, `lib/trinity/receipts/signer/mldsa87.ex`, `lib/trinity/receipts/key_custody.ex` | `Trinity.Crypto.Preflight.mldsa87/1`; `test/trinity/crypto/preflight_test.exs` "a provider that derives the wrong public key fails at :keygen_kat"; `test/trinity/crypto/preflight_test.exs` "a verifier that accepts Wycheproof's bit-flipped signature fails at :sigver_kat, naming the case"; `test/trinity/crypto/preflight_test.exs` "the ML-DSA-87 signer is available exactly when the preflight passes (F-137-1)"; `test/trinity/toolchain_node_test.exs` "a failing preflight refuses the boot, naming the step" | Trinity's | none | none |
| `SC-23` | A request whose `Host` is not the loopback or the configured host is refused before routing, and the websocket origin check is an explicit list in every environment; under `TRINITY_PROFILE=regulated` an origin check of `false` or `:conn` refuses to boot, because a DNS rebinding page controls both | `lib/trinity_web/plugs/host_allow_list.ex`, `lib/trinity/profile.ex` | `Trinity.Profile.check_origin/2`; `test/trinity_web/auth/host_and_origin_test.exs` "a foreign Host is 403 before routing, wherever it points"; `test/trinity_web/auth/host_and_origin_test.exs` "check_origin is an explicit list in dev, test and prod" | shared | Setting `PHX_HOST` to the host users reach | CRM "Refusing a request whose Host is not this deployment's" |
| `SC-23` | The state an MCP client carries between rounds is sealed with AES-256-GCM: a replay, one tampered byte, an expired envelope and a state bound to other arguments are refused | `lib/trinity/mcp/server/envelope.ex` | `test/trinity/mcp/server_mrtr_test.exs` "a replay inside the partition, one tampered byte, an expired envelope and a state bound to other arguments are refused; a missing state is a first call" | Trinity's | none | Register "Envelope MAC in FIPS mode is an approved algorithm": :unknown |
| `SC-24` | Failure denies: an unavailable signer denies every effect, a tool absent from the catalogue or a decision other than allow is denied before anything runs, and an authority adapter that cannot be loaded refuses the boot | `lib/trinity/effects.ex`, `lib/trinity/authority/selection.ex` | `test/trinity/effects/membrane_test.exs` "a :catalog tool absent from the catalog, or a decision other than allow, is denied before anything runs"; `test/trinity/authority/selection_test.exs` "an absent module is refused as not loaded, by name" | Trinity's | none | Register "Nothing fails open": :unknown |
| `SC-28` | Anything SQLite indexes is left unencrypted by design, for the volume beneath it | `docs/encryption-at-rest.md` | none | the deployment's | Volume or page-level encryption below SQLite | CRM "Encryption of everything SQLite indexes" |
| `SC-28(1)` | Blobs nothing indexes (skill files, staged changes, archives) are sealed with AES-256-GCM under a fresh data key each | `lib/trinity/vault.ex` | `test/trinity/vault_test.exs` "a sealed blob is ciphertext: the plaintext does not appear in it"; `test/trinity/vault_test.exs` "every blob gets its own data key, so one compromise does not open the next" | Trinity's | none | CRM "Envelope encryption of blobs"; Register "Encryption of data at rest with deployment-controlled keys": :unknown |
| `SI-2` | Every profile refuses to boot, before any child starts, on a runtime below the release that fixed CVE-2026-55737 for its line (OTP 27.3.4.15, 28.5.0.4 or 29.0.4): erts and every loaded application are compared with that release's row of `otp_versions.table`, checked in verbatim with its source commit and SHA-256. Elixir outside 1.20.x is refused the same way, and the boot receipt records the erts and Elixir it ran on | `lib/trinity/toolchain.ex`, `priv/otp_floor.table`, `lib/trinity/application.ex`, `lib/trinity/effects/boot.ex` | `Trinity.Toolchain.check_otp/3`; `Trinity.Toolchain.check_otp/2`; `Trinity.Toolchain.check_elixir/1`; `test/trinity/toolchain_node_test.exs` "the line the child prints names the refusal, the runtime's erts and the floor"; `test/trinity/toolchain_test.exs` "erts at the floor with an application below it refuses, naming the application"; `test/trinity/toolchain_node_test.exs` "a default node boots" | Trinity's | none | none |
| `SI-4` | Documented telemetry events an operator can subscribe to; Trinity emits them and alerts no one | `lib/trinity/telemetry.ex`, `docs/telemetry.md` | `test/trinity/telemetry/telemetry_test.exs` "every event the catalogue lists appears in docs/telemetry.md" | shared | Collecting the events, and alerting on them | CRM "Telemetry events an operator can subscribe to" |
| `SI-7` | A changed MCP tool definition is held, not called, until the owner accepts it; the first sighting is the baseline | `lib/trinity/tools/surface.ex`, `lib/trinity/tools/definition_digest.ex` | `test/trinity/mcp/surface_drift_test.exs` "a description change alone holds the tool, with the schema untouched" | Trinity's | none | none |
| `SI-7` | Every input to the image build that is not a UBI package is declared with its digest and copied only if declared, and the published bases are pinned by digest | `ci/ironbank/hardening_manifest.yaml`, `lib/mix/tasks/trinity.ironbank.lint.ex` | `mix trinity.ironbank.lint`; `test/ironbank_submission_test.exs` "copying a file no resource declares is refused"; `test/ironbank_submission_test.exs` "a published base pinned by tag and not by digest is refused (planted red)" | Trinity's | none | none |
Expand Down
5 changes: 5 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ defmodule Trinity do
# starts anything. Pure, so the refusals are unit testable and so this boundary does not
# grow a dependency on Authority or Receipts to ask a question about a value.
Profile,
# Slice 137: the runtime floor the application refuses to boot below, and the ML-DSA-87
# known-answer check the receipts signer and the boot receipt ask. Pure questions about
# the runtime, asked before anything starts.
Toolchain,
Crypto.Preflight,
# Slice 136: the web authentication mode, its rules and the mode in force.
WebAuth,
UUID,
Expand Down
14 changes: 14 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ defmodule Trinity.Application do
# project's rules about key material, and the filter is the last line for exactly that case.
install_log_redaction()

# Slice 137, in every profile, before every other refusal and every child: a runtime below
# the CVE-2026-55737 floor, or an Elixir below 1.20, is not one this node trusts to decode
# anything. The floor is erts per OTP line (`Trinity.Toolchain.floor/0`).
verify_toolchain!()

# Slice 135, in every profile: a filesystem root that is, contains or sits inside the data
# directory or the secrets directory refuses the boot, before any child (and before the
# regulated refusals, so the reason printed is this one when both apply).
Expand Down Expand Up @@ -184,6 +189,15 @@ defmodule Trinity.Application do
:ok
end

# Slice 137. The decision is `Trinity.Toolchain.check/0`'s, pure; the reason is raised in the
# shape `verify_web_auth!/0` uses, so a refused child prints it whole.
defp verify_toolchain! do
case Trinity.Toolchain.check() do
:ok -> :ok
{:error, reason} -> raise "Trinity refuses to boot: #{inspect(reason)}"
end
end

# Slice 136 AC4, AC6 and AC13. The decisions are `Trinity.WebAuth`'s and `Trinity.Profile`'s,
# both pure; this reads the endpoint's configuration and hands them the values.
defp verify_web_auth! do
Expand Down
Loading
Loading