Skip to content

Commit 06da183

Browse files
authored
Merge pull request #133 from ScriptKittyOS/slice/138-n6-cbor-cose
feat(s138): deterministic CBOR and COSE_Sign1 with ML-DSA-87
2 parents 6b05aa9 + d8efb43 commit 06da183

361 files changed

Lines changed: 3192 additions & 3 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/gate.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,8 @@ jobs:
380380
# runtime in CI that signs ML-DSA-87 (the gate runners link 3.0; in the mode, Red Hat's
381381
# provider refuses it), so the ML-DSA-87 path runs here end to end: the planted-step cases,
382382
# a regulated node that boots with ML-DSA-87 receipts and whose boot receipt verifies, and the
383-
# signer's key shapes. TRINITY_REQUIRE_MLDSA87_RUNTIME makes test_helper raise if the runtime
383+
# signer's key shapes. Slice 138 adds N6's COSE_Sign1 signing and verification with ML-DSA-87.
384+
# TRINITY_REQUIRE_MLDSA87_RUNTIME makes test_helper raise if the runtime
384385
# cannot sign, rather than excluding the cases it came to run.
385386
#
386387
# It runs last, from a fresh suite state. The suite keeps its receipt keys in tmp/test_keys
@@ -394,4 +395,4 @@ jobs:
394395
TRINITY_REQUIRE_MLDSA87_RUNTIME: "1"
395396
run: |
396397
rm -rf tmp trinity_test.db* trinity_test_receipts.db*
397-
mix test test/trinity/crypto/preflight_test.exs test/trinity/toolchain_node_test.exs test/trinity/receipts/signer_test.exs
398+
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

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ refusing in its own operating system process; the floor at every boundary and a
4747
generated versions; every step of the ML-DSA-87 check failing by name; and, on the FIPS image with
4848
the mode off, the planted failures against the real cryptographic library.
4949

50+
### `slice/138`: deterministic CBOR and COSE_Sign1 with ML-DSA-87
51+
52+
Trinity can now build and check signed statements in the N6 profile: deterministic CBOR (RFC 8949
53+
section 4.2.1, narrowed to integers, byte and text strings, arrays, integer-keyed maps and three
54+
simple values) inside COSE_Sign1, signed with ML-DSA-87 (RFC 9964). The decoder refuses any byte
55+
string that is not exactly the deterministic encoding of such a value and names the first defect;
56+
a statement's key id is the RFC 9679 thumbprint of its public key; a verifier that cannot run
57+
ML-DSA-87 says it cannot judge rather than failing or passing. Bytes received from elsewhere are
58+
referenced by their SHA-384 digest, never re-encoded first. The profile is provisional (v0, draft
59+
r3), so this is groundwork: nothing Trinity signed before changes, and no statement it signs uses
60+
the new format yet. The project's vectors for it, written from RFC 8949's own examples and from
61+
CBOR heads by hand, are checked in with a `sha384sum` manifest.
62+
63+
Verified by the test suite: every vector, encoded and decoded, at the top level and nested; a
64+
property over generated values; each limit at its value and one past it; the header rules one by
65+
one; signing and verifying with NIST's published ML-DSA-87 test key on the FIPS image with the mode
66+
off; a decoder planted to accept one defect class failing that class and no other; and an opt-in
67+
run of 1,000,000 generated statements whose 13,000,000 mutants were all refused.
68+
5069
## 2026-10-08
5170

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

‎REUSE.toml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,19 @@ path = ["priv/kat/wycheproof/**"]
8484
precedence = "override"
8585
SPDX-FileCopyrightText = "Google LLC and the C2SP Wycheproof contributors"
8686
SPDX-License-Identifier = "Apache-2.0"
87+
88+
# Slice 138. The N6 vectors `scripts/n6_vectors.exs` writes: CBOR, diagnostic notation and expected
89+
# outcomes, none of which can carry a comment. The RFC 8949 Appendix A encodings among them are
90+
# credited in each vector's description. The thumbprint vector's public key is NIST's, the ACVP
91+
# case under `priv/kat/acvp/` above, and is annotated as that file is.
92+
[[annotations]]
93+
path = ["test/fixtures/n6/**"]
94+
precedence = "aggregate"
95+
SPDX-FileCopyrightText = "Sudo Apt Holdings LLC"
96+
SPDX-License-Identifier = "Apache-2.0"
97+
98+
[[annotations]]
99+
path = ["test/fixtures/n6/thumbprint/*.pub"]
100+
precedence = "override"
101+
SPDX-FileCopyrightText = "National Institute of Standards and Technology"
102+
SPDX-License-Identifier = "CC0-1.0"

