Skip to content

Commit 2ad4ed8

Browse files
authored
Merge pull request #139 from ScriptKittyOS/slice/143-boot-check-by-role
feat(s143): the ML-DSA-87 boot check by role
2 parents b9f2290 + 2b2a836 commit 2ad4ed8

26 files changed

Lines changed: 959 additions & 204 deletions

‎.github/workflows/gate.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,4 +417,4 @@ jobs:
417417
TRINITY_REQUIRE_MLDSA87_RUNTIME: "1"
418418
run: |
419419
rm -rf tmp trinity_test.db* trinity_test_receipts.db*
420-
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
420+
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

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,23 @@ built and read back; a chain crossing from the earlier form, and every tampering
127127
or a checkpoint refused; attachments missing or altered reported without changing the verdict; and
128128
the two verifiers agreeing on each case.
129129

130+
### `slice/143`: every node checks its ML-DSA-87 verifier at boot
131+
132+
Every node now checks at boot that its cryptography library verifies ML-DSA-87 signatures
133+
correctly, against nine published cases: two that must verify and seven that must not, so a
134+
verifier that accepts everything is caught. A wrong answer stops the boot on any node. A runtime
135+
that cannot do ML-DSA-87 at all still boots and says so on its boot record, except on the national
136+
security profile, where it is refused. On that profile the node also refuses to start unless FIPS
137+
mode is on and the FIPS provider reports the build its validation names. A node that signs receipts
138+
with ML-DSA-87 additionally signs with a published test key at boot, and no longer generates a key
139+
of its own to test with. None of this is presented as FIPS evidence: the provider tests itself.
140+
141+
Verified by the test suite: every case's expected answer on a runtime that does ML-DSA-87; a planted
142+
wrong answer at each case and each step refused by name; real boots refusing a wrong verifier in
143+
both profiles and refusing on the national security profile by mode or by build string; and the
144+
pass on that profile shown by the decision's own tests, since no CI runtime carries the validated
145+
provider.
146+
130147
## 2026-10-08
131148

132149
### `slice/136`: the web pages ask who is there

‎REUSE.toml‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ SPDX-FileCopyrightText = "National Institute of Standards and Technology"
6969
SPDX-License-Identifier = "CC0-1.0"
7070

7171
# Slice 137. **Not ours.** The ML-DSA-87 known-answer vectors the boot preflight checks against.
72-
# `priv/kat/acvp/` is one case from NIST's ACVP-Server (`usnistgov/ACVP-Server`), a work of the US
73-
# Government, annotated CC0-1.0 as the NIST catalogues above are. `priv/kat/wycheproof/` is three
74-
# cases from C2SP's Wycheproof, Apache-2.0. Each file names its source commit and the SHA-256 of the
75-
# file it was cut from. The `**/*.json` line above would otherwise assign them to this project.
72+
# `priv/kat/acvp/` is three cases from NIST's ACVP-Server (`usnistgov/ACVP-Server`), a work of the
73+
# US Government, annotated CC0-1.0 as the NIST catalogues above are: slice 137's keyGen case and
74+
# slice 143's sigGen and sigVer cases. `priv/kat/wycheproof/` is five cases from C2SP's Wycheproof,
75+
# Apache-2.0. Each file names its source commit and the SHA-256 of the file it was cut from. The
76+
# `**/*.json` line above would otherwise assign them to this project. `priv/kat/mldsa87_boot_check.json`,
77+
# the table of which case must answer what (slice 143), is this project's and takes that line.
7678
[[annotations]]
7779
path = ["priv/kat/acvp/**"]
7880
precedence = "override"

‎docs/07-security-model.md‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ the row names must be at or above its version there. Elixir outside 1.20.x is re
539539

