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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
5 changes: 3 additions & 2 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ jobs:
# 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
# signer's key shapes. Slice 138 adds N6's COSE_Sign1 signing and verification with ML-DSA-87.
# 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
Expand All @@ -394,4 +395,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
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ refusing in its own operating system process; the floor at every boundary and a
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.

### `slice/138`: deterministic CBOR and COSE_Sign1 with ML-DSA-87

Trinity can now build and check signed statements in the N6 profile: deterministic CBOR (RFC 8949
section 4.2.1, narrowed to integers, byte and text strings, arrays, integer-keyed maps and three
simple values) inside COSE_Sign1, signed with ML-DSA-87 (RFC 9964). The decoder refuses any byte
string that is not exactly the deterministic encoding of such a value and names the first defect;
a statement's key id is the RFC 9679 thumbprint of its public key; a verifier that cannot run
ML-DSA-87 says it cannot judge rather than failing or passing. Bytes received from elsewhere are
referenced by their SHA-384 digest, never re-encoded first. The profile is provisional (v0, draft
r3), so this is groundwork: nothing Trinity signed before changes, and no statement it signs uses
the new format yet. The project's vectors for it, written from RFC 8949's own examples and from
CBOR heads by hand, are checked in with a `sha384sum` manifest.

Verified by the test suite: every vector, encoded and decoded, at the top level and nested; a
property over generated values; each limit at its value and one past it; the header rules one by
one; signing and verifying with NIST's published ML-DSA-87 test key on the FIPS image with the mode
off; a decoder planted to accept one defect class failing that class and no other; and an opt-in
run of 1,000,000 generated statements whose 13,000,000 mutants were all refused.

## 2026-10-08

### `slice/136`: the web pages ask who is there
Expand Down
16 changes: 16 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,19 @@ path = ["priv/kat/wycheproof/**"]
precedence = "override"
SPDX-FileCopyrightText = "Google LLC and the C2SP Wycheproof contributors"
SPDX-License-Identifier = "Apache-2.0"