‎docs/07-security-model.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,39 @@ fail-open. Where it signs today: a runtime linked to OpenSSL 3.5 or later with t
546546
mode, on the image's current FIPS provider, it does not; a provider validated with ML-DSA-87
547547
(CMVP #5523) is what would change that.
548548

549+
## Signed statements in one byte form (Slice 138, as built)
550+
551+
The rule: every statement Trinity constructs and signs is deterministic CBOR inside COSE_Sign1,
552+
and bytes someone else produced are hashed exactly as received, never parsed and re-encoded first.
553+
Two encoders that agree on a value's meaning but not its bytes produce two signatures for one
554+
statement, and a verifier that re-encodes before checking verifies its own bytes, not the
555+
signer's. `Trinity.N6` is the layer that rule needs; nothing signs through it yet, and receipts keep
556+
their slice 024 format until a later slice moves them.
557+
558+
- **The codec refuses rather than repairs.** `Trinity.N6.CBOR.decode/1` accepts a byte string only
559+
if it is exactly the deterministic encoding of a value in the subset: shortest heads, definite
560+
lengths, unsigned-integer map keys in bytewise order with no duplicate, no float, tag or other
561+
simple value, NFC text, within the four limits. It names the first defect and returns nothing
562+
from an input that fails. A forged length larger than the input is refused before anything is
563+
read.
564+
- **The header is closed.** The protected header carries exactly `alg` -50 (ML-DSA-87), `kid` and
565+
`typ`, plus CWT claims for a SCITT-renderable `typ`; content type, `crit`, the hash-envelope
566+
labels and every other label are refused, and the unprotected header must be empty, so no
567+
unsigned field can change what a statement claims.
568+
- **The key id is the key.** `kid` is the RFC 9679 SHA-384 thumbprint over the public key and
569+
`alg`, and the verifier recomputes it from the key it holds, so a key cannot be presented under
570+
another algorithm or another key's name.
571+
- **Cannot judge is not a pass.** A runtime that cannot run ML-DSA-87 (FIPS mode on a provider
572+
that refuses it, or OpenSSL 3.0) makes `verify/2` answer exit 5, no trust, with
573+
`alg_unavailable`, never a crash and never a pass; an unknown `kid` is exit 5 too.
574+
575+
`test/trinity/n6/census_test.exs` holds every hashing and signing call in `lib/` with a class.
576+
Four sites re-encode data from elsewhere before hashing it, which the rule forbids, and are
577+
declared there rather than changed here: the tool-definition drift digest, the approval
578+
fingerprint, the MCP server's retry binding, and an effect receipt's digest of non-binary tool
579+
output. Each needs the exact bytes it hashes, which the layer that receives them does not keep
580+
today.
581+
549582
## The effect population, published (Slice 027, as built)
550583

551584
The set of things that can cause an effect is small, derived from the tree, and published as

‎docs/09-standards-register.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status
2121
| 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 |
2222
| 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 |
2323
| 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 |
24+
| 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 |
2425
| 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 |
2526
| 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 |
2627
| 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 |

‎docs/regulated/control-mapping.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ register does not claim is never Trinity's.
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 |
120120
| `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 |
121+
| `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 |
121122
| `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" |
122123
| `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 |
123124
| `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 |

‎docs/regulated/crypto-inventory.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ to that provider, on that host, not to this software. **Anything not established
2727
| Device identifier | `lib/trinity/receipts/clock.ex` | `:crypto.strong_rand_bytes/1` | Not applicable | Random, names nothing about the host |
2828
| JWK thumbprint | `lib/trinity/receipts/signer.ex` | `:crypto.hash(:sha256, …)` | Not applicable | RFC 7638 |
2929
| Canonical JSON | `lib/trinity/receipts/envelope.ex:15` | `Jcs.encode/1` | Not applicable | RFC 8785, not a cryptographic primitive |
30+
| 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 |
31+
| N6 key id | `lib/trinity/n6/cose.ex` | `:crypto.hash(:sha384, …)` | Not applicable | RFC 9679 thumbprint over RFC 9964's AKP members |
32+
| N6 foreign-bytes digest | `lib/trinity/n6/digest.ex` | `:crypto.hash(:sha384, …)` | **UNKNOWN** | Over bytes exactly as received, never re-encoded |
33+
| Deterministic CBOR | `lib/trinity/n6/cbor.ex` | pure Elixir | Not applicable | RFC 8949 section 4.2.1, not a cryptographic primitive |
3034

3135
## Why every certificate says UNKNOWN
3236

‎lib/trinity/n6.ex‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
2+
# SPDX-License-Identifier: Apache-2.0
3+
defmodule Trinity.N6 do
4+
@moduledoc """
5+
The N6 profile: deterministic CBOR and COSE_Sign1 with ML-DSA-87 for signed statements
6+
(slice 138).
7+
8+
The rule it serves: every statement this tree constructs and signs is deterministic CBOR inside
9+
COSE_Sign1, and bytes someone else produced are hashed exactly as received, never re-encoded.
10+
11+
* `Trinity.N6.CBOR`: the encoder and the strict decoder (rules E1 to E11);
12+
* `Trinity.N6.Body`: a statement body against its declared schema;
13+
* `Trinity.N6.Digest`: SHA-384 references to foreign bytes;
14+
* `Trinity.N6.COSE`: the header rules, the key thumbprint, signing and verifying;
15+
* `Trinity.N6.Profile`: the pinned draft, its limits, and every refusal code.
16+
17+
This is a top-level boundary with no dependency on the rest of Trinity, as `Trinity.Keys` is,
18+
so it stays extractable: the compiler refuses a call from here into any other Trinity module.
19+
The profile is provisional (v0, draft r3); see `Trinity.N6.Profile`. Nothing Trinity signed
20+
before this boundary existed is changed by it.
21+
"""
22+
23+
use Boundary, top_level?: true, deps: [], exports: [CBOR, Body, Digest, COSE, Profile]
24+
end

0 commit comments

Comments
 (0)