540540
`crypto:supports/0` cannot say whether ML-DSA-87 is usable in FIPS mode: OTP's
541541
`build_pkey_type_list/3` omits every ML-DSA and SLH-DSA type whenever the mode is on, whatever the
542-
loaded provider can do. `Trinity.Crypto.Preflight.mldsa87/1` asks the provider by doing the
542+
loaded provider can do. `Trinity.Crypto.Preflight` (by role since slice 143, below) asks the provider by doing the
543543
operation: NIST's ACVP keyGen case (the published secret key must yield the published public key),
544544
Wycheproof's valid, bit-flipped and malformed-hints verify cases, then a fresh key. The ML-DSA-87
545545
receipts signer reports itself available exactly when that passes.
@@ -549,6 +549,34 @@ fail-open. Where it signs today: a runtime linked to OpenSSL 3.5 or later with t
549549
mode, on the image's current FIPS provider, it does not; a provider validated with ML-DSA-87
550550
(CMVP #5523) is what would change that.
551551

552+
### The check by role, and the provider's identity on `nss` (Slice 143, as built)
553+
554+
Slice 137's six steps are now two checks, chosen by what the node does.
555+
556+
- **The verifier check runs on every node, at boot, right after the floor.** It verifies nine
557+
published cases and requires each to give exactly its expected answer: two that must be true
558+
(NIST ACVP's ML-DSA-87 sigGen case and Wycheproof's valid signature) and seven that must be false
559+
(ACVP's modified signature, Wycheproof's modified signature, hint encoding, infinity norm and
560+
wrong length, and the valid signature with one bit of its message or its signature flipped). The
561+
table and its measured answers are `priv/kat/mldsa87_boot_check.json`. A verifier that says true
562+
to everything fails at the first negative.
563+
- **A case answered wrongly refuses the boot in every profile**, naming the case. A runtime that
564+
cannot run ML-DSA-87 at all (OpenSSL 3.0, or FIPS mode on a provider without it) boots, and the
565+
boot receipt says `unavailable`, except on `nss`.
566+
- **On the `nss` receipts profile** the boot also refuses, each by name, when FIPS mode is not
567+
enabled, when no FIPS provider is loaded, when the provider's build string is not "3.6.0-r4"
568+
(the string CMVP #5523's security policy, section 11.2, names), or when the verifier check cannot
569+
run. This is the node checking itself, not the Crypto Officer's check, and it is never cited as
570+
one. The policy's other string, the provider's name, cannot be checked: OTP's `crypto:info/0` does
571+
not expose it.
572+
- **The signer check runs where receipts sign with ML-DSA-87.** It is the verifier check, ACVP's
573+
keyGen case, and a signature made with ACVP's published test key that verifies and fails on a
574+
flipped message. It generates no key: the provider's own self-tests cover generation, and a key
575+
made at boot cannot be wiped from the BEAM's memory.
576+
- **None of this is FIPS evidence.** Self-tests are the module's duty, and outside the approved
577+
mode the provider owes none, so on `commercial` and on development runtimes this check is the only
578+
ML-DSA-87 test that runs.
579+
552580
## The headless release's floor, and the decoder asked directly (Slice 141, as built)
553581

554582
The headless release, the one deployed as a server, is held to OTP 28.5.0.8 and to the OTP 28 line

‎docs/fips-leg.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ OpenSSL was a 3.0 line; as of 9.8 it is not.
103103
own clients (Req, req_llm, the web tools); a FIPS deployment that fetches from such hosts hits this at run time.
104104
3. **The provider's version string is not the page's.** See the section above; owner: the deployment's assessor,
105105
named in `docs/09-standards-register.md`.
106+
4. **The leg can show an `nss` node refuse, never one boot (slice 143).** On `nss` a node refuses to boot unless its
107+
FIPS provider reports the build string CMVP #5523's security policy names. The image's provider is Red Hat's and
108+
reports another, and it refuses ML-DSA-87 in the mode, so the `fips` job shows the refusal naming the build string;
109+
the boot that passes needs #5523's provider and is shown only by `Trinity.Crypto.BootCheck`'s pure tests. Lift
110+
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
111+
all: OTP's `crypto:info/0` does not expose it.
106112

107113
## Reds on the leg, by test name
108114

‎docs/regulated/control-mapping.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ register does not claim is never Trinity's.
117117
| `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" |
118118
| `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" |
119119
| `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 |
120-
| `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 |
120+
| `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 |
121121
| `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 |
122122
| `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" |
123123
| `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 |

‎docs/regulated/crypto-inventory.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ to that provider, on that host, not to this software. **Anything not established
1515
|---|---|---|---|---|
1616
| 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 |
1717
| 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`) |
18-
| 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 |
18+
| 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 |
19+
| 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 |
1920
| 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 |
2021
| 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 |
2122
| 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 |

‎lib/trinity.ex‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ defmodule Trinity do
4848
# the runtime, asked before anything starts.
4949
Toolchain,
5050
Crypto.Preflight,
51+
# Slice 143: what a boot does with the ML-DSA-87 verifier check and the provider's identity.
52+
Crypto.BootCheck,
5153
# Slice 136: the web authentication mode, its rules and the mode in force.
5254
WebAuth,
5355
UUID,

0 commit comments

Comments
 (0)