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
2 changes: 1 addition & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,4 @@ jobs:
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 test/trinity/n6/cose_signing_test.exs test/trinity/n6/cose_test.exs test/trinity/receipts/foreign_algorithm_test.exs test/trinity/receipts/v4_test.exs test/trinity/n6/esp384_test.exs
mix test test/trinity/crypto/preflight_test.exs test/trinity/toolchain_node_test.exs test/trinity/receipts/signer_test.exs test/trinity/n6/cose_signing_test.exs test/trinity/n6/cose_test.exs test/trinity/receipts/foreign_algorithm_test.exs test/trinity/receipts/v4_test.exs test/trinity/n6/esp384_test.exs test/trinity/crypto/boot_check_test.exs test/trinity/crypto/boot_check_node_test.exs
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ built and read back; a chain crossing from the earlier form, and every tampering
or a checkpoint refused; attachments missing or altered reported without changing the verdict; and
the two verifiers agreeing on each case.

### `slice/143`: every node checks its ML-DSA-87 verifier at boot

Every node now checks at boot that its cryptography library verifies ML-DSA-87 signatures
correctly, against nine published cases: two that must verify and seven that must not, so a
verifier that accepts everything is caught. A wrong answer stops the boot on any node. A runtime
that cannot do ML-DSA-87 at all still boots and says so on its boot record, except on the national
security profile, where it is refused. On that profile the node also refuses to start unless FIPS
mode is on and the FIPS provider reports the build its validation names. A node that signs receipts
with ML-DSA-87 additionally signs with a published test key at boot, and no longer generates a key
of its own to test with. None of this is presented as FIPS evidence: the provider tests itself.

Verified by the test suite: every case's expected answer on a runtime that does ML-DSA-87; a planted
wrong answer at each case and each step refused by name; real boots refusing a wrong verifier in
both profiles and refusing on the national security profile by mode or by build string; and the
pass on that profile shown by the decision's own tests, since no CI runtime carries the validated
provider.

## 2026-10-08

### `slice/136`: the web pages ask who is there
Expand Down
10 changes: 6 additions & 4 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ 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.
# `priv/kat/acvp/` is three cases from NIST's ACVP-Server (`usnistgov/ACVP-Server`), a work of the
# US Government, annotated CC0-1.0 as the NIST catalogues above are: slice 137's keyGen case and
# slice 143's sigGen and sigVer cases. `priv/kat/wycheproof/` is five 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. `priv/kat/mldsa87_boot_check.json`,
# the table of which case must answer what (slice 143), is this project's and takes that line.
[[annotations]]
path = ["priv/kat/acvp/**"]
precedence = "override"
Expand Down
30 changes: 29 additions & 1 deletion docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ the row names must be at or above its version there. Elixir outside 1.20.x is re

