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
4 changes: 3 additions & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ jobs:
# 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. Slice 138 adds N6's COSE_Sign1 signing and verification with ML-DSA-87.
# Slice 139 adds the receipt chains signed under ML-DSA-87, which verify here and nowhere else
# in CI (in the mode, and on the gate runners, they are the no-trust branch).
# TRINITY_REQUIRE_MLDSA87_RUNTIME makes test_helper raise if the runtime
# cannot sign, rather than excluding the cases it came to run.
#
Expand All @@ -401,4 +403,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
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ one; signing and verifying with NIST's published ML-DSA-87 test key on the FIPS
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.

### `slice/139`: one signer per statement, Ed25519 where it is allowed, and an answer for a chain the runtime cannot check

Trinity's N6 layer now has the single-signer carrier, which holds one statement with its one
signature, and Ed25519 as a second algorithm on the `commercial` profile, bound to the statement
types that name it and refused at the `nss` entry point. Both follow the profile publisher's
answers of 2026-10-19, which a later draft will carry, so both are provisional, and the carrier's
schema number is a placeholder. Nothing Trinity signs uses either yet.

Receipt verification also gets an answer where it used to crash. A chain signed under an algorithm
the runtime refuses, such as Ed25519 on a FIPS node or ML-DSA-87 on OpenSSL 3.0, now verifies to
"trust not established" (exit 5), naming the row and the algorithm. This holds in the verify task,
the receipts page, chain merges, the standalone verifier and a chain writer starting over such a
checkpoint.