# Slice 138. The N6 vectors `scripts/n6_vectors.exs` writes: CBOR, diagnostic notation and expected
# outcomes, none of which can carry a comment. The RFC 8949 Appendix A encodings among them are
# credited in each vector's description. The thumbprint vector's public key is NIST's, the ACVP
# case under `priv/kat/acvp/` above, and is annotated as that file is.
[[annotations]]
path = ["test/fixtures/n6/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "Sudo Apt Holdings LLC"
SPDX-License-Identifier = "Apache-2.0"

[[annotations]]
path = ["test/fixtures/n6/thumbprint/*.pub"]
precedence = "override"
SPDX-FileCopyrightText = "National Institute of Standards and Technology"
SPDX-License-Identifier = "CC0-1.0"
33 changes: 33 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,39 @@ 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.

## Signed statements in one byte form (Slice 138, as built)

The rule: every statement Trinity constructs and signs is deterministic CBOR inside COSE_Sign1,
and bytes someone else produced are hashed exactly as received, never parsed and re-encoded first.
Two encoders that agree on a value's meaning but not its bytes produce two signatures for one
statement, and a verifier that re-encodes before checking verifies its own bytes, not the
signer's. `Trinity.N6` is the layer that rule needs; nothing signs through it yet, and receipts keep
their slice 024 format until a later slice moves them.

- **The codec refuses rather than repairs.** `Trinity.N6.CBOR.decode/1` accepts a byte string only
if it is exactly the deterministic encoding of a value in the subset: shortest heads, definite
lengths, unsigned-integer map keys in bytewise order with no duplicate, no float, tag or other
simple value, NFC text, within the four limits. It names the first defect and returns nothing
from an input that fails. A forged length larger than the input is refused before anything is
read.
- **The header is closed.** The protected header carries exactly `alg` -50 (ML-DSA-87), `kid` and
`typ`, plus CWT claims for a SCITT-renderable `typ`; content type, `crit`, the hash-envelope
labels and every other label are refused, and the unprotected header must be empty, so no
unsigned field can change what a statement claims.
- **The key id is the key.** `kid` is the RFC 9679 SHA-384 thumbprint over the public key and
`alg`, and the verifier recomputes it from the key it holds, so a key cannot be presented under
another algorithm or another key's name.
- **Cannot judge is not a pass.** A runtime that cannot run ML-DSA-87 (FIPS mode on a provider
that refuses it, or OpenSSL 3.0) makes `verify/2` answer exit 5, no trust, with
`alg_unavailable`, never a crash and never a pass; an unknown `kid` is exit 5 too.

`test/trinity/n6/census_test.exs` holds every hashing and signing call in `lib/` with a class.
Four sites re-encode data from elsewhere before hashing it, which the rule forbids, and are
declared there rather than changed here: the tool-definition drift digest, the approval
fingerprint, the MCP server's retry binding, and an effect receipt's digest of non-binary tool
output. Each needs the exact bytes it hashes, which the layer that receives them does not keep
today.

## 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
1 change: 1 addition & 0 deletions docs/09-standards-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status
| FIPS 140-3 validated cryptography in FIPS mode | OTP built with `--enable-fips` against a validated provider; `crypto:info_fips()` enabled; slice 024's signer selects an approved algorithm or denies | slice 003 (FIPS build leg), slice 024 AC8 | 003 landed 2026-09-20: the gate runs in the mode on every push (`fips` job, docs/fips-leg.md); the provider is the one the distribution ships and names, and the word validated is the certificate's, not this tree's; 024 AC8 still `:unknown` | owner, then an assessor |
| CNSA 1.0 signature suite (ECDSA P-384, SHA-384) | slice 024 amendment 2, FIPS mode selection | slice 024 AC8 | `:unknown`; 024 landed 2026-09-20 (tag `slice/024`) and is approved, so the gate on this row is gone and the status is the owner's to set | owner |
| CNSA 2.0 readiness (ML-DSA-87) | slice 024 amendment 6, behind the same seam, compile-conditional on OpenSSL 3.5 or later; never default | none yet | `not claimed` | owner; the word validated waits on the CMVP listing of the provider that carries it |
| Signed statements as deterministic CBOR in COSE_Sign1 with ML-DSA-87 (RFC 8949 section 4.2.1, RFC 9052, RFC 9964; key id RFC 9679) | `Trinity.N6`, slice 138: the codec, the header rules, the thumbprint, signing and verifying, under the N6 profile v0, draft r3, which is provisional | `test/trinity/n6/`; `test/fixtures/n6/` with its `SHA384SUMS` | `not claimed`: no statement Trinity signs uses it yet, and the profile has no published vectors | owner |
| FIPS 140-2 certificates on the Historical list from 2026-09-22 | not applicable: Trinity cites no 140-2 module | none | `real-world dependency` (deployment's modules) | deployment |
| TLS floor 1.2 in FIPS mode | one README line and a test pinning it | slice 002 AC3 | `:unknown`; 002 landed 2026-09-23 (tag `slice/002`) and is approved, so the gate on this row is gone and the status is the owner's to set | owner |
| Envelope MAC in FIPS mode is an approved algorithm | HMAC-SHA256 or AES-GCM in the MRTR envelope Trinity mints | slice 061 | `:unknown`; 061 landed 2026-09-22 (tag `slice/061`) and is approved, so the gate on this row is gone and the status is the owner's to set | owner |
Expand Down
1 change: 1 addition & 0 deletions docs/regulated/control-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ register does not claim is never Trinity's.
| `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` | Signed statements have one byte form: deterministic CBOR inside COSE_Sign1 with ML-DSA-87. 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, the key's RFC 9679 thumbprint as `kid`, and `typ`; a verifier that cannot run ML-DSA-87 answers no trust, never a pass. 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. No statement Trinity signs uses the form yet | `lib/trinity/n6/`, `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"; `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/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 |
| `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 |
Expand Down
4 changes: 4 additions & 0 deletions docs/regulated/crypto-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ to that provider, on that host, not to this software. **Anything not established
| Device identifier | `lib/trinity/receipts/clock.ex` | `:crypto.strong_rand_bytes/1` | Not applicable | Random, names nothing about the host |
| JWK thumbprint | `lib/trinity/receipts/signer.ex` | `:crypto.hash(:sha256, …)` | Not applicable | RFC 7638 |
| Canonical JSON | `lib/trinity/receipts/envelope.ex:15` | `Jcs.encode/1` | Not applicable | RFC 8785, not a cryptographic primitive |
| N6 statement signing (slice 138) | `lib/trinity/n6/cose.ex` | `:crypto.sign(:mldsa87, :none, …)` and `:crypto.verify/5`, FIPS 204, pure, empty context | **UNKNOWN** | Over RFC 9052's ToBeSigned. Not used by any statement yet. Where the runtime refuses ML-DSA-87 (FIPS mode on Red Hat's provider; OpenSSL 3.0) verification answers no trust, never a crash |
| N6 key id | `lib/trinity/n6/cose.ex` | `:crypto.hash(:sha384, …)` | Not applicable | RFC 9679 thumbprint over RFC 9964's AKP members |
| N6 foreign-bytes digest | `lib/trinity/n6/digest.ex` | `:crypto.hash(:sha384, …)` | **UNKNOWN** | Over bytes exactly as received, never re-encoded |
| Deterministic CBOR | `lib/trinity/n6/cbor.ex` | pure Elixir | Not applicable | RFC 8949 section 4.2.1, not a cryptographic primitive |

## Why every certificate says UNKNOWN

Expand Down
24 changes: 24 additions & 0 deletions lib/trinity/n6.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.N6 do
@moduledoc """
The N6 profile: deterministic CBOR and COSE_Sign1 with ML-DSA-87 for signed statements
(slice 138).

The rule it serves: every statement this tree constructs and signs is deterministic CBOR inside
COSE_Sign1, and bytes someone else produced are hashed exactly as received, never re-encoded.

* `Trinity.N6.CBOR`: the encoder and the strict decoder (rules E1 to E11);
* `Trinity.N6.Body`: a statement body against its declared schema;
* `Trinity.N6.Digest`: SHA-384 references to foreign bytes;
* `Trinity.N6.COSE`: the header rules, the key thumbprint, signing and verifying;
* `Trinity.N6.Profile`: the pinned draft, its limits, and every refusal code.

This is a top-level boundary with no dependency on the rest of Trinity, as `Trinity.Keys` is,
so it stays extractable: the compiler refuses a call from here into any other Trinity module.
The profile is provisional (v0, draft r3); see `Trinity.N6.Profile`. Nothing Trinity signed
before this boundary existed is changed by it.
"""

use Boundary, top_level?: true, deps: [], exports: [CBOR, Body, Digest, COSE, Profile]
end
Loading
Loading