`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
loaded provider can do. `Trinity.Crypto.Preflight` (by role since slice 143, below) 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.
Expand All @@ -549,6 +549,34 @@ fail-open. Where it signs today: a runtime linked to OpenSSL 3.5 or later with t
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 check by role, and the provider's identity on `nss` (Slice 143, as built)

Slice 137's six steps are now two checks, chosen by what the node does.

- **The verifier check runs on every node, at boot, right after the floor.** It verifies nine
published cases and requires each to give exactly its expected answer: two that must be true
(NIST ACVP's ML-DSA-87 sigGen case and Wycheproof's valid signature) and seven that must be false
(ACVP's modified signature, Wycheproof's modified signature, hint encoding, infinity norm and
wrong length, and the valid signature with one bit of its message or its signature flipped). The
table and its measured answers are `priv/kat/mldsa87_boot_check.json`. A verifier that says true
to everything fails at the first negative.
- **A case answered wrongly refuses the boot in every profile**, naming the case. A runtime that
cannot run ML-DSA-87 at all (OpenSSL 3.0, or FIPS mode on a provider without it) boots, and the
boot receipt says `unavailable`, except on `nss`.
- **On the `nss` receipts profile** the boot also refuses, each by name, when FIPS mode is not
enabled, when no FIPS provider is loaded, when the provider's build string is not "3.6.0-r4"
(the string CMVP #5523's security policy, section 11.2, names), or when the verifier check cannot
run. This is the node checking itself, not the Crypto Officer's check, and it is never cited as
one. The policy's other string, the provider's name, cannot be checked: OTP's `crypto:info/0` does
not expose it.
- **The signer check runs where receipts sign with ML-DSA-87.** It is the verifier check, ACVP's
keyGen case, and a signature made with ACVP's published test key that verifies and fails on a
flipped message. It generates no key: the provider's own self-tests cover generation, and a key
made at boot cannot be wiped from the BEAM's memory.
- **None of this is FIPS evidence.** Self-tests are the module's duty, and outside the approved
mode the provider owes none, so on `commercial` and on development runtimes this check is the only
ML-DSA-87 test that runs.

## The headless release's floor, and the decoder asked directly (Slice 141, as built)

The headless release, the one deployed as a server, is held to OTP 28.5.0.8 and to the OTP 28 line
Expand Down
6 changes: 6 additions & 0 deletions docs/fips-leg.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ OpenSSL was a 3.0 line; as of 9.8 it is not.
own clients (Req, req_llm, the web tools); a FIPS deployment that fetches from such hosts hits this at run time.
3. **The provider's version string is not the page's.** See the section above; owner: the deployment's assessor,
named in `docs/09-standards-register.md`.
4. **The leg can show an `nss` node refuse, never one boot (slice 143).** On `nss` a node refuses to boot unless its
FIPS provider reports the build string CMVP #5523's security policy names. The image's provider is Red Hat's and
reports another, and it refuses ML-DSA-87 in the mode, so the `fips` job shows the refusal naming the build string;
the boot that passes needs #5523's provider and is shown only by `Trinity.Crypto.BootCheck`'s pure tests. Lift
condition: the provider the headless release is fielded with, in a CI image. The provider's name, which the policy also names, cannot be checked at
all: OTP's `crypto:info/0` does not expose it.

## Reds on the leg, by test name

Expand Down
2 changes: 1 addition & 1 deletion docs/regulated/control-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ 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-13` | Every node checks at boot that `:crypto` verifies ML-DSA-87 rightly, by doing it: nine published cases (NIST ACVP sigGen and sigVer, five Wycheproof cases, and flips of a valid signature), each of which must give exactly its expected answer, so a verifier that says yes to everything is refused. A case answered wrongly refuses the boot in every profile; on the `nss` profile a runtime that cannot run the check refuses too, and so does FIPS mode off, no FIPS provider, or a provider whose build string is not the one CMVP #5523's security policy names. Where receipts sign with ML-DSA-87 the signer check adds ACVP's keyGen case and a signature made with a published test key, and generates no key at boot. Not where the algorithm is merely listed: OTP lists no ML-DSA type in FIPS mode whatever the provider can do. The check is defence in depth, never FIPS evidence; the boot receipt records its answer | `lib/trinity/crypto/preflight.ex`, `lib/trinity/crypto/boot_check.ex`, `priv/kat/`, `lib/trinity/receipts/signer/mldsa87.ex`, `lib/trinity/receipts/key_custody.ex` | `Trinity.Crypto.Preflight.verifier/1`; `Trinity.Crypto.Preflight.signer/1`; `Trinity.Crypto.BootCheck.decide/3`; `test/trinity/crypto/preflight_test.exs` "a verifier that says true to everything fails at the first negative"; `test/trinity/crypto/preflight_test.exs` "a provider that derives the wrong public key fails at :keygen_kat"; `test/trinity/crypto/preflight_test.exs` "the ML-DSA-87 signer is available exactly when the signer check passes (F-137-1)"; `test/trinity/crypto/boot_check_test.exs` "another provider's build string refuses, naming both"; `test/trinity/crypto/boot_check_node_test.exs` "a node whose verifier answers a case wrongly refuses, naming the case, in every profile"; `test/trinity/crypto/boot_check_node_test.exs` "an nss node refuses, naming the mode or the provider's build string"; `test/trinity/toolchain_node_test.exs` "a failing preflight refuses the boot, naming the step" | Trinity's | none | none |
| `SC-13` | Signed statements have one byte form: deterministic CBOR inside COSE_Sign1 with ML-DSA-87, or on the `commercial` profile only Ed25519 (slice 139) or, for a node in FIPS mode, ECDSA P-384 as ESP384 with its 96-byte signature (slice 140). Receipts and checkpoints are written in it since slice 140. The decoder refuses any input that is not exactly the deterministic encoding of an allowed value, naming the first defect; the header admits only `alg` -50 (and -19 or -51 at `commercial`), the key's RFC 9679 thumbprint as `kid`, and `typ`; the key's registration names its algorithm and a header naming another is refused; one `typ` per kind, and a body under another kind's is refused; a single-signer carrier refuses a `typ` that needs two signers and a body without an issuer; a verifier that cannot run the header's algorithm answers no trust, never a pass, and so does the receipt verifier for a row whose algorithm the runtime refuses. A census classifies every hashing and signing call in the tree and names the sites that still re-encode data from elsewhere before hashing it | `lib/trinity/n6/`, `lib/trinity/receipts/v4.ex`, `test/fixtures/n6/`, `scripts/n6_vectors.exs` | `test/trinity/n6/cbor_test.exs` "every negative vector is refused with its code"; `test/trinity/n6/cose_test.exs` "every negative header is refused with its code, at either entry point"; `test/trinity/n6/carrier_test.exs` "AC5: every sample gives its expected outcome under its context"; `test/trinity/n6/cose_test.exs` "kid for ACVP's published key is SHA-384 over exactly the RFC 9679 input"; `test/trinity/n6/cose_signing_test.exs` "one flipped byte in the protected header, the payload or the signature is invalid"; `test/trinity/n6/esp384_test.exs` "DER, or 95 or 97 bytes, is signature_encoding"; `test/trinity/n6/esp384_test.exs` "a compressed point, a P-256 key and a point off the curve are key_invalid"; `test/fips/receipts_test.exs` "no effect is denied for want of a signer: an effect runs, its receipts are P-384 and verify"; `test/trinity/n6/census_test.exs` "every hashing and signing call in lib/ is in the census, with its count and a class" | 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 |
Expand Down
3 changes: 2 additions & 1 deletion docs/regulated/crypto-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ to that provider, on that host, not to this software. **Anything not established
|---|---|---|---|---|
| Receipt signing, default | `lib/trinity/receipts/signer/ed25519.ex` | `:crypto.sign(:eddsa, :none, …, :ed25519)`, line 21 | **UNKNOWN** | Ed25519, RFC 8032. `available?/0` line 14 returns false when `:crypto.info_fips() == :enabled`, so it is not selected in FIPS mode |
| Receipt signing, FIPS mode | `lib/trinity/receipts/signer/p384.ex` | `:crypto.sign(:ecdsa, :sha384, …, :secp384r1)`, line 25 | **UNKNOWN** | ECDSA P-384 with SHA-384. Selected when the mode is enabled. Since slice 140 its DER signature is carried as ESP384's 96-byte `r \|\| s` (`lib/trinity/n6/ec2.ex`) |
| Receipt signing, opt-in | `lib/trinity/receipts/signer/mldsa87.ex` | `:crypto` ML-DSA-87, FIPS 204 | **UNKNOWN** | Never the default. `available?/0` asks the runtime by generating, signing and verifying a known answer (`Trinity.Crypto.Preflight`, slice 137); it was a `:crypto.supports/1` lookup before, which never holds in FIPS mode |
| Receipt signing, opt-in | `lib/trinity/receipts/signer/mldsa87.ex` | `:crypto` ML-DSA-87, FIPS 204 | **UNKNOWN** | Never the default. `available?/0` asks the runtime by its signer check (`Trinity.Crypto.Preflight.signer/1`, slice 143): published verify cases, ACVP's keyGen case and a signature made with a published test key, with no key generated; it was slice 137's six steps before, and a `:crypto.supports/1` lookup before those, which never holds in FIPS mode |
| ML-DSA-87 verifier check at boot (slice 143) | `lib/trinity/crypto/preflight.ex`, `lib/trinity/crypto/boot_check.ex` | `:crypto.verify(:mldsa87, :none, …)` over nine published cases | Not applicable | Defence in depth, never FIPS evidence. On `nss` the boot also asserts the FIPS provider's build string (`fips_provider_buildinfo`) against CMVP #5523's security policy |
| Receipt hashing, v2 and v3 rows | `lib/trinity/receipts/envelope.ex:36` | `:crypto.hash(:sha256, …)` | **UNKNOWN** | Over the DSSE pre-authentication encoding, not the bare payload. Rows written before slice 140; still verified |
| Receipt identity and link, v4 rows (slice 140) | `lib/trinity/receipts/v4.ex` | `:crypto.hash(:sha384, …)` | **UNKNOWN** | `statement_id` over the kind's `typ` and the N6 body; the link to a v3 predecessor over its PAE bytes |
| Merkle tree over a chain | `lib/trinity/receipts/merkle.ex` | `:crypto.hash(:sha256, …)` | **UNKNOWN** | RFC 6962 hashing: distinct leaf and node prefixes, odd nodes promoted not duplicated |
Expand Down
2 changes: 2 additions & 0 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ defmodule Trinity do
# the runtime, asked before anything starts.
Toolchain,
Crypto.Preflight,
# Slice 143: what a boot does with the ML-DSA-87 verifier check and the provider's identity.
Crypto.BootCheck,
# Slice 136: the web authentication mode, its rules and the mode in force.
WebAuth,
UUID,
Expand Down
Loading
Loading