Verified by the test suite: the Ed25519 key id over RFC 8032's published test key, checked against
`sha384sum`; signing that key reproduces the sample's signature byte for byte; each carrier and
header sample gives its expected outcome; receipt chains signed with ML-DSA-87 and with Ed25519
verify where the runtime has the algorithm and answer exit 5 where it does not, each branch
exercised on the CI leg whose runtime takes it. Five planted faults (no binding of `typ` to its
algorithm, Ed25519 admitted at `nss`, any `typ` admitted in the carrier, a refused algorithm told
apart from a bad key by the error alone, a key's registered algorithm ignored) each turned their
own tests red.

## 2026-10-08

### `slice/136`: the web pages ask who is there
Expand Down
16 changes: 13 additions & 3 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,26 @@ 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.
# credited in each vector's description. The ML-DSA-87 thumbprint vector's public key is NIST's, the
# ACVP case under `priv/kat/acvp/` above, and is annotated as that file is. Slice 139's Ed25519 key
# is RFC 8032 section 7.1's test 1, credited in the set's README as the RFC 8949 examples are.
[[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"]
path = ["test/fixtures/n6/thumbprint/acvp_mldsa87_tg3_tc51.pub"]
precedence = "override"
SPDX-FileCopyrightText = "National Institute of Standards and Technology"
SPDX-License-Identifier = "CC0-1.0"

# Slice 139. Exported receipt chains signed under published test keys (RFC 8032 section 7.1's test 1,
# and the NIST ACVP ML-DSA-87 case above), written by `scripts/receipt_foreign_chains.exs`. JSON
# cannot carry a comment.
[[annotations]]
path = ["test/fixtures/receipts/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "Sudo Apt Holdings LLC"
SPDX-License-Identifier = "Apache-2.0"
40 changes: 37 additions & 3 deletions bin/verify_receipt.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
# elixir verify_receipt.exs receipts.json [--schemes receipt_v3_ed25519,receipt_v2_ed25519]
#
# Exit codes, the vocabulary an operator learns once: 0 verified, 1 invalid, 2 usage,
# 5 trust not established (a key id the registry does not know), 6 compromised key.
# 5 trust not established (a key id the registry does not know, or a signature this runtime
# cannot judge: an algorithm it refuses, or a registered key it cannot use), 6 compromised key.
#
# This is a copy of Trinity.Receipts.Verifier's rules, kept small so a stranger can read it:
# the seq is the previous plus one; prev_hash is the previous receipt_hash; the scheme is
Expand Down Expand Up @@ -107,7 +108,7 @@ defmodule VerifyReceipt do
if r["kind"] in @signed_kinds do
with {:ok, sig} <- b64(r["signature_b64"], 1, {:signature_undecodable, seq}),
{:ok, pub} <- b64(row["public_key_b64"], 5, {:key_undecodable, r["key_id"]}) do
expect(verify(fam, bytes, sig, pub), 1, {:signature_invalid, seq})
check(fam, bytes, sig, pub, seq, r["key_id"], {:signature_invalid, seq})
end
else
:ok
Expand Down Expand Up @@ -152,7 +153,7 @@ defmodule VerifyReceipt do
:ok <- expect(body["last_seq"] == last and body["first_seq"] == cp["first_seq"] and body["tail_hash"] == cp["tail_hash"], 1, {:checkpoint_body_mismatch, last}),
{:ok, sig} <- b64(cp["signature_b64"], 1, {:signature_undecodable, {:checkpoint, last}}),
{:ok, pub} <- b64(row["public_key_b64"], 5, {:key_undecodable, cp["key_id"]}) do
expect(verify(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub), 1, {:checkpoint_signature_invalid, last})
check(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub, {:checkpoint, last}, cp["key_id"], {:checkpoint_signature_invalid, last})
end

if r == :ok, do: {:cont, :ok}, else: {:halt, r}
Expand All @@ -165,6 +166,39 @@ defmodule VerifyReceipt do
expect(uncovered == [], 1, {:query_receipts_uncovered, uncovered})
end

# Slice 139. :crypto.verify raises, rather than answering, where the runtime refuses the algorithm
# (FIPS mode refuses Ed25519; OpenSSL before 3.5, and FIPS mode on Red Hat's provider, refuse
# ML-DSA-87) and also for a public key it cannot use, with the same badarg and error shapes. A
# fresh key pair of the family says which: if the runtime cannot sign and verify one, it refuses
# the algorithm; if it can, the registered key is the problem. Both are 5: no judgement.
defp check(fam, bytes, sig, pub, at, key_id, invalid) do
expect(verify(fam, bytes, sig, pub), 1, invalid)
rescue
e in ErlangError ->
case e.original do
{reason, _, _} when reason in [:notsup, :badarg, :error] ->
if runs?(fam), do: {:error, 5, {:key_undecodable, key_id}}, else: {:error, 5, {:alg_unavailable, at, elem(fam, 0)}}

_ ->
reraise e, __STACKTRACE__
end
end

defp runs?(fam) do
{pub, priv} = keypair(fam)
verify(fam, "probe", sign(fam, "probe", priv), pub) == true
rescue
_ -> false
end

defp keypair({_, :eddsa, _, curve}), do: :crypto.generate_key(:eddsa, curve)
defp keypair({_, :ecdsa, _, curve}), do: :crypto.generate_key(:ecdh, curve)
defp keypair({_, :mldsa87, _, _}), do: :crypto.generate_key(:mldsa87, [])

defp sign({_, :eddsa, _, curve}, bytes, priv), do: :crypto.sign(:eddsa, :none, bytes, [priv, curve])
defp sign({_, :ecdsa, digest, curve}, bytes, priv), do: :crypto.sign(:ecdsa, digest, bytes, [priv, curve])
defp sign({_, :mldsa87, _, _}, bytes, priv), do: :crypto.sign(:mldsa87, :none, bytes, priv)

defp verify({_, :eddsa, _, curve}, bytes, sig, pub), do: :crypto.verify(:eddsa, :none, bytes, sig, [pub, curve])
defp verify({_, :ecdsa, digest, curve}, bytes, sig, pub), do: :crypto.verify(:ecdsa, digest, bytes, sig, [pub, curve])
defp verify({_, :mldsa87, _, _}, bytes, sig, pub), do: :crypto.verify(:mldsa87, :none, bytes, sig, pub)
Expand Down
25 changes: 25 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,31 @@ fingerprint, the MCP server's retry binding, and an effect receipt's digest of n
output. Each needs the exact bytes it hashes, which the layer that receives them does not keep
today.

### One signer, and a classical algorithm where the profile allows one (Slice 139, as built)

The profile's publisher answered Trinity's questions on draft r3 on 2026-10-19, ahead of the draft
that will carry the answers. Slice 139 builds the two that a single signer needs; still nothing
Trinity signs uses the layer.

- **One statement travels in one carrier.** `Trinity.N6.Carrier` holds a statement and its one
signature as `{0: SINGLE_SCHEMA, 1: payload, 2: Sign1}`, the Sign1 untagged with its payload
detached, the shape the twin envelope gives each of its two. Anything else is refused. A `typ`
whose profile entry needs two signers is refused in a single-signer carrier before its signature
is looked at: each half of a twin is a valid single signature, so accepting one would accept half
a statement as the whole. `SINGLE_SCHEMA` is a placeholder until the profile assigns the number.
- **Ed25519, only where the profile admits it, only for the `typ` bound to it.** On the
`commercial` profile a header may carry `alg` -19 (Ed25519, RFC 9864); at the `nss` entry point,
the default, only -50. An Ed25519 key's thumbprint (RFC 9679's OKP members) does not include the
algorithm, so the binding comes from two other places: the profile table binds each `typ` to its
one algorithm, and a verifier takes each key with the algorithm it was registered under, which
must be the header's.
- **A receipt chain the runtime cannot judge gets an answer.** Before this slice, verifying a chain
signed under an algorithm the runtime refuses (Ed25519 on a FIPS node, ML-DSA-87 on OpenSSL 3.0)
raised out of `:crypto`, so the verify task, the receipts page, a chain merge and a writer
rehydrating over such a checkpoint crashed. They now answer exit 5, trust not established, naming
the row and the algorithm. `:crypto` raises the same way for a registered key it cannot use, so
the verifier asks whether the runtime does the algorithm at all before saying which it was.

## 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 @@ -22,6 +22,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status
| 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 |
| N6 statements signed with Ed25519 on the `commercial` profile (RFC 9864's -19; RFC 8032 section 5.1; key id RFC 9679 section 4.1 over RFC 9053's OKP members), and the single-signer carrier | `Trinity.N6.COSE` and `Trinity.N6.Carrier`, slice 139, to the profile publisher's answers of 2026-10-19 | `test/trinity/n6/cose_test.exs`, `carrier_test.exs`; `test/fixtures/n6/cose/sign1/` | `not claimed`: the answers are not yet in a posted draft, the carrier's schema number is a placeholder, and no statement Trinity signs uses either | 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
Loading
Loading