From 645e4bd3276b8c96e1cef8117e237ec715ca8a74 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 08:09:35 -0400 Subject: [PATCH 1/2] feat(s139): complete slice 139 (the N6 single-signer carrier, Ed25519 on commercial; the receipt verifier answers in FIPS mode) Signed-off-by: Ayla Croft --- .github/workflows/gate.yml | 4 +- CHANGELOG.md | 23 ++ REUSE.toml | 16 +- bin/verify_receipt.exs | 40 ++- docs/07-security-model.md | 25 ++ docs/09-standards-register.md | 1 + docs/regulated/control-mapping.md | 2 +- docs/regulated/crypto-inventory.md | 5 +- lib/trinity/n6.ex | 10 +- lib/trinity/n6/carrier.ex | 99 +++++++ lib/trinity/n6/cose.ex | 252 +++++++++++++----- lib/trinity/n6/profile.ex | 42 ++- lib/trinity/receipts/chain_writer.ex | 27 +- lib/trinity/receipts/signer.ex | 28 ++ lib/trinity/receipts/verifier.ex | 34 ++- scripts/n6_vectors.exs | 132 ++++++++- scripts/receipt_foreign_chains.exs | 174 ++++++++++++ test/fixtures/n6/README.md | 34 ++- test/fixtures/n6/SHA384SUMS | 52 +++- test/fixtures/n6/VERSION | 2 +- .../n6/cose/sign1/carrier_extra_key.bin | Bin 0 -> 198 bytes .../n6/cose/sign1/carrier_extra_key.ctx.json | 1 + .../n6/cose/sign1/carrier_extra_key.expect | 1 + .../n6/cose/sign1/carrier_key0_envelope.bin | Bin 0 -> 196 bytes .../cose/sign1/carrier_key0_envelope.ctx.json | 1 + .../cose/sign1/carrier_key0_envelope.expect | 1 + .../cose/sign1/carrier_keys_out_of_order.bin | Bin 0 -> 196 bytes .../sign1/carrier_keys_out_of_order.ctx.json | 1 + .../sign1/carrier_keys_out_of_order.expect | 1 + .../n6/cose/sign1/carrier_no_sign1.bin | Bin 0 -> 23 bytes .../n6/cose/sign1/carrier_no_sign1.ctx.json | 1 + .../n6/cose/sign1/carrier_no_sign1.expect | 1 + .../n6/cose/sign1/carrier_not_map.bin | Bin 0 -> 193 bytes .../n6/cose/sign1/carrier_not_map.ctx.json | 1 + .../n6/cose/sign1/carrier_not_map.expect | 1 + .../n6/cose/sign1/carrier_payload_text.bin | Bin 0 -> 191 bytes .../cose/sign1/carrier_payload_text.ctx.json | 1 + .../n6/cose/sign1/carrier_payload_text.expect | 1 + .../cose/sign1/ed25519_signature_flipped.bin | Bin 0 -> 196 bytes .../sign1/ed25519_signature_flipped.ctx.json | 1 + .../sign1/ed25519_signature_flipped.expect | 1 + .../fixtures/n6/cose/sign1/ed25519_single.bin | Bin 0 -> 196 bytes .../n6/cose/sign1/ed25519_single.ctx.json | 1 + .../n6/cose/sign1/ed25519_single.expect | 1 + .../n6/cose/sign1/ed25519_single_at_nss.bin | Bin 0 -> 196 bytes .../cose/sign1/ed25519_single_at_nss.ctx.json | 1 + .../cose/sign1/ed25519_single_at_nss.expect | 1 + .../cose/sign1/pair_typ_in_single_carrier.bin | Bin 0 -> 4758 bytes .../sign1/pair_typ_in_single_carrier.ctx.json | 1 + .../sign1/pair_typ_in_single_carrier.expect | 1 + .../fixtures/n6/cose/sign1/sign1_attached.bin | Bin 0 -> 210 bytes .../n6/cose/sign1/sign1_attached.ctx.json | 1 + .../n6/cose/sign1/sign1_attached.expect | 1 + test/fixtures/n6/cose/sign1/sign1_tagged.bin | Bin 0 -> 197 bytes .../n6/cose/sign1/sign1_tagged.ctx.json | 1 + .../n6/cose/sign1/sign1_tagged.expect | 1 + .../n6/cose/sign1/sign1_unprotected_kid.bin | Bin 0 -> 199 bytes .../cose/sign1/sign1_unprotected_kid.ctx.json | 1 + .../cose/sign1/sign1_unprotected_kid.expect | 1 + .../ed25519_under_mldsa87_typ.cbor | 2 + .../ed25519_under_mldsa87_typ.expect | 1 + .../mldsa87_under_ed25519_typ.cbor | 1 + .../mldsa87_under_ed25519_typ.expect | 1 + .../header/positive/ed25519_alg_kid_typ.cbor | 2 + test/fixtures/n6/header/profile-table.json | 2 +- .../n6/thumbprint/rfc8032_test1_ed25519.kid | 2 + .../n6/thumbprint/rfc8032_test1_ed25519.pub | 2 + .../fixtures/receipts/ed25519_checkpoint.json | 1 + test/fixtures/receipts/ed25519_signed.json | 1 + .../fixtures/receipts/mldsa87_checkpoint.json | 1 + test/fixtures/receipts/mldsa87_signed.json | 1 + test/support/ed25519_oracle.ex | 21 ++ test/support/n6_vectors.ex | 65 ++++- test/trinity/n6/carrier_test.exs | 87 ++++++ test/trinity/n6/census_test.exs | 3 +- test/trinity/n6/cose_signing_test.exs | 22 +- test/trinity/n6/cose_test.exs | 167 +++++++++++- .../receipts/foreign_algorithm_test.exs | 160 +++++++++++ 78 files changed, 1429 insertions(+), 141 deletions(-) create mode 100644 lib/trinity/n6/carrier.ex create mode 100644 scripts/receipt_foreign_chains.exs create mode 100644 test/fixtures/n6/cose/sign1/carrier_extra_key.bin create mode 100644 test/fixtures/n6/cose/sign1/carrier_extra_key.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/carrier_extra_key.expect create mode 100644 test/fixtures/n6/cose/sign1/carrier_key0_envelope.bin create mode 100644 test/fixtures/n6/cose/sign1/carrier_key0_envelope.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/carrier_key0_envelope.expect create mode 100644 test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.bin create mode 100644 test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.expect create mode 100644 test/fixtures/n6/cose/sign1/carrier_no_sign1.bin create mode 100644 test/fixtures/n6/cose/sign1/carrier_no_sign1.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/carrier_no_sign1.expect create mode 100644 test/fixtures/n6/cose/sign1/carrier_not_map.bin create mode 100644 test/fixtures/n6/cose/sign1/carrier_not_map.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/carrier_not_map.expect create mode 100644 test/fixtures/n6/cose/sign1/carrier_payload_text.bin create mode 100644 test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/carrier_payload_text.expect create mode 100644 test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin create mode 100644 test/fixtures/n6/cose/sign1/ed25519_signature_flipped.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/ed25519_signature_flipped.expect create mode 100644 test/fixtures/n6/cose/sign1/ed25519_single.bin create mode 100644 test/fixtures/n6/cose/sign1/ed25519_single.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/ed25519_single.expect create mode 100644 test/fixtures/n6/cose/sign1/ed25519_single_at_nss.bin create mode 100644 test/fixtures/n6/cose/sign1/ed25519_single_at_nss.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/ed25519_single_at_nss.expect create mode 100644 test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.bin create mode 100644 test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.expect create mode 100644 test/fixtures/n6/cose/sign1/sign1_attached.bin create mode 100644 test/fixtures/n6/cose/sign1/sign1_attached.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/sign1_attached.expect create mode 100644 test/fixtures/n6/cose/sign1/sign1_tagged.bin create mode 100644 test/fixtures/n6/cose/sign1/sign1_tagged.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/sign1_tagged.expect create mode 100644 test/fixtures/n6/cose/sign1/sign1_unprotected_kid.bin create mode 100644 test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/sign1_unprotected_kid.expect create mode 100644 test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.cbor create mode 100644 test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect create mode 100644 test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.cbor create mode 100644 test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect create mode 100644 test/fixtures/n6/header/positive/ed25519_alg_kid_typ.cbor create mode 100644 test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.kid create mode 100644 test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.pub create mode 100644 test/fixtures/receipts/ed25519_checkpoint.json create mode 100644 test/fixtures/receipts/ed25519_signed.json create mode 100644 test/fixtures/receipts/mldsa87_checkpoint.json create mode 100644 test/fixtures/receipts/mldsa87_signed.json create mode 100644 test/support/ed25519_oracle.ex create mode 100644 test/trinity/n6/carrier_test.exs create mode 100644 test/trinity/receipts/foreign_algorithm_test.exs diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 0c12039..1f4a4d0 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -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. # @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d33880..506bde1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/REUSE.toml b/REUSE.toml index bf5229c..00f57ac 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -87,8 +87,9 @@ 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" @@ -96,7 +97,16 @@ 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" diff --git a/bin/verify_receipt.exs b/bin/verify_receipt.exs index c935159..fabe9bf 100755 --- a/bin/verify_receipt.exs +++ b/bin/verify_receipt.exs @@ -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 @@ -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 @@ -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} @@ -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) diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 516ff10..cfed16d 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -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 diff --git a/docs/09-standards-register.md b/docs/09-standards-register.md index 6dede1f..67f1cd7 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -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 | diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md index caddd90..b86c236 100644 --- a/docs/regulated/control-mapping.md +++ b/docs/regulated/control-mapping.md @@ -118,7 +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-13` | Signed statements have one byte form: deterministic CBOR inside COSE_Sign1 with ML-DSA-87, or Ed25519 on the `commercial` profile only (slice 139). 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 at `commercial`, for the `typ` bound to it), the key's RFC 9679 thumbprint as `kid`, and `typ`; a single-signer carrier refuses a `typ` that needs two signers; 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. 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, 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/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 | diff --git a/docs/regulated/crypto-inventory.md b/docs/regulated/crypto-inventory.md index 700a18c..29f753b 100644 --- a/docs/regulated/crypto-inventory.md +++ b/docs/regulated/crypto-inventory.md @@ -15,7 +15,7 @@ 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 | -| Receipt signing, opt-in | `lib/trinity/receipts/signer/mldsa87.ex` | `:crypto` ML-DSA-87, FIPS 204 | **UNKNOWN** | Never the default. `available?/0` line 24 checks `:mldsa87 in :crypto.supports(:public_keys)` | +| 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 hashing | `lib/trinity/receipts/envelope.ex:36` | `:crypto.hash(:sha256, …)` | **UNKNOWN** | Over the DSSE pre-authentication encoding, not the bare payload | | 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 | | Blob encryption at rest | `lib/trinity/vault.ex:180` | `:crypto.crypto_one_time_aead(:aes_256_gcm, …)` | **UNKNOWN** | Fresh 256-bit data key per blob, `docs/encryption-at-rest.md` | @@ -28,7 +28,8 @@ to that provider, on that host, not to this software. **Anything not established | 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 statement signing, `commercial` profile (slice 139) | `lib/trinity/n6/cose.ex` | `:crypto.sign(:eddsa, :none, …, :ed25519)` and `:crypto.verify/5` | **UNKNOWN** | Ed25519, RFC 8032 section 5.1, as COSE -19 (RFC 9864). Refused at the `nss` entry point; FIPS mode refuses it, and verification there answers no trust. Not used by any statement yet | +| N6 key id | `lib/trinity/n6/cose.ex` | `:crypto.hash(:sha384, …)` | Not applicable | RFC 9679 thumbprint over RFC 9964's AKP members (ML-DSA-87) or RFC 9053's OKP members (Ed25519) | | 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 | diff --git a/lib/trinity/n6.ex b/lib/trinity/n6.ex index 0873e20..6b5cf56 100644 --- a/lib/trinity/n6.ex +++ b/lib/trinity/n6.ex @@ -2,8 +2,8 @@ # 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 N6 profile: deterministic CBOR and COSE_Sign1 for signed statements, with ML-DSA-87 + (slice 138) and, on the `commercial` profile, Ed25519 (slice 139). 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. @@ -12,13 +12,15 @@ defmodule Trinity.N6 do * `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.Carrier`: the single-signer carrier, one statement with its one Sign1; * `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 + The profile is provisional (v0, draft r3, and the publisher's answers of 2026-10-19); 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] + use Boundary, top_level?: true, deps: [], exports: [CBOR, Body, Digest, COSE, Carrier, Profile] end diff --git a/lib/trinity/n6/carrier.ex b/lib/trinity/n6/carrier.ex new file mode 100644 index 0000000..3c935af --- /dev/null +++ b/lib/trinity/n6/carrier.ex @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.Carrier do + @moduledoc """ + The single-signer carrier (slice 139): how one N6 statement with one signature travels. + + {0: SINGLE_SCHEMA, 1: payload, 2: [protected, {}, nil, signature]} + + The profile publisher's answer of 2026-10-19 (item 3), ahead of the draft that will carry it: key + 0 is a schema number in the reserved carrier range, key 1 the payload as a byte string (the N6 + statement body), key 2 the Sign1, untagged and with its payload field nil (detached), as the twin + envelope holds each of its two. ToBeSigned is over key 1's bytes (`Trinity.N6.COSE`). The tagged, + attached Sign1 is a rendering only (`COSE.render/2`), never this carrier. + + `SINGLE_SCHEMA` is a placeholder, 0xFFFF0003, until the profile assigns it + (`Trinity.N6.Profile.schemas/0`). + + ## Refusals + + The carrier is decoded whole by the codec first, so an encoding defect anywhere in it, a tagged + Sign1 among them (`tag_forbidden`, E6), is the codec's code. Then the shape: anything but a map of + exactly keys 0, 1 and 2, with key 0 the single-signer schema, key 1 a byte string and key 2 a + four-element Sign1 with a nil payload, is `carrier_malformed`. The unprotected header follows the + rules `COSE.parse/3` applies. + + `verify/2` also refuses a `typ` whose table entry is not `carrier: single, signers: single` + (`wrong_carrier_for_typ`), before any signature is looked at: each of a twin's two signatures is a + valid single signature on its own, so a single-signer entry point that accepted one would accept + half a twin as a whole statement. + """ + + alias Trinity.N6.{CBOR, COSE, Profile} + + @doc "The carrier bytes for a Sign1's parts." + @spec encode(COSE.parts()) :: binary() + def encode(%{protected: p, payload: payload, signature: s}) + when is_binary(p) and is_binary(payload) and is_binary(s) do + CBOR.encode!(%{ + 0 => Profile.schema(:single), + 1 => {:bytes, payload}, + 2 => [{:bytes, p}, %{}, nil, {:bytes, s}] + }) + end + + @doc "A carrier's Sign1 parts, strictly decoded, or the code of its first defect." + @spec decode(binary()) :: {:ok, COSE.parts()} | {:error, Profile.code()} + def decode(bytes) when is_binary(bytes) do + with {:ok, value} <- codec(bytes), + {:ok, payload, sign1} <- shape(value), + {:ok, p, s} <- sign1(sign1) do + {:ok, %{protected: p, payload: payload, signature: s}} + end + end + + @doc """ + Verifies a carrier. `opts` as `COSE.verify/2` takes them (`keys`, `typ`, `entry`). The `typ`'s + table entry must say `carrier: single` and `signers: single`. + """ + @spec verify(binary(), keyword()) :: + {:ok, %{exit: 0, verdict_code: :claim, kid: binary(), typ: String.t()}} + | {:error, 1 | 5, Profile.code()} + def verify(bytes, opts) when is_binary(bytes) do + table = Keyword.fetch!(opts, :typ) + entry_point = Keyword.get(opts, :entry, :nss) + + with {:ok, parts} <- decode(bytes), + {:ok, %{entry: entry}} <- COSE.decode_protected(parts.protected, table, entry_point), + :ok <- single(entry) do + COSE.verify(parts, opts) + else + {:error, code} -> {:error, Profile.exit_of(code), code} + end + end + + defp codec(bytes) do + case CBOR.decode(bytes) do + {:ok, value} -> {:ok, value} + {:error, code, _offset} -> {:error, code} + end + end + + defp shape(%{0 => schema, 1 => {:bytes, payload}, 2 => sign1} = map) + when map_size(map) == 3 do + if schema == Profile.schema(:single), + do: {:ok, payload, sign1}, + else: {:error, :carrier_malformed} + end + + defp shape(_other), do: {:error, :carrier_malformed} + + defp sign1([{:bytes, p}, unprotected, nil, {:bytes, s}]) do + with :ok <- COSE.unprotected(unprotected), do: {:ok, p, s} + end + + defp sign1(_other), do: {:error, :carrier_malformed} + + defp single(%{carrier: :single, signers: :single}), do: :ok + defp single(_entry), do: {:error, :wrong_carrier_for_typ} +end diff --git a/lib/trinity/n6/cose.ex b/lib/trinity/n6/cose.ex index 7e6693f..78c2e88 100644 --- a/lib/trinity/n6/cose.ex +++ b/lib/trinity/n6/cose.ex @@ -2,22 +2,39 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.N6.COSE do @moduledoc """ - COSE_Sign1 under the N6 profile (its section 5; slice 138): ML-DSA-87 only, the header rules, - the key thumbprint, signing and verifying. + COSE_Sign1 under the N6 profile (its section 5; slice 138): the header rules, the key + thumbprint, signing and verifying. ML-DSA-87 (-50) at every entry point; Ed25519 (-19) on the + `commercial` profile only (slice 139, the profile publisher's answers of 2026-10-19). ## The protected header - {1: -50, 4: kid, 16: typ, ? 15: {1: iss, 2: sub}} + {1: alg, 4: kid, 16: typ, ? 15: {1: iss, 2: sub}} `alg` -50 is ML-DSA-87 (RFC 9964), pure, with an empty context string (RFC 9964 section 5). - `kid` is the RFC 9679 COSE Key Thumbprint, SHA-384, over the AKP members RFC 9964 section 6 - names: `{1: 7, 3: -50, -1: pub}`, encoded under RFC 8949 section 4.2.1, which for a 2,592-byte key - is exactly `a3 01 07 03 38 31 20 59 0a 20` followed by the key. The thumbprint includes `alg`, so a - key cannot be used with another algorithm. `typ` (RFC 9596) is a media type, compared byte for - byte against the table the caller passes; CWT claims (label 15) are present exactly when the - table marks the `typ` as SCITT-renderable. - - The protected label set is closed to {1, 4, 15, 16}: content type (3) is refused with its own + `alg` -19 is Ed25519 (RFC 9864), pure EdDSA as RFC 8032 section 5.1 defines it. + + `kid` is the RFC 9679 COSE Key Thumbprint, SHA-384, encoded under RFC 8949 section 4.2.1: + + * for ML-DSA-87, over the AKP members RFC 9964 section 6 names, `{1: 7, 3: -50, -1: pub}`, which + for a 2,592-byte key is exactly `a3 01 07 03 38 31 20 59 0a 20` followed by the key. The + thumbprint includes `alg`, so the key cannot be used with another algorithm; + * for Ed25519, over the OKP members RFC 9679 section 4.1 names, `{1: 1, -1: 6, -2: x}` (OKP and + Ed25519 are 1 and 6 in RFC 9053 section 7.1), which for a 32-byte key is exactly + `a3 01 01 20 06 21 58 20` followed by the key. This thumbprint has no `alg`, so the binding + comes from elsewhere: the profile table binds each `typ` to its one algorithm, and a key is + registered with its algorithm, which must be the header's. + + `typ` (RFC 9596) is a media type, compared byte for byte against the table the caller passes. + Each entry names the `typ`'s one `alg`, its carrier and signers (`Trinity.N6.Carrier` reads + those), and whether it is SCITT-renderable: CWT claims (label 15) are present exactly when it is. + + ## Entry points + + The caller says which entry point it is (`entry:`, `:nss` when it says nothing). At `:nss` only + -50 is permitted; at `:commercial`, -50 and -19. A header whose `alg` is not the one its `typ` is + bound to is refused at either. All three refusals are `alg_not_permitted`. + + The protected label set, for both algorithms, is closed to {1, 4, 15, 16}: content type (3) is refused with its own code, and every other label, `crit` (2) and the hash-envelope labels (258 to 260) among them, with `header_label_forbidden`. The header is strictly decoded (`Trinity.N6.CBOR`); a key that is not an unsigned integer is `header_label_forbidden` and every other encoding defect @@ -39,23 +56,30 @@ defmodule Trinity.N6.COSE do `verify/2` answers the profile's exit classes: 0 with `:claim`, 1 (invalid) with the code of the rule that failed, or 5 (no trust) when the key is unknown (`policy_stale_or_unknown_key`) or the - runtime cannot run ML-DSA-87 at all (`alg_unavailable`, this tree's code). A runtime that refuses - the algorithm is never a crash and never a pass. The attribution classes 6 and 7 need the - profile's key descriptors and are not decided here. + runtime cannot run the header's algorithm at all (`alg_unavailable`, this tree's code; FIPS mode + refuses Ed25519). A runtime that refuses the algorithm is never a crash and never a pass. The + attribution classes 6 and 7 need the profile's key descriptors and are not decided here. ## Keys - The private key is the 32-byte seed RFC 9964 section 4 defines, as `{:seed, seed}`, or the - expanded key OTP's `generate_key/2` returns. The public key is passed beside it: on OTP 28.5.0.8 - no form of `:crypto.generate_key/3` derives it from a seed. + A key names its algorithm: `%{alg: -50 | -19, public: pub, private: priv}` to sign, and + `{:ok, %{alg: alg, public: pub}}` from the `keys` function a verifier passes. The algorithm comes + from the key's registration, never from its size or from the header. + + An ML-DSA-87 private key is the 32-byte seed RFC 9964 section 4 defines, as `{:seed, seed}`, or + the expanded key OTP's `generate_key/2` returns; `alg` may be left out, as it was before slice + 139. The public key is passed beside it: on OTP 28.5.0.8 no form of `:crypto.generate_key/3` + derives it from a seed. An Ed25519 private key is its 32-byte secret (RFC 8032 section 5.1.5). """ alias Trinity.N6.{CBOR, Profile} - @alg -50 - @pub_size 2592 - @sig_size 4627 - @thumbprint_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> + @mldsa87 -50 + @ed25519 -19 + @sizes %{@mldsa87 => %{public: 2592, signature: 4627}, @ed25519 => %{public: 32, signature: 64}} + @permitted %{nss: [@mldsa87], commercial: [@mldsa87, @ed25519]} + @akp_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> + @okp_prefix <<0xA3, 0x01, 0x01, 0x20, 0x06, 0x21, 0x58, 0x20>> @labels [1, 4, 15, 16] @required [1, 4, 16] @countersignature [7, 9, 11, 12] @@ -64,21 +88,52 @@ defmodule Trinity.N6.COSE do @typedoc "One COSE_Sign1: the protected header bytes, the payload, the signature." @type parts :: %{protected: binary(), payload: binary(), signature: binary()} - @typedoc "The `typ` values an entry point accepts, each saying whether it is SCITT-renderable." - @type typ_table :: %{optional(String.t()) => %{scitt: boolean()}} + @typedoc "An algorithm N6 permits somewhere: -50 (ML-DSA-87) or -19 (Ed25519)." + @type alg :: -50 | -19 - @typedoc "An ML-DSA-87 key: the public key and the private key as a seed or expanded." - @type key :: %{public: binary(), private: {:seed, binary()} | binary()} + @typedoc "The entry point a verifier is: the `nss` profile, or `commercial`." + @type entry_point :: :nss | :commercial + + @typedoc """ + The `typ` values an entry point accepts: each one's algorithm, carrier, signers, and whether it is + SCITT-renderable. + """ + @type typ_table :: %{ + optional(String.t()) => %{ + required(:scitt) => boolean(), + required(:alg) => alg(), + optional(:carrier) => :single | :envelope | :note, + optional(:signers) => :single | :pair + } + } + + @typedoc "A signing key: its algorithm, its public key, and its private key." + @type key :: %{ + optional(:alg) => alg(), + public: binary(), + private: {:seed, binary()} | binary() + } @doc "The RFC 9679 SHA-384 thumbprint of an ML-DSA-87 public key: its `kid`." @spec thumbprint(binary()) :: binary() - def thumbprint(pub) when byte_size(pub) == @pub_size, - do: :crypto.hash(:sha384, @thumbprint_prefix <> pub) + def thumbprint(pub), do: thumbprint(@mldsa87, pub) - @doc "The protected header bytes for a `kid` and `typ`, with CWT `{iss, sub}` when given." - @spec protected_header(binary(), String.t(), {String.t(), String.t()} | nil) :: binary() - def protected_header(kid, typ, cwt \\ nil) when byte_size(kid) == 48 and is_binary(typ) do - header = %{1 => @alg, 4 => {:bytes, kid}, 16 => {:text, typ}} + @doc "The RFC 9679 SHA-384 thumbprint of a public key of an algorithm: its `kid`." + @spec thumbprint(alg(), binary()) :: binary() + def thumbprint(@mldsa87, pub) when byte_size(pub) == 2592, + do: :crypto.hash(:sha384, @akp_prefix <> pub) + + def thumbprint(@ed25519, pub) when byte_size(pub) == 32, + do: :crypto.hash(:sha384, @okp_prefix <> pub) + + @doc """ + The protected header bytes for a `kid` and `typ`, with CWT `{iss, sub}` when given, under `alg` + (-50 when not given). + """ + @spec protected_header(binary(), String.t(), {String.t(), String.t()} | nil, alg()) :: binary() + def protected_header(kid, typ, cwt \\ nil, alg \\ @mldsa87) + when byte_size(kid) == 48 and is_binary(typ) and is_map_key(@sizes, alg) do + header = %{1 => alg, 4 => {:bytes, kid}, 16 => {:text, typ}} header = case cwt do @@ -89,18 +144,25 @@ defmodule Trinity.N6.COSE do CBOR.encode!(header) end - @doc "Strictly decodes protected header bytes under the header rules and a `typ` table." - @spec decode_protected(binary(), typ_table()) :: - {:ok, %{kid: binary(), typ: String.t(), cwt: map() | nil}} | {:error, Profile.code()} - def decode_protected(bytes, table) when is_binary(bytes) and is_map(table) do + @doc """ + Strictly decodes protected header bytes under the header rules, a `typ` table and an entry point + (`:nss` when none is given). The result carries the `typ`'s table entry, for the carrier rules. + """ + @spec decode_protected(binary(), typ_table(), entry_point()) :: + {:ok, %{alg: alg(), kid: binary(), typ: String.t(), cwt: map() | nil, entry: map()}} + | {:error, Profile.code()} + def decode_protected(bytes, table, entry_point \\ :nss) + when is_binary(bytes) and is_map(table) and is_map_key(@permitted, entry_point) do with {:ok, header} <- decode_header_map(bytes), :ok <- labels(header), :ok <- present(header), - :ok <- alg(header), + {:ok, alg} <- alg(header), + :ok <- permitted(alg, entry_point), {:ok, kid} <- kid(header), {:ok, typ, entry} <- typ(header, table), + :ok <- bound(alg, entry), {:ok, cwt} <- cwt(header, entry) do - {:ok, %{kid: kid, typ: typ, cwt: cwt}} + {:ok, %{alg: alg, kid: kid, typ: typ, cwt: cwt, entry: entry}} end end @@ -127,9 +189,17 @@ defmodule Trinity.N6.COSE do else: {:error, :header_not_protected} end - defp alg(%{1 => @alg}), do: :ok + defp alg(%{1 => alg}) when is_map_key(@sizes, alg), do: {:ok, alg} defp alg(_header), do: {:error, :alg_not_permitted} + defp permitted(alg, entry_point) do + if alg in Map.fetch!(@permitted, entry_point), do: :ok, else: {:error, :alg_not_permitted} + end + + # The publisher's answer of 2026-10-19: the table binds each `typ` to its one algorithm. + defp bound(alg, %{alg: alg}), do: :ok + defp bound(_alg, _entry), do: {:error, :alg_not_permitted} + defp kid(%{4 => {:bytes, kid}}) when byte_size(kid) == 48, do: {:ok, kid} defp kid(_header), do: {:error, :kid_not_thumbprint} @@ -166,25 +236,44 @@ defmodule Trinity.N6.COSE do CBOR.encode!([{:text, "Signature1"}, {:bytes, protected}, {:bytes, ""}, {:bytes, payload}]) @doc """ - Signs a payload as a Sign1 with ML-DSA-87. `opts`: `cwt: {iss, sub}` for a SCITT-renderable - `typ`. Answers `{:error, :alg_unavailable}` where the runtime cannot sign ML-DSA-87. + Signs a payload as a Sign1 with the key's algorithm. `opts`: `cwt: {iss, sub}` for a + SCITT-renderable `typ`. Answers `{:error, :alg_unavailable}` where the runtime cannot sign it. """ @spec sign(binary(), String.t(), key(), keyword()) :: {:ok, parts()} | {:error, Profile.code()} - def sign(payload, typ, %{public: pub, private: priv}, opts \\ []) - when is_binary(payload) and byte_size(pub) == @pub_size and - ((is_binary(priv) and byte_size(priv) == 4896) or - (is_tuple(priv) and tuple_size(priv) == 2 and elem(priv, 0) == :seed and - byte_size(elem(priv, 1)) == 32)) do + def sign(payload, typ, key, opts \\ []) + + def sign(payload, typ, %{alg: @ed25519, public: pub, private: priv}, opts) + when is_binary(payload) and byte_size(pub) == 32 and is_binary(priv) and + byte_size(priv) == 32 do + protected = + protected_header(thumbprint(@ed25519, pub), typ, Keyword.get(opts, :cwt), @ed25519) + + signed(protected, payload, &:crypto.sign(:eddsa, :none, &1, [priv, :ed25519])) + end + + def sign(payload, typ, %{public: pub, private: priv} = key, opts) + when is_binary(payload) and byte_size(pub) == 2592 and + (not is_map_key(key, :alg) or :erlang.map_get(:alg, key) == @mldsa87) do + priv = mldsa87_private!(priv) protected = protected_header(thumbprint(pub), typ, Keyword.get(opts, :cwt)) - signature = :crypto.sign(:mldsa87, :none, to_be_signed(protected, payload), priv) + signed(protected, payload, &:crypto.sign(:mldsa87, :none, &1, priv)) + end + + # The two forms of an ML-DSA-87 private key OTP signs with: the expanded key, and the seed. + defp mldsa87_private!(priv) when is_binary(priv) and byte_size(priv) == 4896, do: priv + defp mldsa87_private!({:seed, seed} = priv) when byte_size(seed) == 32, do: priv + + defp signed(protected, payload, sign) do + signature = sign.(to_be_signed(protected, payload)) {:ok, %{protected: protected, payload: payload, signature: signature}} rescue e in ErlangError -> unavailable(e) end @doc """ - Verifies a Sign1's parts. `opts`: `keys`, a function from a `kid` to `{:ok, public_key}` or - `:error`; `typ`, the `typ` table of the entry point in use. + Verifies a Sign1's parts. `opts`: `keys`, a function from a `kid` to + `{:ok, %{alg: alg, public: public_key}}` or `:error`; `typ`, the `typ` table of the entry point in + use; `entry`, the entry point (`:nss` when not given). """ @spec verify(parts(), keyword()) :: {:ok, %{exit: 0, verdict_code: :claim, kid: binary(), typ: String.t()}} @@ -193,41 +282,56 @@ defmodule Trinity.N6.COSE do when is_binary(protected) and is_binary(payload) and is_binary(signature) do keys = Keyword.fetch!(opts, :keys) table = Keyword.fetch!(opts, :typ) + entry_point = Keyword.get(opts, :entry, :nss) - with {:ok, %{kid: kid, typ: typ}} <- decode_protected(protected, table), - {:ok, pub} <- known_key(keys.(kid)), - :ok <- thumbprint_matches(pub, kid), - :ok <- signature_size(signature), - :ok <- signature_valid(to_be_signed(protected, payload), signature, pub) do + with {:ok, %{alg: alg, kid: kid, typ: typ}} <- decode_protected(protected, table, entry_point), + {:ok, pub} <- known_key(keys.(kid), alg), + :ok <- thumbprint_matches(alg, pub, kid), + :ok <- signature_size(alg, signature), + :ok <- signature_valid(alg, to_be_signed(protected, payload), signature, pub) do {:ok, %{exit: 0, verdict_code: :claim, kid: kid, typ: typ}} else {:error, code} -> {:error, Profile.exit_of(code), code} end end - defp known_key({:ok, pub}) when is_binary(pub), do: {:ok, pub} - defp known_key(_unknown), do: {:error, :policy_stale_or_unknown_key} + # A key registered under another algorithm than the header's is the draft's C5 case, a key used + # with another algorithm, whatever its thumbprint: for OKP the thumbprint cannot say. + defp known_key({:ok, %{alg: alg, public: pub}}, alg) when is_binary(pub), do: {:ok, pub} - defp thumbprint_matches(pub, kid) when byte_size(pub) == @pub_size do - if thumbprint(pub) == kid, do: :ok, else: {:error, :kid_not_thumbprint} - end + defp known_key({:ok, %{alg: _other, public: pub}}, _alg) when is_binary(pub), + do: {:error, :kid_not_thumbprint} - defp thumbprint_matches(_pub, _kid), do: {:error, :kid_not_thumbprint} + defp known_key(_unknown, _alg), do: {:error, :policy_stale_or_unknown_key} - defp signature_size(sig) when byte_size(sig) == @sig_size, do: :ok - defp signature_size(_sig), do: {:error, :carrier_malformed} + defp thumbprint_matches(alg, pub, kid) do + if byte_size(pub) == @sizes[alg].public and thumbprint(alg, pub) == kid, + do: :ok, + else: {:error, :kid_not_thumbprint} + end + + defp signature_size(alg, sig) do + if byte_size(sig) == @sizes[alg].signature, do: :ok, else: {:error, :carrier_malformed} + end - defp signature_valid(tbs, sig, pub) do - if :crypto.verify(:mldsa87, :none, tbs, sig, pub), do: :ok, else: {:error, :signature_invalid} + defp signature_valid(alg, tbs, sig, pub) do + if crypto_verify(alg, tbs, sig, pub), do: :ok, else: {:error, :signature_invalid} rescue e in ErlangError -> unavailable(e) end - # How :crypto says the runtime cannot do ML-DSA-87, each measured: `badarg` ("Bad key type") - # where the linked OpenSSL has no ML-DSA (3.0); `error` ("Can't create PKEY_CTX from id") from - # `verify` in FIPS mode on Red Hat's provider (the `fips` job, PR #133); `notsup` ("Unsupported - # algorithm in FIPS mode") for an algorithm the mode refuses. Key and signature sizes are checked - # before any call, so these are the runtime's answer, not the caller's mistake: no trust. + defp crypto_verify(@mldsa87, tbs, sig, pub), do: :crypto.verify(:mldsa87, :none, tbs, sig, pub) + + defp crypto_verify(@ed25519, tbs, sig, pub), + do: :crypto.verify(:eddsa, :none, tbs, sig, [pub, :ed25519]) + + # How :crypto says the runtime cannot do the algorithm, each measured: `badarg` ("Bad key type", + # "Bad digest type") where the linked OpenSSL has no ML-DSA (3.0); `error` ("Can't create + # PKEY_CTX from id") from `verify` in FIPS mode on Red Hat's provider (the `fips` job, PR #133); + # `notsup` ("Unsupported algorithm in FIPS mode") for an algorithm the mode refuses, Ed25519 among + # them. `:crypto` raises the same shapes for a malformed public key, but the key's size and its + # thumbprint are checked before any call here, so these are the runtime's answer, not the + # caller's mistake: no trust. defp unavailable(%ErlangError{original: {reason, _, _}}) when reason in [:notsup, :badarg, :error], do: {:error, :alg_unavailable} @@ -281,9 +385,15 @@ defmodule Trinity.N6.COSE do end end - defp unprotected(map) when map == %{}, do: :ok + @doc """ + Checks a Sign1's unprotected header, which must be empty: `alg`, `kid` or `typ` there is + `header_not_protected`, a countersignature label `countersignature_forbidden`, any other label + `unprotected_forbidden`, and anything but a map `carrier_malformed`. + """ + @spec unprotected(term()) :: :ok | {:error, Profile.code()} + def unprotected(map) when map == %{}, do: :ok - defp unprotected(map) when is_map(map) do + def unprotected(map) when is_map(map) do keys = Map.keys(map) cond do @@ -293,5 +403,5 @@ defmodule Trinity.N6.COSE do end end - defp unprotected(_other), do: {:error, :carrier_malformed} + def unprotected(_other), do: {:error, :carrier_malformed} end diff --git a/lib/trinity/n6/profile.ex b/lib/trinity/n6/profile.ex index a5a8fa4..8380016 100644 --- a/lib/trinity/n6/profile.ex +++ b/lib/trinity/n6/profile.ex @@ -2,14 +2,26 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.N6.Profile do @moduledoc """ - The N6 profile this tree implements, pinned: its version, its limits, and the closed table of - refusal codes with the exit class of each (slice 138). + The N6 profile this tree implements, pinned: its version, its limits, the closed table of + refusal codes with the exit class of each (slice 138), and the schema numbers of its carriers + (slice 139). N6 is a profile of deterministic CBOR (RFC 8949 section 4.2.1) and COSE_Sign1 (RFC 9052) for signed statements. The version implemented here is **v0, draft r3, provisional**: every rule may change until a frozen v1 is published with its vectors. `pin/0` names the draft and the SHA-384 of its text, so a later draft is a visible change of pin, not a silent one. + Slice 139 adds what the profile publisher's answers of 2026-10-19 add to r3 for a single signer, + ahead of the draft that will carry them (v0.1): the single-signer carrier, and Ed25519 (-19) as the + one classical algorithm on the `commercial` profile. `pin/0` names those answers too. + + ## Schema numbers + + Key 0 of a carrier is a number in the reserved range 0xFFFF0000 to 0xFFFFFFFF. r3 assigns two, + still Open in it (its section 12.1 item 3). The single-signer carrier has none yet, so this tree + uses a **placeholder**, 0xFFFF0003, and `schemas/0` says so; it changes when the profile assigns + the number, and every carrier written under it is provisional for that reason too. + ## Codes Every refusal in `Trinity.N6` is one of the atoms in `codes/0`. Each carries its exit class @@ -35,6 +47,8 @@ defmodule Trinity.N6.Profile do @pin %{ version: "n6/v0", draft: "r3", + answers: + "the profile publisher's answers of 2026-10-19 (single-signer carrier, Ed25519 on commercial)", status: :provisional, draft_sha384: "54ab5e549259b87b18c51b4d1a3dbbbbb230b74ae562d5c2ef8ec43288c6c2dafc6376bff51ae205da6043bb9da7b7f2" @@ -42,6 +56,13 @@ defmodule Trinity.N6.Profile do @limits %{depth: 16, items: 65_536, text: 65_536, size: 1_048_576} + # carrier => {key 0 value, who assigned it} + @schemas %{ + envelope: {0xFFFF0001, :draft}, + statement_id: {0xFFFF0002, :draft}, + single: {0xFFFF0003, :placeholder} + } + # code => {exit class, who defines it} @codes %{ # Section 2, the encoding rules. @@ -91,6 +112,7 @@ defmodule Trinity.N6.Profile do kid_not_thumbprint: {1, :draft}, typ_unexpected: {1, :draft}, carrier_malformed: {1, :draft}, + wrong_carrier_for_typ: {1, :draft}, # The draft names signature failures per twin role (signature_invalid_kv, _kh); a # single-signer statement has no role yet (its section 12.1 item 1). signature_invalid: {1, :trinity}, @@ -98,8 +120,9 @@ defmodule Trinity.N6.Profile do claim: {0, :draft}, usage: {2, :draft}, policy_stale_or_unknown_key: {5, :draft}, - # The runtime cannot run ML-DSA-87 (for example in FIPS mode on a provider that refuses it), - # so the verifier cannot judge: no trust, never a pass and never a crash. + # The runtime cannot run the header's algorithm (ML-DSA-87 or Ed25519; FIPS mode refuses Ed25519, + # and Red Hat's provider ML-DSA-87), so the verifier cannot judge: no trust, never a pass and + # never a crash. alg_unavailable: {5, :trinity} } @@ -123,6 +146,17 @@ defmodule Trinity.N6.Profile do @spec codes() :: %{code() => {0 | 1 | 2 | 5, :draft | :trinity}} def codes, do: @codes + @doc """ + The reserved-range schema numbers of the carriers, each with who assigned it: `:draft` (r3, still + Open there) or `:placeholder` (none assigned yet; this tree's stand-in). + """ + @spec schemas() :: %{atom() => {non_neg_integer(), :draft | :placeholder}} + def schemas, do: @schemas + + @doc "The key 0 value of a carrier." + @spec schema(:envelope | :statement_id | :single) :: non_neg_integer() + def schema(carrier), do: @schemas |> Map.fetch!(carrier) |> elem(0) + @doc "The exit class of a code. Raises on a code not in the table, which is a bug in the caller." @spec exit_of(code()) :: 0 | 1 | 2 | 5 def exit_of(code), do: @codes |> Map.fetch!(code) |> elem(0) diff --git a/lib/trinity/receipts/chain_writer.ex b/lib/trinity/receipts/chain_writer.ex index 49db9ed..d3ded42 100644 --- a/lib/trinity/receipts/chain_writer.ex +++ b/lib/trinity/receipts/chain_writer.ex @@ -563,14 +563,25 @@ defmodule Trinity.Receipts.ChainWriter do {:error, {:checkpoint_tail_not_in_chain, cp.last_seq}}, {:ok, impl} <- Signer.impl_for_scheme(cp.scheme) |> ok_or({:error, {:unknown_scheme, cp.scheme}}), - {:ok, pub} <- public_key(cp.key_id), - true <- - impl.verify( - Envelope.pae(Envelope.checkpoint_type(cp.scheme), cp.signed_payload), - cp.signature, - pub - ) || {:error, {:checkpoint_signature_invalid, cp.last_seq}} do - :ok + {:ok, pub} <- public_key(cp.key_id) do + bytes = Envelope.pae(Envelope.checkpoint_type(cp.scheme), cp.signed_payload) + + case Signer.check(impl, bytes, cp.signature, pub) do + :ok -> + :ok + + :invalid -> + {:error, {:checkpoint_signature_invalid, cp.last_seq}} + + # Slice 139: a checkpoint this runtime cannot judge (one signed before the node entered FIPS + # mode, say) stops the writer with the reason. Before 139 it was an exception out of + # `:crypto.verify`, which stopped the writer just the same but said nothing about why. + :alg_unavailable -> + {:error, {:checkpoint_alg_unavailable, cp.last_seq, Atom.to_string(impl.algorithm())}} + + :key_unusable -> + {:error, {:checkpoint_key_unusable, cp.last_seq, cp.key_id}} + end end end diff --git a/lib/trinity/receipts/signer.ex b/lib/trinity/receipts/signer.ex index 9924602..44b04e0 100644 --- a/lib/trinity/receipts/signer.ex +++ b/lib/trinity/receipts/signer.ex @@ -140,6 +140,34 @@ defmodule Trinity.Receipts.Signer do end end + @doc """ + Verifies through an implementation, with a reason where `:crypto` raises rather than answering + (slice 139, slice 137's follow-up): `:ok`, `:invalid`, `:alg_unavailable` or `:key_unusable`. + + `:crypto.verify` raises where the runtime refuses the algorithm: `notsup` for Ed25519 in FIPS mode, + `error` "Can't create PKEY_CTX from id" for ML-DSA-87 in FIPS mode on Red Hat's provider, `badarg` + "Bad digest type" for ML-DSA-87 on OpenSSL 3.0 (each measured). It raises the same `badarg` and + `error` for a malformed public key ("Couldn't get EDDSA public key", "Can't create PKEY fromdata", + measured on OpenSSL 3.0 and 3.5), so the shape alone cannot say which. The implementation's + `available?/0` does: a runtime that cannot do the algorithm at all is `:alg_unavailable`, and one + that can was handed a key it cannot use, `:key_unusable`. Either way the caller cannot judge the + signature, which is no trust, never a crash and never a pass. Anything else still raises. + """ + @spec check(module(), binary(), binary(), public_key()) :: + :ok | :invalid | :alg_unavailable | :key_unusable + def check(impl, bytes, signature, public_key) do + if impl.verify(bytes, signature, public_key), do: :ok, else: :invalid + rescue + e in ErlangError -> + case e.original do + {reason, _, _} when reason in [:notsup, :badarg, :error] -> + if impl.available?(), do: :key_unusable, else: :alg_unavailable + + _ -> + reraise e, __STACKTRACE__ + end + end + @doc "Every algorithm this tree knows, in the order of preference outside FIPS mode." @spec algorithms() :: [algorithm()] def algorithms, do: [:ed25519, :p384, :mldsa87] diff --git a/lib/trinity/receipts/verifier.ex b/lib/trinity/receipts/verifier.ex index 71659de..e07cdf3 100644 --- a/lib/trinity/receipts/verifier.ex +++ b/lib/trinity/receipts/verifier.ex @@ -23,8 +23,9 @@ defmodule Trinity.Receipts.Verifier do signature verifies the same way; a covered range never has a gap. Outcomes carry the exit vocabulary the operator learns once: `0` verified, `1` invalid, - `2` usage, `5` trust not established (a key id the registry does not know), `6` a key the - registry marks compromised. + `2` usage, `5` trust not established (a key id the registry does not know, or, since slice 139, + a signature this runtime cannot judge: an algorithm it refuses, as FIPS mode refuses Ed25519, or + a registered key it cannot use), `6` a key the registry marks compromised. """ alias Trinity.Receipts.{Envelope, Signer} @@ -115,12 +116,30 @@ defmodule Trinity.Receipts.Verifier do defp check_signature(%{"kind" => kind} = r, bytes, impl, row, seq) when kind in @signed_kinds do with {:ok, sig} <- decode_sig(r["signature_b64"], seq), {:ok, pub} <- public_key(row, r["key_id"]) do - expect(impl.verify(bytes, sig, pub), 1, {:signature_invalid, seq}) + answer( + Signer.check(impl, bytes, sig, pub), + seq, + impl, + r["key_id"], + {:signature_invalid, seq} + ) end end defp check_signature(_r, _bytes, _impl, _row, _seq), do: :ok + # Slice 139: a runtime that refuses the row's algorithm, or cannot use its registered key, cannot + # judge the signature. That is trust not established (5), naming where and why; before 139 it was + # an exception out of `:crypto.verify`. + defp answer(:ok, _at, _impl, _key_id, _invalid), do: :ok + defp answer(:invalid, _at, _impl, _key_id, invalid), do: {:error, 1, invalid} + + defp answer(:alg_unavailable, at, impl, _key_id, _invalid), + do: {:error, 5, {:alg_unavailable, at, Atom.to_string(impl.algorithm())}} + + defp answer(:key_unusable, _at, _impl, key_id, _invalid), + do: {:error, 5, {:key_undecodable, key_id}} + # The stored body must say what the row says: a row whose columns disagree with its # signed body is a row edited after the fact. defp body_matches(r, seq) do @@ -198,7 +217,14 @@ defmodule Trinity.Receipts.Verifier do {:ok, sig} <- decode_sig(cp["signature_b64"], {:checkpoint, last}), {:ok, pub} <- public_key(row, cp["key_id"]) do bytes = Envelope.pae(Envelope.checkpoint_type(cp["scheme"]), cp["signed_payload"]) - expect(impl.verify(bytes, sig, pub), 1, {:checkpoint_signature_invalid, last}) + + answer( + Signer.check(impl, bytes, sig, pub), + {:checkpoint, last}, + impl, + cp["key_id"], + {:checkpoint_signature_invalid, last} + ) end end diff --git a/scripts/n6_vectors.exs b/scripts/n6_vectors.exs index 5ab3ffd..5601f92 100644 --- a/scripts/n6_vectors.exs +++ b/scripts/n6_vectors.exs @@ -19,6 +19,23 @@ defmodule N6Vectors do @typ "application/vnd.example.test.v1+cose" @typ_scitt "application/vnd.example.scitt-test.v1+cose" @typ_other "application/vnd.example.other.v1+cose" + # Slice 139: a `typ` bound to Ed25519 (-19) on the `commercial` profile, and one whose entry needs + # two signers, which a single-signer carrier must refuse. + @typ_ed "application/vnd.example.test-ed25519.v1+cose" + @typ_twin "application/vnd.example.twin-test.v1+cose" + + # RFC 8032 section 7.1, test 1: a published Ed25519 key, so no key of this tree's is published. + @ed_pub "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + + # Ed25519 over the sample's ToBeSigned (below) under that key's secret + # (9d61b19d...1cae7f60, RFC 8032 section 7.1). Ed25519 is deterministic, so this is the only valid + # signature, and it is a literal so the generator runs where the runtime refuses Ed25519 (FIPS + # mode). The suite checks it signs and verifies wherever the runtime has Ed25519. + @ed_sig "44f793ebf76a054b798344893478ce4d19985eb9b8e813e6a99fa2920cc45f955c8fa32ce1307b6673c5418419cb81b0e35c7018966622bcf179ecd623740703" + + # The single-signer carrier's schema number: a placeholder, 0xFFFF0003, until the profile's next + # draft assigns one (Trinity.N6.Profile.schemas/0). + @single_schema "1a ff ff 00 03" # FIPS 180-2, Appendix D.1: SHA-384("abc"). @sha384_abc "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7" @@ -33,6 +50,12 @@ defmodule N6Vectors do thumb_input = h("a3 01 07 03 38 31 20 59 0a 20") <> pub kid = :crypto.hash(:sha384, thumb_input) + # RFC 9679 section 4.1 over the OKP members of RFC 9053 section 7.1: {1: 1, -1: 6, -2: x}, keys in + # bytewise order 0x01, 0x20, 0x21, a 32-byte string's head being 58 20. No `alg`, which is why the + # profile table binds a `typ` to its one algorithm. + ed_pub = h(@ed_pub) + ed_kid = :crypto.hash(:sha384, h("a3 01 01 20 06 21 58 20") <> ed_pub) + File.rm_rf!(root) for {id, hex, desc, source} <- encoding_positive(), do: positive(root, "encoding", id, h(hex), desc, source) @@ -41,13 +64,18 @@ defmodule N6Vectors do for {defect, id, hex, code} <- body_negative(), do: negative(root, "body", defect, id, h(hex), code, "hand") write(root, "body/schema.json", json(test_schema())) - header = header_vectors(kid) + header = header_vectors(kid, ed_kid) for {id, bytes} <- header.positive, do: write(root, "header/positive/#{id}.cbor", bytes) for {defect, id, bytes, code} <- header.negative, do: negative(root, "header", defect, id, bytes, code, "hand") write(root, "header/profile-table.json", json(profile_table())) write(root, "thumbprint/acvp_mldsa87_tg3_tc51.pub", pub) write(root, "thumbprint/acvp_mldsa87_tg3_tc51.kid", kid) + write(root, "thumbprint/rfc8032_test1_ed25519.pub", ed_pub) + write(root, "thumbprint/rfc8032_test1_ed25519.kid", ed_kid) + + for {id, bytes, exit, code, entry} <- sign1_samples(kid, ed_kid), + do: sample(root, id, bytes, exit, code, entry) abc = "abc" digest = :crypto.hash(:sha384, abc) @@ -61,13 +89,13 @@ defmodule N6Vectors do for {defect, id, bytes, code} <- digest_negative(), do: negative(root, "digest", defect, id, bytes, code, "hand") - write(root, "VERSION", "n6/v0 draft r3 PROVISIONAL (Trinity candidate set)\n") - write(root, "README.md", readme(header.negative)) + write(root, "VERSION", "n6/v0 draft r3 and the publisher's answers of 2026-10-19 PROVISIONAL (Trinity candidate set)\n") + write(root, "README.md", readme(header.negative, sign1_samples(kid, ed_kid))) write_sums(root) end # The README lists every negative directory with its expected code, derived from the lists above. - defp readme(header_negative) do + defp readme(header_negative, samples) do rows = fn vectors -> vectors |> Enum.map(fn v -> {elem(v, 0), code_of(v)} end) @@ -92,11 +120,16 @@ defmodule N6Vectors do * Expected encodings are hex literals in the generator, copied from RFC 8949 Appendix A or built by hand from CBOR heads, never output of the encoder under test. * `body/schema.json` and `header/profile-table.json` are Trinity's test schema and test `typ` - table (the draft leaves both Open, VB6 and VB4). `thumbprint/` uses NIST ACVP's published - ML-DSA-87 keyGen case (tgId 3, tcId 51); no key of this project's is published. - * Not included: the limit vectors at 64 KiB and 1 MiB (the suite builds them), and COSE_Sign1 - samples (ML-DSA signatures are hedged, so a sample is one valid signature among many; the - suite signs and verifies where the runtime can). + table (the draft leaves both Open, VB6 and VB4). Each `typ` names its one `alg`, its carrier + and its signers, as the publisher's answers of 2026-10-19 bind them. `thumbprint/` uses NIST + ACVP's published ML-DSA-87 keyGen case (tgId 3, tcId 51) and RFC 8032's Ed25519 test 1 key; no + key of this project's is published. + * `cose/sign1/`: single-signer carriers, `{0: SINGLE_SCHEMA, 1: payload, 2: N6_Sign1}`, each with + its `.expect` and a `.ctx.json` naming the entry point, the `typ` table and the keys (the draft + leaves `ctx.json` Open, VB5). `SINGLE_SCHEMA` is 0xFFFF0003, a placeholder until the profile + assigns one. The signed sample is Ed25519, which is deterministic; ML-DSA signatures are hedged, + so no ML-DSA-87 sample is included and the suite signs and verifies one where the runtime can. + * Not included: the limit vectors at 64 KiB and 1 MiB (the suite builds them). ## Encoding defects @@ -121,6 +154,12 @@ defmodule N6Vectors do | Directory | Expected code | | -- | -- | #{rows.(digest_negative())} + + ## Single-signer carriers (`cose/sign1/`) + + | Sample | Entry point | Exit | Expected code | + | -- | -- | -- | -- | + #{Enum.map_join(samples, "\n", fn {id, _, exit, code, entry} -> "| `#{id}` | `#{entry}` | #{exit} | `#{code}` |" end)} """ end @@ -332,16 +371,21 @@ defmodule N6Vectors do "provisional" => true, "note" => "Trinity's test profile table for the header vectors (the draft leaves its CDDL Open, VB4).", "typ" => [ - %{"typ" => @typ, "carrier" => "single", "signers" => "single", "scitt" => false}, - %{"typ" => @typ_scitt, "carrier" => "single", "signers" => "single", "scitt" => true} + %{"typ" => @typ, "alg" => -50, "carrier" => "single", "signers" => "single", "scitt" => false}, + %{"typ" => @typ_scitt, "alg" => -50, "carrier" => "single", "signers" => "single", "scitt" => true}, + %{"typ" => @typ_ed, "alg" => -19, "carrier" => "single", "signers" => "single", "scitt" => false}, + %{"typ" => @typ_twin, "alg" => -50, "carrier" => "envelope", "signers" => "pair", "scitt" => false} ] } end - defp header_vectors(kid) do + defp header_vectors(kid, ed_kid) do alg = h("01 38 31") kid_pair = h("04 58 30") <> kid typ = h("10 78 24") <> @typ + ed_alg = h("01 32") + ed_kid_pair = h("04 58 30") <> ed_kid + typ_ed = h("10 78 2c") <> @typ_ed typ_scitt = h("10 78 2a") <> @typ_scitt typ_other = h("10 78 25") <> @typ_other cwt = h("0f a2 01 63") <> "iss" <> h("02 63") <> "sub" @@ -349,7 +393,8 @@ defmodule N6Vectors do %{ positive: [ {"alg_kid_typ", h("a3") <> alg <> kid_pair <> typ}, - {"alg_kid_cwt_typ_scitt", h("a4") <> alg <> kid_pair <> cwt <> typ_scitt} + {"alg_kid_cwt_typ_scitt", h("a4") <> alg <> kid_pair <> cwt <> typ_scitt}, + {"ed25519_alg_kid_typ", h("a3") <> ed_alg <> ed_kid_pair <> typ_ed} ], negative: [ {"alg_not_mldsa87", "mldsa65", h("a3 01 38 30") <> kid_pair <> typ, "alg_not_permitted"}, @@ -369,9 +414,68 @@ defmodule N6Vectors do {"kid_wrong_size", "kid_32", h("a3") <> alg <> h("04 58 20") <> binary_part(kid, 0, 32) <> typ, "kid_not_thumbprint"}, {"header_noncanonical", "typ_before_kid", h("a3") <> alg <> typ <> kid_pair, "header_noncanonical"}, {"header_noncanonical", "nonminimal_label", h("a3 18 01 38 31") <> kid_pair <> typ, "header_noncanonical"}, - {"header_noncanonical", "trailing_byte", h("a3") <> alg <> kid_pair <> typ <> h("00"), "header_noncanonical"} + {"header_noncanonical", "trailing_byte", h("a3") <> alg <> kid_pair <> typ <> h("00"), "header_noncanonical"}, + # Slice 139: the header's `alg` must be the one its `typ` is bound to. + {"alg_not_bound_to_typ", "ed25519_under_mldsa87_typ", h("a3") <> ed_alg <> ed_kid_pair <> typ, "alg_not_permitted"}, + {"alg_not_bound_to_typ", "mldsa87_under_ed25519_typ", h("a3") <> alg <> kid_pair <> typ_ed, "alg_not_permitted"} + ] + } + end + + ## Single-signer carriers (slice 139) + + # {id, carrier bytes, exit, verdict code, entry point}. The payload is the N6 body + # {0: 1, 16: "slice 139"}; the Ed25519 sample is signed over + # ["Signature1", protected, h'', payload], RFC 9052 section 4.4. + defp sign1_samples(kid, ed_kid) do + payload = h("a2 00 01 10 69") <> "slice 139" + payload_field = h("01 4e") <> payload + protected = h("a3 01 32 04 58 30") <> ed_kid <> h("10 78 2c") <> @typ_ed + sig = h(@ed_sig) + sign1 = h("84 58 65") <> protected <> h("a0 f6 58 40") <> sig + good = h("a3 00") <> h(@single_schema) <> payload_field <> h("02") <> sign1 + + flipped = binary_part(sig, 0, 63) <> <<:binary.last(sig) |> Bitwise.bxor(1)>> + twin_protected = h("a3") <> h("01 38 31") <> h("04 58 30") <> kid <> h("10 78 29") <> @typ_twin + twin_sign1 = h("84 58 63") <> twin_protected <> h("a0 f6 59 12 13") <> :binary.copy(<<0>>, 4627) + + [ + {"ed25519_single", good, 0, "claim", "commercial"}, + {"ed25519_single_at_nss", good, 1, "alg_not_permitted", "nss"}, + {"ed25519_signature_flipped", + h("a3 00") <> h(@single_schema) <> payload_field <> h("02 84 58 65") <> protected <> h("a0 f6 58 40") <> flipped, + 1, "signature_invalid", "commercial"}, + {"carrier_key0_envelope", h("a3 00 1a ff ff 00 01") <> payload_field <> h("02") <> sign1, 1, "carrier_malformed", "commercial"}, + {"carrier_extra_key", h("a4 00") <> h(@single_schema) <> payload_field <> h("02") <> sign1 <> h("03 00"), 1, "carrier_malformed", "commercial"}, + {"carrier_no_sign1", h("a2 00") <> h(@single_schema) <> payload_field, 1, "carrier_malformed", "commercial"}, + {"carrier_payload_text", h("a3 00") <> h(@single_schema) <> h("01 69") <> "slice 139" <> h("02") <> sign1, 1, "carrier_malformed", "commercial"}, + {"carrier_not_map", h("83") <> h(@single_schema) <> h("4e") <> payload <> sign1, 1, "carrier_malformed", "commercial"}, + {"carrier_keys_out_of_order", h("a3 00") <> h(@single_schema) <> h("02") <> sign1 <> payload_field, 1, "noncanonical", "commercial"}, + {"sign1_attached", + h("a3 00") <> h(@single_schema) <> payload_field <> h("02 84 58 65") <> protected <> h("a0 4e") <> payload <> h("58 40") <> sig, + 1, "carrier_malformed", "commercial"}, + {"sign1_tagged", h("a3 00") <> h(@single_schema) <> payload_field <> h("02 d2") <> sign1, 1, "tag_forbidden", "commercial"}, + {"sign1_unprotected_kid", + h("a3 00") <> h(@single_schema) <> payload_field <> h("02 84 58 65") <> protected <> h("a1 04 41 00 f6 58 40") <> sig, + 1, "header_not_protected", "commercial"}, + {"pair_typ_in_single_carrier", h("a3 00") <> h(@single_schema) <> payload_field <> h("02") <> twin_sign1, 1, "wrong_carrier_for_typ", "commercial"} + ] + end + + defp sample(root, id, bytes, exit, code, entry) do + write(root, "cose/sign1/#{id}.bin", bytes) + write(root, "cose/sign1/#{id}.expect", json(%{"exit" => exit, "verdict_code" => code, "source" => "hand"})) + + ctx = %{ + "entry_point" => entry, + "typ_table" => "header/profile-table.json", + "keys" => [ + %{"alg" => -19, "public" => "thumbprint/rfc8032_test1_ed25519.pub"}, + %{"alg" => -50, "public" => "thumbprint/acvp_mldsa87_tg3_tc51.pub"} ] } + + write(root, "cose/sign1/#{id}.ctx.json", json(ctx)) end ## Digest diff --git a/scripts/receipt_foreign_chains.exs b/scripts/receipt_foreign_chains.exs new file mode 100644 index 0000000..6d62635 --- /dev/null +++ b/scripts/receipt_foreign_chains.exs @@ -0,0 +1,174 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Writes the receipt chains under `test/fixtures/receipts/` (slice 139): exported chains signed under +# algorithms a runtime may refuse, so the verifier's answer on such a runtime can be tested there. +# +# mix run --no-start scripts/receipt_foreign_chains.exs test/fixtures/receipts +# +# Run it on a runtime that signs both algorithms (OTP 28.5.0.8 with OpenSSL 3.5.9, FIPS mode off). +# The keys are published test keys, never a key of this tree's: RFC 8032 section 7.1 test 1 for +# Ed25519, and NIST ACVP's ML-DSA-87 keyGen case (`priv/kat/acvp/`, slice 137) for ML-DSA-87. +# +# Ed25519 is deterministic, so its two chains come out the same on every run. ML-DSA-87 signing is +# hedged, so its two chains differ in their signature bytes from run to run; the checked-in files +# are one run's output, and nothing compares them with a rerun. +# +# Each algorithm gets two chains: `signed` (one decision receipt, signed) and `checkpoint` (one query +# receipt, unsigned, under one signed checkpoint), because the verifier checks a row's signature and +# a checkpoint's signature at two different places. + +alias Trinity.Receipts.{Envelope, Signer} + +defmodule ForeignChains do + @at "2026-10-20T00:00:00.000000Z" + @wall 1_792_454_400_000 + + def run([root]) do + acvp = "priv/kat/acvp/mldsa87_keygen.json" |> File.read!() |> JSON.decode!() + + keys = %{ + "ed25519" => %{ + public: h("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"), + sign: fn bytes -> + priv = h("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + :crypto.sign(:eddsa, :none, bytes, [priv, :ed25519]) + end + }, + "mldsa87" => %{ + public: Base.decode16!(acvp["pk"]), + sign: fn bytes -> + :crypto.sign(:mldsa87, :none, bytes, {:seed, Base.decode16!(acvp["seed"])}) + end + } + } + + File.mkdir_p!(root) + + for {alg, key} <- keys do + registry = [registry_row(alg, key.public)] + key_id = hd(registry)["key_id"] + scheme = "receipt_v3_" <> alg + + signed = %{ + "receipts" => [row(alg, scheme, key_id, 1, nil, "decision", key.sign)], + "checkpoints" => [], + "registry" => registry + } + + query = row(alg, scheme, key_id, 1, nil, "query", key.sign) + + checkpoint = %{ + "receipts" => [query], + "checkpoints" => [checkpoint(alg, scheme, key_id, query, key.sign)], + "registry" => registry + } + + write(root, "#{alg}_signed.json", export(alg, "signed", signed)) + write(root, "#{alg}_checkpoint.json", export(alg, "checkpoint", checkpoint)) + end + end + + defp export(alg, name, map) do + Map.merge(map, %{ + "format" => "trinity-receipts-export/1", + "chain_scope" => scope(alg, name), + "exported_at" => @at + }) + end + + defp scope(alg, _name), do: "fixture:" <> alg + + defp row(alg, scheme, key_id, seq, prev_hash, kind, sign) do + body = %{ + "scheme" => scheme, + "seq" => seq, + "chain_scope" => scope(alg, nil), + "prev_hash" => prev_hash, + "kind" => kind, + "subject" => %{"fixture" => "slice 139"}, + "decision" => if(kind == "decision", do: %{"outcome" => "allow", "basis" => "fixture"}), + "fingerprint" => nil, + "at" => @at, + "key_id" => key_id, + "clock" => %{"wall" => @wall, "counter" => seq - 1, "node" => "fixture"} + } + + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.receipt_type(scheme), payload) + + %{ + "chain_scope" => body["chain_scope"], + "seq" => seq, + "prev_hash" => prev_hash, + "receipt_hash" => Envelope.hash(bytes), + "scheme" => scheme, + "kind" => kind, + "signed_payload" => payload, + "signature_b64" => if(kind == "query", do: nil, else: Base.encode64(sign.(bytes))), + "key_id" => key_id, + "meta" => %{} + } + end + + defp checkpoint(alg, scheme, key_id, tail, sign) do + body = %{ + "scheme" => scheme, + "chain_scope" => scope(alg, nil), + "boot_receipt_hash" => nil, + "first_seq" => tail["seq"], + "last_seq" => tail["seq"], + "tail_hash" => tail["receipt_hash"], + "key_id" => key_id, + "reason" => "fixture", + "at" => @at + } + + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.checkpoint_type(scheme), payload) + + %{ + "chain_scope" => body["chain_scope"], + "boot_receipt_hash" => nil, + "first_seq" => tail["seq"], + "last_seq" => tail["seq"], + "tail_hash" => tail["receipt_hash"], + "scheme" => scheme, + "signed_payload" => payload, + "signature_b64" => Base.encode64(sign.(bytes)), + "key_id" => key_id, + "reason" => "fixture" + } + end + + # The registry row as `Trinity.Receipts.KeyCustody` writes one (slice 024). + defp registry_row(alg, pub) do + impl = Signer.impl(String.to_existing_atom(alg)) + jwk = impl.jwk(pub) + + {key_id, kid_scheme} = + case jwk do + nil -> {:crypto.hash(:sha256, pub) |> Base.url_encode64(padding: false), "sha256-raw"} + jwk -> {Signer.thumbprint(jwk), "rfc7638"} + end + + %{ + "key_id" => key_id, + "kid_scheme" => kid_scheme, + "algorithm" => alg, + "scheme" => "receipt_v3_" <> alg, + "jwk" => jwk, + "public_key_b64" => Base.encode64(pub), + "fingerprint" => :crypto.hash(:sha256, pub) |> Base.encode16(case: :lower), + "valid_from" => @at, + "status" => "active", + "custody" => "file" + } + end + + defp write(root, name, map), do: File.write!(Path.join(root, name), JSON.encode!(map) <> "\n") + + defp h(hex), do: Base.decode16!(hex, case: :lower) +end + +ForeignChains.run(System.argv()) diff --git a/test/fixtures/n6/README.md b/test/fixtures/n6/README.md index 0fb465f..7d58815 100644 --- a/test/fixtures/n6/README.md +++ b/test/fixtures/n6/README.md @@ -14,11 +14,16 @@ profile's own vectors. These are candidate input, not the published set. * Expected encodings are hex literals in the generator, copied from RFC 8949 Appendix A or built by hand from CBOR heads, never output of the encoder under test. * `body/schema.json` and `header/profile-table.json` are Trinity's test schema and test `typ` - table (the draft leaves both Open, VB6 and VB4). `thumbprint/` uses NIST ACVP's published - ML-DSA-87 keyGen case (tgId 3, tcId 51); no key of this project's is published. -* Not included: the limit vectors at 64 KiB and 1 MiB (the suite builds them), and COSE_Sign1 - samples (ML-DSA signatures are hedged, so a sample is one valid signature among many; the - suite signs and verifies where the runtime can). + table (the draft leaves both Open, VB6 and VB4). Each `typ` names its one `alg`, its carrier + and its signers, as the publisher's answers of 2026-10-19 bind them. `thumbprint/` uses NIST + ACVP's published ML-DSA-87 keyGen case (tgId 3, tcId 51) and RFC 8032's Ed25519 test 1 key; no + key of this project's is published. +* `cose/sign1/`: single-signer carriers, `{0: SINGLE_SCHEMA, 1: payload, 2: N6_Sign1}`, each with + its `.expect` and a `.ctx.json` naming the entry point, the `typ` table and the keys (the draft + leaves `ctx.json` Open, VB5). `SINGLE_SCHEMA` is 0xFFFF0003, a placeholder until the profile + assigns one. The signed sample is Ed25519, which is deterministic; ML-DSA signatures are hedged, + so no ML-DSA-87 sample is included and the suite signs and verifies one where the runtime can. +* Not included: the limit vectors at 64 KiB and 1 MiB (the suite builds them). ## Encoding defects @@ -88,6 +93,7 @@ profile's own vectors. These are candidate input, not the published set. | `cwt_without_scitt_typ` | `header_label_forbidden` | | `kid_wrong_size` | `kid_not_thumbprint` | | `header_noncanonical` | `header_noncanonical` | +| `alg_not_bound_to_typ` | `alg_not_permitted` | ## Digest defects @@ -96,3 +102,21 @@ profile's own vectors. These are candidate input, not the published set. | `digest_alg_not_permitted` | `digest_alg_not_permitted` | | `digest_length_mismatch` | `digest_length_mismatch` | | `digest_not_array` | `digest_malformed` | + +## Single-signer carriers (`cose/sign1/`) + +| Sample | Entry point | Exit | Expected code | +| -- | -- | -- | -- | +| `ed25519_single` | `commercial` | 0 | `claim` | +| `ed25519_single_at_nss` | `nss` | 1 | `alg_not_permitted` | +| `ed25519_signature_flipped` | `commercial` | 1 | `signature_invalid` | +| `carrier_key0_envelope` | `commercial` | 1 | `carrier_malformed` | +| `carrier_extra_key` | `commercial` | 1 | `carrier_malformed` | +| `carrier_no_sign1` | `commercial` | 1 | `carrier_malformed` | +| `carrier_payload_text` | `commercial` | 1 | `carrier_malformed` | +| `carrier_not_map` | `commercial` | 1 | `carrier_malformed` | +| `carrier_keys_out_of_order` | `commercial` | 1 | `noncanonical` | +| `sign1_attached` | `commercial` | 1 | `carrier_malformed` | +| `sign1_tagged` | `commercial` | 1 | `tag_forbidden` | +| `sign1_unprotected_kid` | `commercial` | 1 | `header_not_protected` | +| `pair_typ_in_single_carrier` | `commercial` | 1 | `wrong_carrier_for_typ` | diff --git a/test/fixtures/n6/SHA384SUMS b/test/fixtures/n6/SHA384SUMS index d0bd763..d8a6b5d 100644 --- a/test/fixtures/n6/SHA384SUMS +++ b/test/fixtures/n6/SHA384SUMS @@ -1,5 +1,5 @@ -bd5172bf9a168bdd8ca067f7ce7b83c0e7f3f9badf15646beaa26c7641d7cc5778de3f2a197ce716c46d9ea4c3b9324a README.md -f3719250dcef54348054c1b7a348ad7283a28809162e3f1b4dae46460d2390b174aefda182083d5d3aaa234d78ebe481 VERSION +98407a9a6fb2dda0329e79719aecf89b39c57d31556f92778496899720fa0b0d8d33e214ae3373d2aab273e7678794bf README.md +933f4c72dbb5f90e2b8e4c86fce10d9c5f9f9c200cd93b1552d9b8840112d90d4f407d93de02ed37ef4f26773f45e356 VERSION e80ceee3df61c92398d9f44ea69efc5ca52602afd424d63ec799735a3466f836ff7208cc45a21918b4ea314e8beb77a8 body/negative/bool_schema_version/true.cbor c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d1380539e54ba616ca2 body/negative/bool_schema_version/true.expect 848553192b7cfaa67a1c231f3dc290c3e63643070e616910412a6e0acf4a9ae3134ada5de86e606c8d2a0c139f25aa09 body/negative/carrier_schema_key0/envelope_schema.cbor @@ -36,6 +36,45 @@ b63c74e780fd7f3493ab66b850a33a52ec6ee6fb9a6120b66405f95f87c94532a8afb98e328046e3 b8f34840b32bf53c31b2056633a3476660eca858bd14c15b1e0e1d3399c4475464e8a7f4b95ad030a0b22ad0698aab57 body/positive/set_sorted_by_encoded_bytes.desc.json c6f0aa0ae966875501196a53c4211686c34714d8ee7d5de601c709d82d356da5503b830491477e43d29db75e62fafc35 body/positive/set_sorted_by_encoded_bytes.diag d5e567a12927ec81b341cb6af2ac76d6ed470bd313f07107a66d5c961a89d18a24a3e43d3c05c27bed891c9dd4cfd955 body/schema.json +8dfb70f3464f7a2c068f1795590d42e08708402895d00ab46c4d71cdf092540e5248e51f8639aaf93d54e7474dc6629f cose/sign1/carrier_extra_key.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/carrier_extra_key.ctx.json +6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/carrier_extra_key.expect +0fb9c71937623d3ba976f2bc5c85d86a6e34f60d22e960ec31619ffb948b158ff591caebb05b3f88f0aa9aeddfeb5b5e cose/sign1/carrier_key0_envelope.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/carrier_key0_envelope.ctx.json +6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/carrier_key0_envelope.expect +6caa93e5429cf79585eade98bc156f4ec0467cc1ed93901e688eb95ad1445ace1dd3e31a73b89219071c38a3a01a29bd cose/sign1/carrier_keys_out_of_order.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/carrier_keys_out_of_order.ctx.json +b3d1981309035b76016f6266b6a5db1904f976c6ce40a1ad6e861a061a98dc10c414b8392528d70ee75a1e71b059280b cose/sign1/carrier_keys_out_of_order.expect +c9d2ed1ac13d008fecc544daa182f623b79a92b5d964d23f32dffbfc1a5f798a8568090f8529f23dd6656515234b67ab cose/sign1/carrier_no_sign1.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/carrier_no_sign1.ctx.json +6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/carrier_no_sign1.expect +3be4a2408a145733d482cffaeed3ff6f62641103f6096a05d2f38ce952590b316fcfdfc538cef223719e9049ffa7226e cose/sign1/carrier_not_map.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/carrier_not_map.ctx.json +6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/carrier_not_map.expect +82ca37d3690d3e21ab315c7b74bb4c66ab0061bf5d25bd433b2472059e9e17e79c6915e9d73bcdf9654ac6bda55bd9d7 cose/sign1/carrier_payload_text.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/carrier_payload_text.ctx.json +6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/carrier_payload_text.expect +aa25c4cc0e49eafa267c877ccf9b65da2d4c60021ce2ed5bf9ba3007512b33d3f497866da4b9dbb7345a0849a7a03ab1 cose/sign1/ed25519_signature_flipped.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/ed25519_signature_flipped.ctx.json +d9dac7ae9073b03b97b273d7bdd1102b2bb52c35ff68efd68dfef3d980038b66924993403b5be93d134ab49fbaa819f6 cose/sign1/ed25519_signature_flipped.expect +a2627eafcf2d4b8652c4da26d757e4336f117dc70029311a3e9d422adaed7f304edc14a81259bbac89046cec106249f5 cose/sign1/ed25519_single.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/ed25519_single.ctx.json +129b24235ed01510f297a3444794725550dce67f27c960403338abd7d3b898bfa31a55909bc9138672791699a53874cf cose/sign1/ed25519_single.expect +a2627eafcf2d4b8652c4da26d757e4336f117dc70029311a3e9d422adaed7f304edc14a81259bbac89046cec106249f5 cose/sign1/ed25519_single_at_nss.bin +b5adc90cc824e37214623ee13c03efd7bb23e8a93cf859b245caa5fa0b50d7518757cf98664f36efca7659c1892e777a cose/sign1/ed25519_single_at_nss.ctx.json +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff cose/sign1/ed25519_single_at_nss.expect +db7dcbd100ae415ff98c586d16c05dc6cc2fb18e8a5213f4b2595d457562c3b9fc0f29f7fe9cca141c570f710d050496 cose/sign1/pair_typ_in_single_carrier.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/pair_typ_in_single_carrier.ctx.json +676072035dd177e6933ce59ce5f22f8f96422713354f9ae3bb2e3e756f59bea3cbaab1c7a3d7d1829a0a10be2f8e84c9 cose/sign1/pair_typ_in_single_carrier.expect +4ef7f6412f9328377aa7fb8c4d14e25fc2bb03edb68d3b2b6e59758f80891273da612c65fd1c1d2a1fd66e240e70e84a cose/sign1/sign1_attached.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/sign1_attached.ctx.json +6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/sign1_attached.expect +dc0a45f83ea1ae5c5f61b3cb9c56ddfdd706435897a9ec120bb4ab68e17ba4954f1e5322b4e2be632a141bf81db3ce70 cose/sign1/sign1_tagged.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/sign1_tagged.ctx.json +f6473c3a453c7392810e4e5e9b41cd1bcd3968ba7799387ac0dcc6bc59a7190c4172cb05b4a0b26998d7a61f0cc312f4 cose/sign1/sign1_tagged.expect +2dbb15cf8526494210fc212aa11d2e1eb058ff1332abcc621646e4c0f5a215e2cf9fc9a046b6b7f55cd2047cfa129883 cose/sign1/sign1_unprotected_kid.bin +c9998e8bf4c713a2c55faf42dd68671a382db0d585d7830a77915ea850ddd34e132e11b00a0449c10d30c052832e9894 cose/sign1/sign1_unprotected_kid.ctx.json +c9e09f8bf67b2857043cd7029d06f50b97a0f28946f1e8d7c4022d27b43262f5a389c1c07be090a5528051b3cdd0f879 cose/sign1/sign1_unprotected_kid.expect cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7 digest/abc.in 23f24ce4bb582a0a59e71588f4cf5b2aaf21e5cc65b7ee88265940adec375c4b39187bc3fb85c33ed40c9fc87380346a digest/abc.ref.cbor 3b87f97be5c59b8c657ddfd50f91411d085e18ffc917b9e6de661c9f17f03546f6f7abdeaced4355641ed459b925f807 digest/negative/digest_alg_not_permitted/sha256.cbor @@ -296,6 +335,10 @@ b35433a1e93042b7800388a8cdaacc0c4d687eaf2c732e97833d2fbfcf727e4842dcf11c79d12387 f116bf4ce82cb0cd46d91e5ff2442b3f527eae8affefcef0678d2e37980babed66a01689a4d2bc0fa36195601a83f02d encoding/positive/uint_max_u64.diag 38999b3fb39414528cb2bf618aa60979755e666a2a2358feb1796694ea50c50319c90b6cdc5d2e5085d7984e0e9153c6 header/negative/alg_missing/no_label_1.cbor c9e09f8bf67b2857043cd7029d06f50b97a0f28946f1e8d7c4022d27b43262f5a389c1c07be090a5528051b3cdd0f879 header/negative/alg_missing/no_label_1.expect +44f2e52792b35d1bee0fc942dcf6853bf6ea54b54019049e065857a8f45ff9ed66c963f94e204accc502160ac2669579 header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.cbor +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect +3907dc98300c0e2ba8173d228018b37016d16121f785192da94e47e4bad0990c90e12c670b078843424d540974dc7e6b header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.cbor +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect 78dffd69b0ef23c3c594da067e7de10004866506d28f3ff8059883f777e03deef713f1a0ebc9b1a51ea3daf8fea972ca header/negative/alg_not_mldsa87/eddsa.cbor 2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff header/negative/alg_not_mldsa87/eddsa.expect f158dcfe0b056f6ba777f297fa4de874c310130a13724c67fa0b098f585dc3635cd9a8ec9c2d5d389f1b4b6e48561087 header/negative/alg_not_mldsa87/mldsa65.cbor @@ -332,6 +375,9 @@ c38c1bb721e5f1c4f62dd9b2a6db895af7350fbd905c5609ef068bc54b87f9453e80c26cb9574f86 c38c1bb721e5f1c4f62dd9b2a6db895af7350fbd905c5609ef068bc54b87f9453e80c26cb9574f86fe32c34dc5f4f88c header/negative/typ_uint/typ_1.expect d5da8e2d7de0437d378fbb09fac16fc4a1e073e1afc704648b66555623c86d73840241553dfc9e722cae66dccb9619f8 header/positive/alg_kid_cwt_typ_scitt.cbor a9b5e6706fec09c6f59343ef85022f91767b3939965b27a3053852638968d916b7b9142c315f241d6155171c7a4f0f98 header/positive/alg_kid_typ.cbor -4e833218fa3384f4ca4ef6d9d86c79fbdb36f24a0c1379027485ee8fb804c7fd8f05e6ca6a3820b5f9b3d7b88707c2b2 header/profile-table.json +759a6fa1580e08c85aef93715a74bc444dd8185c132db40ef9d1e08fb3eaa96f51067dfa99ab1a55339c1956520137fa header/positive/ed25519_alg_kid_typ.cbor +38a75cb2d7a8d721942a94f7ac6246a3162323240af9ea38b08fe1cb75f19fa28d985647e1538026a808928471580e81 header/profile-table.json aab9ecd6c7320915435631cae66cf8bc25a4c33d782c8fe5f81a55eb40e3df2d81450d101f068d52bed4f37a2ec0492e thumbprint/acvp_mldsa87_tg3_tc51.kid 954bc653b6b8f323b050607cb2db6560e0a6634ec020083892bfb7c2cd4370b9b3b44161739b9cc9a6a4d56ac17d3bc2 thumbprint/acvp_mldsa87_tg3_tc51.pub +72ba5baf301e2836e739b1ffcb9afb5373785acad1e1299ff23ef8558ff51e053fd673f7a840ec0462c0f8a7660b7a63 thumbprint/rfc8032_test1_ed25519.kid +66f256987d910812bdbe2268d564fc4468ec86f0a7c93be296b2166d5c887451f7b1672e5dbd211039d8e38abf02aec5 thumbprint/rfc8032_test1_ed25519.pub diff --git a/test/fixtures/n6/VERSION b/test/fixtures/n6/VERSION index 3eeddaa..f52500b 100644 --- a/test/fixtures/n6/VERSION +++ b/test/fixtures/n6/VERSION @@ -1 +1 @@ -n6/v0 draft r3 PROVISIONAL (Trinity candidate set) +n6/v0 draft r3 and the publisher's answers of 2026-10-19 PROVISIONAL (Trinity candidate set) diff --git a/test/fixtures/n6/cose/sign1/carrier_extra_key.bin b/test/fixtures/n6/cose/sign1/carrier_extra_key.bin new file mode 100644 index 0000000000000000000000000000000000000000..85e87636e0dfefe83eaf08a4af84d2e0574671d3 GIT binary patch literal 198 zcmV;%06G7p02=@Q00RL|q5uI9X>)98V`U&QGdTi;SY@LDG6YyKq)y~YH&u=&D+d%8 zlWx1z`(Gsmq2<@pOX>>Oq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJk zZe%WHcwudDY-KKVWpi{bWn?lnH8D9Zb}=hsZ*yg!_E)98V`U&QGdTi;SY@LDG6YyKq)y~YH&u=&D+d%8 zlWx1z`(Gsmq2<@pOX>>Oq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJk zZe%WHcwudDY-KKVWpi{bWn?lnH8D9Zb}=hsZ*yg!_E>O zq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJkZe%WHcwudDY-KKVWpi{b zWn?lnH8D9Zb}=hsZ*yg!_EV>Xc5Y-Y zWq4t2aBO8RbY*jNEoEdfH8n9gE_N|1V{db1p!QfmME8^H_i6=8d4oiWGh<=4|o>I&DSm{rA8jbfz_eu4cSz**x&KFHhsqb%VtduDURL4+C0fw1FTa2S?mBE0c=?A9Z62Lo#-Ql0<+ literal 0 HcmV?d00001 diff --git a/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json b/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json new file mode 100644 index 0000000..e2c8018 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/carrier_payload_text.expect b/test/fixtures/n6/cose/sign1/carrier_payload_text.expect new file mode 100644 index 0000000..573f3fd --- /dev/null +++ b/test/fixtures/n6/cose/sign1/carrier_payload_text.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"carrier_malformed"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin b/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin new file mode 100644 index 0000000000000000000000000000000000000000..5ce6d7c129a5da5d635119a70f446a91989ee51e GIT binary patch literal 196 zcmV;#06YJq02=@Q00RL|q5uI9X>)98V`U&QGdTi;SY@LDG6YyKq)y~YH&u=&D+d%8 zlWx1z`(Gsmq2<@pOX>>Oq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJk zZe%WHcwudDY-KKVWpi{bWn?lnH8D9Zb}=hsZ*yg!_E)98V`U&QGdTi;SY@LDG6YyKq)y~YH&u=&D+d%8 zlWx1z`(Gsmq2<@pOX>>Oq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJk zZe%WHcwudDY-KKVWpi{bWn?lnH8D9Zb}=hsZ*yg!_E)98V`U&QGdTi;SY@LDG6YyKq)y~YH&u=&D+d%8 zlWx1z`(Gsmq2<@pOX>>Oq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJk zZe%WHcwudDY-KKVWpi{bWn?lnH8D9Zb}=hsZ*yg!_E6hiD z=%rR9<`(3n>Xnpd=INHC7MJLi8EPlz7pE@x7AYjmFbYOPU^E0qLtr!nMnhmU1V%$( eGz3ONU^E0qLtr!nMnhmU1V%$(Gz3Ok2mk;r$1&~z literal 0 HcmV?d00001 diff --git a/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.ctx.json b/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.ctx.json new file mode 100644 index 0000000..e2c8018 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.expect b/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.expect new file mode 100644 index 0000000..43d3e3a --- /dev/null +++ b/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"wrong_carrier_for_typ"} diff --git a/test/fixtures/n6/cose/sign1/sign1_attached.bin b/test/fixtures/n6/cose/sign1/sign1_attached.bin new file mode 100644 index 0000000000000000000000000000000000000000..62aaf394d7226550285441a335b0f256e84201ef GIT binary patch literal 210 zcmZ3?Aoc%012d!FA_hi*%;KEP%ynkFMGe@y1rya=+Thgq^10Ijeq41L_T)0J8?&#LMO4H0BBBPNoIbYepz0M zUTQ^RZb43}UP)?kiEe6&k*TSnrCyn#c5;4k>H;*2BOF}5Pk#M9i`Bcb*`?E@;+(JK zjJTaUUI;&1Ie*b4o+I&7WBM2CJT$0ID?aMjB6+%T!{e9&iD_v{dp=gaxu#sg&I|zU CT~^=# literal 0 HcmV?d00001 diff --git a/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json b/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json new file mode 100644 index 0000000..e2c8018 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/sign1_attached.expect b/test/fixtures/n6/cose/sign1/sign1_attached.expect new file mode 100644 index 0000000..573f3fd --- /dev/null +++ b/test/fixtures/n6/cose/sign1/sign1_attached.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"carrier_malformed"} diff --git a/test/fixtures/n6/cose/sign1/sign1_tagged.bin b/test/fixtures/n6/cose/sign1/sign1_tagged.bin new file mode 100644 index 0000000000000000000000000000000000000000..09ae8450592d0a110e4200c63c5bbe150b3cfc9a GIT binary patch literal 197 zcmV;$06PDp02=@Q00RL|q5uI9X>)98V`U&QGdTj%gji*x0Wt(wFr-f8N;g%GCo2aO z7n5$g)cap01)=5F(@W|K*QA(L#Z--Ar4N3A{T{$s<3v8l+z@yyVQ_G4X=7n@X>V>X zc5Y-YWq4t2aBO8RbY*jNEoEdfH8n9gE_N|1V{db1p!QfmME8^H_i6=8d4oiWG)98V`U&QGdTi;SY@LDG6YyKq)y~YH&u=&D+d%8 zlWx1z`(Gsmq2<@pOX>>Oq?lF3RE=V#4}O9D9>7`SL_Wye5O^$MaBysCV_|e@Z*DJk zZe%WHcwudDY-KKVWpi{bWn?lnH8D9Zb}=hsZ*yg#1VI4ySU^Pglk4|t1xtB@M2R$b z&P^GZUb(pF6XvO(qLK{6UzJ>sqb%VtduDURL4+C0fw1FTa2S?mBE0c=?A9Z62Ls-Z BQ{?~v literal 0 HcmV?d00001 diff --git a/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json new file mode 100644 index 0000000..e2c8018 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.expect b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.expect new file mode 100644 index 0000000..3822c49 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"header_not_protected"} diff --git a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.cbor b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.cbor new file mode 100644 index 0000000..54ce494 --- /dev/null +++ b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.cbor @@ -0,0 +1,2 @@ +£2X0¤NäJ7UŽ'+“n»Ôû_%¡å×ÓKê +פ˜UÅT�b¥~�ýÀYãD>ÈÜx$application/vnd.example.test.v1+cose \ No newline at end of file diff --git a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect new file mode 100644 index 0000000..30fde88 --- /dev/null +++ b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"alg_not_permitted"} diff --git a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.cbor b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.cbor new file mode 100644 index 0000000..0e5552a --- /dev/null +++ b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.cbor @@ -0,0 +1 @@ +£81X0tÌÎÊHY"•<ÍZèœ'p¤åËJäI)ó9Àìd9†)í¶�Z=3W`2ku¼x,application/vnd.example.test-ed25519.v1+cose \ No newline at end of file diff --git a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect new file mode 100644 index 0000000..30fde88 --- /dev/null +++ b/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"alg_not_permitted"} diff --git a/test/fixtures/n6/header/positive/ed25519_alg_kid_typ.cbor b/test/fixtures/n6/header/positive/ed25519_alg_kid_typ.cbor new file mode 100644 index 0000000..04d7e01 --- /dev/null +++ b/test/fixtures/n6/header/positive/ed25519_alg_kid_typ.cbor @@ -0,0 +1,2 @@ +£2X0¤NäJ7UŽ'+“n»Ôû_%¡å×ÓKê +פ˜UÅT�b¥~�ýÀYãD>ÈÜx,application/vnd.example.test-ed25519.v1+cose \ No newline at end of file diff --git a/test/fixtures/n6/header/profile-table.json b/test/fixtures/n6/header/profile-table.json index 42fe86a..c2fc81c 100644 --- a/test/fixtures/n6/header/profile-table.json +++ b/test/fixtures/n6/header/profile-table.json @@ -1 +1 @@ -{"note":"Trinity's test profile table for the header vectors (the draft leaves its CDDL Open, VB4).","provisional":true,"typ":[{"carrier":"single","scitt":false,"signers":"single","typ":"application/vnd.example.test.v1+cose"},{"carrier":"single","scitt":true,"signers":"single","typ":"application/vnd.example.scitt-test.v1+cose"}]} +{"note":"Trinity's test profile table for the header vectors (the draft leaves its CDDL Open, VB4).","provisional":true,"typ":[{"alg":-50,"carrier":"single","scitt":false,"signers":"single","typ":"application/vnd.example.test.v1+cose"},{"alg":-50,"carrier":"single","scitt":true,"signers":"single","typ":"application/vnd.example.scitt-test.v1+cose"},{"alg":-19,"carrier":"single","scitt":false,"signers":"single","typ":"application/vnd.example.test-ed25519.v1+cose"},{"alg":-50,"carrier":"envelope","scitt":false,"signers":"pair","typ":"application/vnd.example.twin-test.v1+cose"}]} diff --git a/test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.kid b/test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.kid new file mode 100644 index 0000000..71f4069 --- /dev/null +++ b/test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.kid @@ -0,0 +1,2 @@ +¤NäJ7UŽ'+“n»Ôû_%¡å×ÓKê +פ˜UÅT�b¥~�ýÀYãD>ÈÜ \ No newline at end of file diff --git a/test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.pub b/test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.pub new file mode 100644 index 0000000..9ed9163 --- /dev/null +++ b/test/fixtures/n6/thumbprint/rfc8032_test1_ed25519.pub @@ -0,0 +1,2 @@ +×Z˜‚± +·ÕKþÓÉd:áróÚ¦#%¯h÷Q \ No newline at end of file diff --git a/test/fixtures/receipts/ed25519_checkpoint.json b/test/fixtures/receipts/ed25519_checkpoint.json new file mode 100644 index 0000000..4d11cbc --- /dev/null +++ b/test/fixtures/receipts/ed25519_checkpoint.json @@ -0,0 +1 @@ +{"chain_scope":"fixture:ed25519","checkpoints":[{"boot_receipt_hash":null,"chain_scope":"fixture:ed25519","first_seq":1,"key_id":"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k","last_seq":1,"reason":"fixture","scheme":"receipt_v3_ed25519","signature_b64":"Hz94r9q0m11zL39a6RZfCpzBCGdgmTlAhT/s9H8Iw8aez6HCW6gSR2XeYTKcmYTpS8gXTq2xwISTjurKRXfPAA==","signed_payload":"{\"at\":\"2026-10-20T00:00:00.000000Z\",\"boot_receipt_hash\":null,\"chain_scope\":\"fixture:ed25519\",\"first_seq\":1,\"key_id\":\"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k\",\"last_seq\":1,\"reason\":\"fixture\",\"scheme\":\"receipt_v3_ed25519\",\"tail_hash\":\"6468a423c1775e5fe9d1106341383a3694e2cd2632105366ea8d6ff48bb266f9\"}","tail_hash":"6468a423c1775e5fe9d1106341383a3694e2cd2632105366ea8d6ff48bb266f9"}],"exported_at":"2026-10-20T00:00:00.000000Z","format":"trinity-receipts-export/1","receipts":[{"chain_scope":"fixture:ed25519","key_id":"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k","kind":"query","meta":{},"prev_hash":null,"receipt_hash":"6468a423c1775e5fe9d1106341383a3694e2cd2632105366ea8d6ff48bb266f9","scheme":"receipt_v3_ed25519","seq":1,"signature_b64":null,"signed_payload":"{\"at\":\"2026-10-20T00:00:00.000000Z\",\"chain_scope\":\"fixture:ed25519\",\"clock\":{\"counter\":0,\"node\":\"fixture\",\"wall\":1792454400000},\"decision\":null,\"fingerprint\":null,\"key_id\":\"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k\",\"kind\":\"query\",\"prev_hash\":null,\"scheme\":\"receipt_v3_ed25519\",\"seq\":1,\"subject\":{\"fixture\":\"slice 139\"}}"}],"registry":[{"algorithm":"ed25519","custody":"file","fingerprint":"21fe31dfa154a261626bf854046fd2271b7bed4b6abe45aa58877ef47f9721b9","jwk":{"crv":"Ed25519","kty":"OKP","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"},"key_id":"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k","kid_scheme":"rfc7638","public_key_b64":"11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=","scheme":"receipt_v3_ed25519","status":"active","valid_from":"2026-10-20T00:00:00.000000Z"}]} diff --git a/test/fixtures/receipts/ed25519_signed.json b/test/fixtures/receipts/ed25519_signed.json new file mode 100644 index 0000000..9e4bab2 --- /dev/null +++ b/test/fixtures/receipts/ed25519_signed.json @@ -0,0 +1 @@ +{"chain_scope":"fixture:ed25519","checkpoints":[],"exported_at":"2026-10-20T00:00:00.000000Z","format":"trinity-receipts-export/1","receipts":[{"chain_scope":"fixture:ed25519","key_id":"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k","kind":"decision","meta":{},"prev_hash":null,"receipt_hash":"2f662fe05d3b868fd2f2fc39add39a668ed9e9657010b572875d9701efbf636d","scheme":"receipt_v3_ed25519","seq":1,"signature_b64":"wRxI3/Ukk5cvE98AoO5OO5vMjejpNP/4ITF9ukIunEsIA5O4g/LrkFW97gIHIcFJwQdZPkWxcBoYRa9D4ydGDQ==","signed_payload":"{\"at\":\"2026-10-20T00:00:00.000000Z\",\"chain_scope\":\"fixture:ed25519\",\"clock\":{\"counter\":0,\"node\":\"fixture\",\"wall\":1792454400000},\"decision\":{\"basis\":\"fixture\",\"outcome\":\"allow\"},\"fingerprint\":null,\"key_id\":\"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k\",\"kind\":\"decision\",\"prev_hash\":null,\"scheme\":\"receipt_v3_ed25519\",\"seq\":1,\"subject\":{\"fixture\":\"slice 139\"}}"}],"registry":[{"algorithm":"ed25519","custody":"file","fingerprint":"21fe31dfa154a261626bf854046fd2271b7bed4b6abe45aa58877ef47f9721b9","jwk":{"crv":"Ed25519","kty":"OKP","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"},"key_id":"kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k","kid_scheme":"rfc7638","public_key_b64":"11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=","scheme":"receipt_v3_ed25519","status":"active","valid_from":"2026-10-20T00:00:00.000000Z"}]} diff --git a/test/fixtures/receipts/mldsa87_checkpoint.json b/test/fixtures/receipts/mldsa87_checkpoint.json new file mode 100644 index 0000000..0af9b5a --- /dev/null +++ b/test/fixtures/receipts/mldsa87_checkpoint.json @@ -0,0 +1 @@ +{"chain_scope":"fixture:mldsa87","checkpoints":[{"boot_receipt_hash":null,"chain_scope":"fixture:mldsa87","first_seq":1,"key_id":"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ","last_seq":1,"reason":"fixture","scheme":"receipt_v3_mldsa87","signature_b64":"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","signed_payload":"{\"at\":\"2026-10-20T00:00:00.000000Z\",\"boot_receipt_hash\":null,\"chain_scope\":\"fixture:mldsa87\",\"first_seq\":1,\"key_id\":\"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ\",\"last_seq\":1,\"reason\":\"fixture\",\"scheme\":\"receipt_v3_mldsa87\",\"tail_hash\":\"ad76bfd578294b165d89ddc2ea0657487cd371b7df8c88c7a89c3b38644c5682\"}","tail_hash":"ad76bfd578294b165d89ddc2ea0657487cd371b7df8c88c7a89c3b38644c5682"}],"exported_at":"2026-10-20T00:00:00.000000Z","format":"trinity-receipts-export/1","receipts":[{"chain_scope":"fixture:mldsa87","key_id":"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ","kind":"query","meta":{},"prev_hash":null,"receipt_hash":"ad76bfd578294b165d89ddc2ea0657487cd371b7df8c88c7a89c3b38644c5682","scheme":"receipt_v3_mldsa87","seq":1,"signature_b64":null,"signed_payload":"{\"at\":\"2026-10-20T00:00:00.000000Z\",\"chain_scope\":\"fixture:mldsa87\",\"clock\":{\"counter\":0,\"node\":\"fixture\",\"wall\":1792454400000},\"decision\":null,\"fingerprint\":null,\"key_id\":\"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ\",\"kind\":\"query\",\"prev_hash\":null,\"scheme\":\"receipt_v3_mldsa87\",\"seq\":1,\"subject\":{\"fixture\":\"slice 139\"}}"}],"registry":[{"algorithm":"mldsa87","custody":"file","fingerprint":"33f49649f05ec2fc3b050007b18ade043bbc8d1c0ded03a269d540486daaa5f4","jwk":null,"key_id":"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ","kid_scheme":"sha256-raw","public_key_b64":"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","scheme":"receipt_v3_mldsa87","status":"active","valid_from":"2026-10-20T00:00:00.000000Z"}]} diff --git a/test/fixtures/receipts/mldsa87_signed.json b/test/fixtures/receipts/mldsa87_signed.json new file mode 100644 index 0000000..f6fb981 --- /dev/null +++ b/test/fixtures/receipts/mldsa87_signed.json @@ -0,0 +1 @@ +{"chain_scope":"fixture:mldsa87","checkpoints":[],"exported_at":"2026-10-20T00:00:00.000000Z","format":"trinity-receipts-export/1","receipts":[{"chain_scope":"fixture:mldsa87","key_id":"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ","kind":"decision","meta":{},"prev_hash":null,"receipt_hash":"44a2a801904e7611149abaf7093b7ae46917e882653ee220c957c050cba45b17","scheme":"receipt_v3_mldsa87","seq":1,"signature_b64":"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","signed_payload":"{\"at\":\"2026-10-20T00:00:00.000000Z\",\"chain_scope\":\"fixture:mldsa87\",\"clock\":{\"counter\":0,\"node\":\"fixture\",\"wall\":1792454400000},\"decision\":{\"basis\":\"fixture\",\"outcome\":\"allow\"},\"fingerprint\":null,\"key_id\":\"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ\",\"kind\":\"decision\",\"prev_hash\":null,\"scheme\":\"receipt_v3_mldsa87\",\"seq\":1,\"subject\":{\"fixture\":\"slice 139\"}}"}],"registry":[{"algorithm":"mldsa87","custody":"file","fingerprint":"33f49649f05ec2fc3b050007b18ade043bbc8d1c0ded03a269d540486daaa5f4","jwk":null,"key_id":"M_SWSfBewvw7BQAHsYreBDu8jRwN7QOiadVASG2qpfQ","kid_scheme":"sha256-raw","public_key_b64":"pXh+gEQkjz+FqsVOlGn8mPGxE4zBJ7Eg+ZRsgLluPYnM/jjJlWRdS2pVnqyyr7gWIddlxuQucwMdRMvnTTIsexYklXbrTFACU1ONGixrQI5oG5O5AU4xR9++z52YWPfoY1+FmLpoRxJ9IWqIj/sWNsx2FhagOJw5pCRWld3gyGzPijvFpQ6m/bzAo0RX1N7+Nfd1xZk2ha7yI3wxkSphn/gEr+isNBjBNQKCCu5SSdbld+4LKl4+jaLdMPUUoHa1BVbXWBuh+bLkZxdWpjBlwg69pu4sM8nZerFMXyIE/FNZrbK9qjqreuHaz/GKZ9gBvLjwVLvERPD9AAGnkI69zfK4TzwCbuwSgkmNMawzrmowmsqxe3Dcnw775SZI0a0qTNWWTcYZtmzcnTXtp6PcIccpuZKQJNuLhS398QKghoRXAv0knuQ7VNLQM6upURDE8KZu6u5488QdXXkqN9HSKZJSpUmKRMo1T283/cLztysag3iluotJl1VuWm9BJa2Ua9T8QCxDILJxEbwgS4tUSPQ/fneoFmpIE32FWEvv4tnIXMzZu/P4pOBZMBgK/daX2oLtnxBpFQ/8dleMlBzbzFvS7LfWq/nmgyffUcJsi0LLHdi9qYqCxMa6apkeZRvf9o8KYtXb/qAg1DA+DFPUdMsR1VPFvuEVaRfXKsKmzMTsHHddQe5mDiSFpFr1p8oIPd3MPuT//F53upfMRHMwPHfYtv2zXiogYnvrvjJ90tGtH4gMqOrh4AZ6kJOSnlqxjUBqUY7djB4PCrB3NvVfzs9KOyrc4c4+CAtY3fhaImLQgDp+W05IXmQrpTPi7HpD+ejbIPdSkq2scEOVRpQIwVZBqcKLg+jBx5n64GUvUTaZePTAif4V3XjF9WDNKPX3X+CjmmCmGu9sfYAhQemAnnrKaKOL6b31MSJYcE+LEa9CYiIMtVZB/pXfg+yfeG1uaSAskexMqk44ohw8xgnCi49lVS/oM0hQhYu7MLg32HSGfq0zCh9bTX9r7EdIvlSngtWyGhkrwAp/IkD72QBGB4XRlxqUxYdJPSHNoklnbtrGyGUUfiaUiLnKePrv3HeP9g55c13VU5h5GCQksFT66KnhU7/9aVfFM67cEF5DrXYmMSyNIpMn0+cqqWRL8+LJoIq/gHzzpHLH2vCsQpCp6PiNB66P7bikshjD672+U4gngfLeA0sX/v5pMCtJdc9D4DZF3FO9NVr5iLOj0kMb+dmoZXUAUe7Xuu/RutSTnH7Ck1CaWFGhRfedy+79GVVxrCFyrGA2gStNyAQNGGuJhM+dwk+PdlFmxrLoOJ3STtY87pUUQoYWab2gYivZCwQdxHfAGg2V1Ufgeoks4fJidavG+XcC4Dt3biynHj0Ou4jRrfWREi5vDslabPu5dq5kvQx/B0y2545kTe6CAOLWJkNZB7kTQADbw7UCcfWo8lTSzwLaA51FjoD6E6M1Z6qbN0tHt5nQ/xoUypKlDvoZLqFkH+KaOA4ROGUotySNbeaqkOHJdEcTt2gmS3KhPMqpTz4ZraUSng2BVewbJn4s3H8OegjyA9mhj4yNGFKXCe90aiHV/sNutUei/USQCSrQ8GxV7gocsQTiw8PPK8vL/+rsdEoT43MQlizkDgcufsriI5Q6AwAgd9PZbHtMP7Hiye6cUiKmMlLyY3K8K9lFB85XKM63oKozUn4GYrRWHRvSVYBkUHcuq7ftQPeHouPGZPprw76bzYT8e0Lxb5TMVs+2cpfkdRd+GeUQEM+3S6mWuhw9eT6gEMmZAeIiOzdeNkuhk3crMp1dBa7ZWeu5JLaY8GV69D7ujg1U0VyTURIJqqnhAlG/ga2LRn2PruKkQHgsNy9Vpiz+9AiAPks72O+5QGHqtSW9MZV3ebiescdauX8niz6vmaBWhuBIc9emhYaNHA9FEK2rCyZ/5NPLcMNbKVr8ZxtgxldbYO65l1bnsgSiTXCV3yd74YZo4PGqViHRbyBFdfdsOxM4W69hr/fzbVYRH9iP8JO+xP/Ca6Y3IGYLW9IJqcFMCsa04Is4/1gMGOOaIqmsNpEoklN7D9QoAERdvvGgPS2GJMGxJVGZJ8KC7vrEr8FhKLB0VvvJnTTHg64I67DEaRWXFCn+ZORI8WeL92x8IPka2A4hPjmtiZYq6kTgbqs1HJs6WFnVqITeDNdnJgy2Cpb1CMYrcxBHyx88pq4odCdx9Ow75F31APATK26UfSRoqrlBC8H1gTKEcbPlPo4poHlPTqi9+f2zkizsvSqq91oq9MfOugM4IzLTnft3BVl4lwiTDkx3lmxzZOKk12LBIr4/y9NyduphlAcbt8GOJidSTzqyvnwMbln2LR8HXhlR3TNS9sqXYvJD9mkf5qnc5CeLF49ojkM7mQJyAA8jyS90oc/xQpvfHD/huaocDnpYq/+7VNPzjtk7EfsSIzz/SMgSoid0eEkBL3u4VSnYdFnhHcqfepwyQgVNtruTtIpH1punkbKl1pSod2siuIgbstGSqwpFrscacXH4ROOxxBSdEY76iZ6I2WopbV64Ea+SOv0akuDHFGS5DI5ewklU/oyb+1GKMIs9MB2NbGLlrUD2O84kaZrwu9Lkj9mqfDocWXcxcA49huFq02pnvAMb84G4LPQLGyNfUXJ5E7vDEaGG/cI+4mdzl0DMV8Nr4F+TMepAJ5w0xE+4/fzH6c2lTDlM75wX+FKaVuO8y6Mnyr0H9fnFZ7cN+9nXE545t+Gkk0gjIbEciBup1Ey6vqZPGvGMGLO1+Y/0qV/ZB+ESrthXsDO7fg/5Rmz1/GkczBFCv7jOgbrX21VECbeeI9CkEGOFfkqgVYNSXmntqgF/an+pbCWjjnp9AeIvlq27aDtOuKSHICQm1o0pezC+CoA9T8oeA32zvWOi8G79aL1Ne+rYM402rBQlFjs8c5uGrJ2WftVN4kyli8Epz/c1hbZn3DPzK8Sk2eEGB2hJvCV08OWrLAtcvbylm7ZEvIbzM5sjs7EpWfx4h/cpETY0PlT0pWw0TcW+ZB285irtTUu5WMTVTXGW20rLih9D+Izbt1hUb0TFkpNQHgVp8AuX/CRFB8JIAx0H7k27apcnC3cnkedY5YKYnZMSSrgvFancpGnBoOmMx1/2g0MANvFrTpTpTeygbr5/4rjpwKaQqtepGHd5n88kzidYRKY+aPGlx5m8g8f1OEzZMi4gthnTnQAxSC/+2CJPbFIshTPLKcBK8VTZINdH2BYW6iGeNYOFqp/bjpSn7ltT8swxs6e6x4flSrlTb8QqPjaQQ8b1wR0PfUUoUsP7PxhFlCGGBEOF+55IKWKx2uvSs98SXRphhD9xonLjodl6q5feWDHFbRampmIPjGzQ9PHkGv5olbo2ZNI6A+qzUxEm6HM19Ly78uOcR7Wli6FQZveXF9gpZmdVPs0JkfFNQviTTXU5KfFG5LWNAKPgE51m","scheme":"receipt_v3_mldsa87","status":"active","valid_from":"2026-10-20T00:00:00.000000Z"}]} diff --git a/test/support/ed25519_oracle.ex b/test/support/ed25519_oracle.ex new file mode 100644 index 0000000..77f7c1c --- /dev/null +++ b/test/support/ed25519_oracle.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestSupport.Ed25519Oracle do + @moduledoc """ + Whether this runtime verifies Ed25519, asked of `:crypto` directly with RFC 8032 section 7.1's + test 1 (the empty message under the published key). An oracle for slice 139's tests, independent + of the code they are about. It answers false in FIPS mode, which refuses Ed25519. + """ + + @pub "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" + @sig "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + + @spec verifies?() :: boolean() + def verifies? do + pub = Base.decode16!(@pub, case: :lower) + sig = Base.decode16!(@sig, case: :lower) + :crypto.verify(:eddsa, :none, "", sig, [pub, :ed25519]) == true + rescue + _ -> false + end +end diff --git a/test/support/n6_vectors.ex b/test/support/n6_vectors.ex index 64123b7..3607440 100644 --- a/test/support/n6_vectors.ex +++ b/test/support/n6_vectors.ex @@ -71,7 +71,70 @@ defmodule Trinity.TestSupport.N6Vectors do |> File.read!() |> JSON.decode!() |> Map.fetch!("typ") - |> Map.new(&{&1["typ"], %{scitt: &1["scitt"]}}) + |> Map.new(fn e -> + {e["typ"], + %{ + scitt: e["scitt"], + alg: e["alg"], + carrier: String.to_atom(e["carrier"]), + signers: String.to_atom(e["signers"]) + }} + end) + end + + @doc """ + The single-signer carrier samples (slice 139): `{id, bytes, exit, code, opts}`, where `opts` is + what `Trinity.N6.Carrier.verify/2` takes, read from the sample's `.ctx.json`. + """ + def samples do + for bin <- Path.wildcard(Path.join([@root, "cose", "sign1", "*.bin"])) |> Enum.sort() do + id = Path.basename(bin, ".bin") + base = Path.rootname(bin) + + %{"exit" => exit, "verdict_code" => code} = + (base <> ".expect") |> File.read!() |> JSON.decode!() + + ctx = (base <> ".ctx.json") |> File.read!() |> JSON.decode!() + + registered = + Map.new(ctx["keys"], fn %{"alg" => alg, "public" => rel} -> + pub = File.read!(Path.join(@root, rel)) + {thumbprint(alg, pub), %{alg: alg, public: pub}} + end) + + opts = [ + keys: &Map.fetch(registered, &1), + typ: typ_table(), + entry: String.to_atom(ctx["entry_point"]) + ] + + # `to_atom`, not `to_existing_atom`: which atoms exist depends on which modules a test run has + # loaded first, and these strings come from the checked-in fixtures, not from input. + {id, File.read!(bin), exit, String.to_atom(code), opts} + end + end + + # RFC 9679 over the AKP (RFC 9964 section 6) or OKP (RFC 9053 section 7.1) members, built here + # from the fixed prefixes rather than asked of `Trinity.N6.COSE`, the code under test. + defp thumbprint(-50, pub), + do: :crypto.hash(:sha384, <<0xA3, 1, 7, 3, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> <> pub) + + defp thumbprint(-19, pub), + do: :crypto.hash(:sha384, <<0xA3, 1, 1, 0x20, 6, 0x21, 0x58, 0x20>> <> pub) + + @doc "RFC 8032 section 7.1, test 1: the published Ed25519 key." + def rfc8032_key do + %{ + alg: -19, + public: + Base.decode16!("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + case: :lower + ), + private: + Base.decode16!("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + case: :lower + ) + } end @doc "NIST ACVP's ML-DSA-87 keyGen case (slice 137 pins it): seed, public key, expanded secret key." diff --git a/test/trinity/n6/carrier_test.exs b/test/trinity/n6/carrier_test.exs new file mode 100644 index 0000000..776bd4b --- /dev/null +++ b/test/trinity/n6/carrier_test.exs @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.CarrierTest do + @moduledoc """ + Slice 139, AC4 and AC5: the single-signer carrier, `{0: SINGLE_SCHEMA, 1: payload, 2: N6_Sign1}`, + built, parsed and verified, and every sample in `test/fixtures/n6/cose/sign1/` giving the outcome + its `.expect` names under its `.ctx.json`. + + The signed sample is Ed25519. Where the runtime refuses Ed25519 (FIPS mode), a sample whose + outcome needs the signature checked answers `alg_unavailable` (5) instead, and every other + sample keeps its code, because those are decided before any signature is looked at. + """ + + use ExUnit.Case, async: true + + alias Trinity.N6.{Carrier, CBOR, COSE, Profile} + alias Trinity.TestSupport.{Ed25519Oracle, N6Vectors} + + # The outcomes that need the signature checked. + @judged [:claim, :signature_invalid] + + test "AC5: every sample gives its expected outcome under its context" do + samples = N6Vectors.samples() + assert length(samples) >= 13 + ed25519? = Ed25519Oracle.verifies?() + + for {id, bytes, exit, code, opts} <- samples do + answer = Carrier.verify(bytes, opts) + + cond do + code in @judged and not ed25519? -> + assert answer == {:error, 5, :alg_unavailable}, id + + code == :claim -> + assert {:ok, %{exit: 0, verdict_code: :claim}} = answer, id + + true -> + assert answer == {:error, exit, code}, id + assert Profile.exit_of(code) == exit + end + end + end + + test "AC4: the carrier round-trips its parts, and its key 0 is the placeholder schema" do + bytes = File.read!(Path.join(N6Vectors.root(), "cose/sign1/ed25519_single.bin")) + assert {:ok, parts} = Carrier.decode(bytes) + assert Carrier.encode(parts) == bytes + + assert {:ok, %{0 => 0xFFFF0003, 1 => {:bytes, payload}}} = CBOR.decode(bytes) + assert payload == parts.payload + assert Profile.schemas().single == {0xFFFF0003, :placeholder} + end + + test "AC4: the twin envelope's schema, and a body's schema, are not a single-signer carrier" do + {:ok, parts} = + Carrier.decode(File.read!(Path.join(N6Vectors.root(), "cose/sign1/ed25519_single.bin"))) + + sign1 = [{:bytes, parts.protected}, %{}, nil, {:bytes, parts.signature}] + + for schema <- [Profile.schema(:envelope), Profile.schema(:statement_id), 1] do + bytes = CBOR.encode!(%{0 => schema, 1 => {:bytes, parts.payload}, 2 => sign1}) + assert Carrier.decode(bytes) == {:error, :carrier_malformed}, "schema #{schema}" + end + end + + test "AC4: a pair typ is refused before the signature, whatever the signature" do + [{_, bytes, _, :wrong_carrier_for_typ, opts}] = + Enum.filter(N6Vectors.samples(), &(elem(&1, 3) == :wrong_carrier_for_typ)) + + # The sample's signature is 4,627 zero bytes: refused for its typ, never judged. + assert {:ok, %{signature: sig}} = Carrier.decode(bytes) + assert sig == :binary.copy(<<0>>, 4627) + assert Carrier.verify(bytes, opts) == {:error, 1, :wrong_carrier_for_typ} + + assert Carrier.verify(bytes, Keyword.put(opts, :entry, :nss)) == + {:error, 1, :wrong_carrier_for_typ} + end + + test "an unknown key in a carrier is no trust, as for a bare Sign1" do + bytes = File.read!(Path.join(N6Vectors.root(), "cose/sign1/ed25519_single.bin")) + + opts = [keys: fn _ -> :error end, typ: N6Vectors.typ_table(), entry: :commercial] + assert Carrier.verify(bytes, opts) == {:error, 5, :policy_stale_or_unknown_key} + assert {:ok, parts} = Carrier.decode(bytes) + assert COSE.verify(parts, opts) == {:error, 5, :policy_stale_or_unknown_key} + end +end diff --git a/test/trinity/n6/census_test.exs b/test/trinity/n6/census_test.exs index 019e5f5..6693c33 100644 --- a/test/trinity/n6/census_test.exs +++ b/test/trinity/n6/census_test.exs @@ -34,7 +34,8 @@ defmodule Trinity.N6.CensusTest do # path => {calls in that file, class, why} @census %{ - "lib/trinity/n6/cose.ex" => {3, :n6, "thumbprint, ML-DSA-87 sign and verify"}, + "lib/trinity/n6/cose.ex" => + {6, :n6, "thumbprints (AKP and OKP), ML-DSA-87 and Ed25519 sign and verify"}, "lib/trinity/n6/digest.ex" => {1, :n6, "SHA-384 over foreign bytes exactly as given"}, "lib/trinity/archive.ex" => {1, :exact_bytes, "the archive file's bytes"}, "lib/mix/tasks/trinity.image.inspect.ex" => {2, :exact_bytes, "image layer and file bytes"}, diff --git a/test/trinity/n6/cose_signing_test.exs b/test/trinity/n6/cose_signing_test.exs index 5dc0efc..ac6fcb3 100644 --- a/test/trinity/n6/cose_signing_test.exs +++ b/test/trinity/n6/cose_signing_test.exs @@ -8,11 +8,13 @@ defmodule Trinity.N6.COSESigningTest do The key is NIST ACVP's published keyGen case (slice 137 pins it), so no key of this tree's is published: its 32-byte seed and its expanded secret key are two forms of one key. + + Slice 139, AC4: an ML-DSA-87 Sign1 in the single-signer carrier verifies, at both entry points. """ use ExUnit.Case, async: true - alias Trinity.N6.COSE + alias Trinity.N6.{Carrier, COSE} alias Trinity.TestSupport.N6Vectors @moduletag :mldsa87_runtime @@ -23,7 +25,7 @@ defmodule Trinity.N6.COSESigningTest do setup_all do key = N6Vectors.acvp_key() kid = COSE.thumbprint(key.public) - keys = fn k -> if k == kid, do: {:ok, key.public}, else: :error end + keys = fn k -> if k == kid, do: {:ok, %{alg: -50, public: key.public}}, else: :error end %{key: key, opts: [keys: keys, typ: N6Vectors.typ_table()]} end @@ -99,4 +101,20 @@ defmodule Trinity.N6.COSESigningTest do flipped = %{parts | protected: <>} assert COSE.verify(flipped, opts) == {:error, 5, :policy_stale_or_unknown_key} end + + test "slice 139: in the single-signer carrier, it verifies at nss and at commercial", %{ + key: key, + opts: opts + } do + {:ok, parts} = + COSE.sign("a statement", @typ, %{alg: -50, public: key.public, private: {:seed, key.seed}}) + + carrier = Carrier.encode(parts) + assert Carrier.decode(carrier) == {:ok, parts} + + for entry <- [:nss, :commercial] do + assert {:ok, %{exit: 0, verdict_code: :claim}} = + Carrier.verify(carrier, Keyword.put(opts, :entry, entry)) + end + end end diff --git a/test/trinity/n6/cose_test.exs b/test/trinity/n6/cose_test.exs index f991c92..6cb2292 100644 --- a/test/trinity/n6/cose_test.exs +++ b/test/trinity/n6/cose_test.exs @@ -5,20 +5,40 @@ defmodule Trinity.N6.COSETest do Slice 138, AC6 and AC7, and the parts of AC8 that need no signature: the key thumbprint, the header rules, the unprotected header, ToBeSigned, and what `verify/2` answers when it cannot judge. None of it needs a runtime that signs ML-DSA-87, so it runs on every leg. + + Slice 139, AC1 to AC3: the OKP thumbprint, the header rules for Ed25519 and the entry points, and + Ed25519 signing, which is deterministic and needs no special runtime but is refused in FIPS mode, + so each leg takes the branch its runtime allows and the module says which. """ use ExUnit.Case, async: true alias Trinity.N6.{CBOR, COSE, Profile} - alias Trinity.TestSupport.{MLDSA87Oracle, N6Vectors} + alias Trinity.TestSupport.{Ed25519Oracle, MLDSA87Oracle, N6Vectors} @typ "application/vnd.example.test.v1+cose" + @typ_ed "application/vnd.example.test-ed25519.v1+cose" setup_all do key = N6Vectors.acvp_key() - %{key: key, kid: COSE.thumbprint(key.public), table: N6Vectors.typ_table()} + ed = N6Vectors.rfc8032_key() + + IO.puts( + "cose_test: Ed25519 #{if Ed25519Oracle.verifies?(), do: "verifies", else: "is refused"} " <> + "on this runtime (fips #{:crypto.info_fips()})" + ) + + %{ + key: key, + kid: COSE.thumbprint(key.public), + ed: ed, + ed_kid: COSE.thumbprint(-19, ed.public), + table: N6Vectors.typ_table() + } end + defp mldsa(key), do: fn _ -> {:ok, %{alg: -50, public: key.public}} end + describe "AC6: the thumbprint" do test "kid for ACVP's published key is SHA-384 over exactly the RFC 9679 input", %{key: key} do pub = File.read!(Path.join(N6Vectors.root(), "thumbprint/acvp_mldsa87_tg3_tc51.pub")) @@ -33,31 +53,70 @@ defmodule Trinity.N6.COSETest do # N6 cannot build it (key -1 is outside its keys), which is why the prefix is fixed by hand. assert CBOR.encode(%{1 => 7, 3 => -50}) == {:ok, <<0xA2, 0x01, 0x07, 0x03, 0x38, 0x31>>} end + + test "slice 139, AC1: the OKP kid for RFC 8032's test key is SHA-384 over exactly its input", + %{ + ed: ed + } do + pub = File.read!(Path.join(N6Vectors.root(), "thumbprint/rfc8032_test1_ed25519.pub")) + kid = File.read!(Path.join(N6Vectors.root(), "thumbprint/rfc8032_test1_ed25519.kid")) + assert pub == ed.public + + # {1: 1, -1: 6, -2: x}: kty OKP, crv Ed25519 (RFC 9053 section 7.1), keys 0x01, 0x20, 0x21. + input = <<0xA3, 0x01, 0x01, 0x20, 0x06, 0x21, 0x58, 0x20>> <> pub + assert COSE.thumbprint(-19, pub) == kid + assert :crypto.hash(:sha384, input) == kid + end end describe "AC7: the header rules" do - test "every positive header decodes, and the builder makes exactly the plain one", %{ + test "every positive header decodes at commercial, and the builder makes exactly each", %{ kid: kid, + ed_kid: ed_kid, table: table } do - for path <- Path.wildcard(Path.join(N6Vectors.root(), "header/positive/*.cbor")) do - assert {:ok, %{kid: ^kid}} = COSE.decode_protected(File.read!(path), table), path + positives = Path.wildcard(Path.join(N6Vectors.root(), "header/positive/*.cbor")) + assert length(positives) == 3 + + for path <- positives do + assert {:ok, %{kid: k}} = COSE.decode_protected(File.read!(path), table, :commercial), + path + + assert k in [kid, ed_kid] end plain = File.read!(Path.join(N6Vectors.root(), "header/positive/alg_kid_typ.cbor")) assert COSE.protected_header(kid, @typ) == plain + + ed = File.read!(Path.join(N6Vectors.root(), "header/positive/ed25519_alg_kid_typ.cbor")) + assert COSE.protected_header(ed_kid, @typ_ed, nil, -19) == ed end - test "every negative header is refused with its code", %{table: table} do + test "every negative header is refused with its code, at either entry point", %{table: table} do vectors = N6Vectors.negative("header") - assert length(vectors) >= 17 + assert length(vectors) >= 19 + + for {defect, id, bytes, code} <- vectors, entry <- [:nss, :commercial] do + assert COSE.decode_protected(bytes, table, entry) == {:error, code}, + "#{defect}/#{id} at #{entry}" - for {defect, id, bytes, code} <- vectors do - assert COSE.decode_protected(bytes, table) == {:error, code}, "#{defect}/#{id}" assert Profile.exit_of(code) == 1 end end + test "slice 139, AC2: -19 decodes at commercial and is refused at nss, the default", %{ + ed_kid: ed_kid, + table: table + } do + ed = COSE.protected_header(ed_kid, @typ_ed, nil, -19) + + assert {:ok, %{alg: -19, kid: ^ed_kid, entry: %{alg: -19}}} = + COSE.decode_protected(ed, table, :commercial) + + assert COSE.decode_protected(ed, table, :nss) == {:error, :alg_not_permitted} + assert COSE.decode_protected(ed, table) == {:error, :alg_not_permitted} + end + test "the unprotected header must be empty", %{kid: kid} do p = COSE.protected_header(kid, @typ) sig = :binary.copy(<<0>>, 4627) @@ -130,7 +189,7 @@ defmodule Trinity.N6.COSETest do signature: :binary.copy(<<0>>, 4627) } - assert COSE.verify(parts, keys: fn _ -> {:ok, key.public} end, typ: table) == + assert COSE.verify(parts, keys: mldsa(key), typ: table) == {:error, 1, :kid_not_thumbprint} end @@ -141,7 +200,7 @@ defmodule Trinity.N6.COSETest do } do parts = %{protected: COSE.protected_header(kid, @typ), payload: "x", signature: <<0>>} - assert COSE.verify(parts, keys: fn _ -> {:ok, key.public} end, typ: table) == + assert COSE.verify(parts, keys: mldsa(key), typ: table) == {:error, 1, :carrier_malformed} end @@ -153,7 +212,7 @@ defmodule Trinity.N6.COSETest do signature: :binary.copy(<<0>>, 4627) } - answer = COSE.verify(parts, keys: fn _ -> {:ok, key.public} end, typ: table) + answer = COSE.verify(parts, keys: mldsa(key), typ: table) if MLDSA87Oracle.signs?() do assert answer == {:error, 1, :signature_invalid} @@ -165,4 +224,88 @@ defmodule Trinity.N6.COSETest do end end end + + describe "slice 139, AC3: Ed25519" do + test "a key registered under another algorithm than the header's is kid_not_thumbprint", %{ + key: key, + ed: ed, + ed_kid: ed_kid, + kid: kid, + table: table + } do + # An ML-DSA-87 key under a -19 header, and an Ed25519 key under a -50 header. + ed_parts = %{ + protected: COSE.protected_header(ed_kid, @typ_ed, nil, -19), + payload: "x", + signature: :binary.copy(<<0>>, 64) + } + + assert COSE.verify(ed_parts, keys: mldsa(key), typ: table, entry: :commercial) == + {:error, 1, :kid_not_thumbprint} + + ml_parts = %{ + protected: COSE.protected_header(kid, @typ), + payload: "x", + signature: :binary.copy(<<0>>, 4627) + } + + ed_key = fn _ -> {:ok, %{alg: -19, public: ed.public}} end + + assert COSE.verify(ml_parts, keys: ed_key, typ: table, entry: :commercial) == + {:error, 1, :kid_not_thumbprint} + end + + # Found by planting (PROOF, P5): with the sizes of -50 and -19 keys different, the size check + # refuses a key of the other algorithm on its own, so the registration's algorithm is only + # observable where the registration is wrong about its own key. + test "a registration that names another algorithm is refused, even for bytes that would verify", + %{ed: ed, ed_kid: ed_kid, table: table} do + sample = File.read!(Path.join(N6Vectors.root(), "cose/sign1/ed25519_single.bin")) + {:ok, parts} = Trinity.N6.Carrier.decode(sample) + + # The Ed25519 test key, registered as if it were an ML-DSA-87 key. + wrong = fn k -> if k == ed_kid, do: {:ok, %{alg: -50, public: ed.public}}, else: :error end + + assert COSE.verify(parts, keys: wrong, typ: table, entry: :commercial) == + {:error, 1, :kid_not_thumbprint} + end + + test "signs the sample's literal signature and verifies; one flipped byte is invalid; " <> + "where FIPS mode refuses Ed25519, both answer alg_unavailable", + %{ed: ed, ed_kid: ed_kid, table: table} do + sample = File.read!(Path.join(N6Vectors.root(), "cose/sign1/ed25519_single.bin")) + literal = binary_part(sample, byte_size(sample) - 64, 64) + payload = <<0xA2, 0x00, 0x01, 0x10, 0x69>> <> "slice 139" + keys = fn k -> if k == ed_kid, do: {:ok, %{alg: -19, public: ed.public}}, else: :error end + opts = [keys: keys, typ: table, entry: :commercial] + + if Ed25519Oracle.verifies?() do + assert {:ok, parts} = COSE.sign(payload, @typ_ed, ed) + assert parts.signature == literal + assert {:ok, %{exit: 0, verdict_code: :claim, typ: @typ_ed}} = COSE.verify(parts, opts) + + # The last protected byte is inside `typ`; a byte inside `kid` would be an unknown key (5). + for field <- [:protected, :payload, :signature], + at <- [0, byte_size(Map.fetch!(parts, field)) - 1] do + flipped = Map.update!(parts, field, &flip(&1, at)) + assert {:error, 1, _code} = COSE.verify(flipped, opts), "#{field} byte #{at}" + end + else + assert COSE.sign(payload, @typ_ed, ed) == {:error, :alg_unavailable} + + parts = %{ + protected: COSE.protected_header(ed_kid, @typ_ed, nil, -19), + payload: payload, + signature: literal + } + + assert COSE.verify(parts, opts) == {:error, 5, :alg_unavailable} + end + end + end + + defp flip(bin, at) do + <> = bin + a <> <> <> rest + end end diff --git a/test/trinity/receipts/foreign_algorithm_test.exs b/test/trinity/receipts/foreign_algorithm_test.exs new file mode 100644 index 0000000..62db616 --- /dev/null +++ b/test/trinity/receipts/foreign_algorithm_test.exs @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.ForeignAlgorithmTest do + @moduledoc """ + Slice 139, AC6 and AC7: a chain signed under an algorithm this runtime refuses gets an answer, + not an exception. The chains are `test/fixtures/receipts/`, signed under published test keys + (`scripts/receipt_foreign_chains.exs`): Ed25519, which FIPS mode refuses, and ML-DSA-87, which + FIPS mode on Red Hat's provider and every OpenSSL before 3.5 refuse. + + Before slice 139, `Verifier.verify/2` and a writer rehydrating over such a checkpoint raised + (slice 137's follow-up, seen on the `fips` job). Where the runtime has the algorithm, the same + chains verify, so each leg of CI takes one branch of each test and PROOF says which. + """ + use Trinity.DataCase, async: false + + alias Trinity.Receipts + alias Trinity.Receipts.{ChainWriter, Checkpoint, KeyCustody, KeyRegistry, Receipt, Verifier} + alias Trinity.Repo.Receipts, as: RRepo + alias Trinity.TestSupport.{Ed25519Oracle, MLDSA87Oracle} + + @fixtures "test/fixtures/receipts" + @script Path.expand("../../../bin/verify_receipt.exs", __DIR__) + + defp runtime_has?("ed25519"), do: Ed25519Oracle.verifies?() + defp runtime_has?("mldsa87"), do: MLDSA87Oracle.signs?() + + defp fixture(name), do: Path.join(@fixtures, name) |> File.read!() |> JSON.decode!() + + setup_all do + branches = + for alg <- ~w(ed25519 mldsa87), + do: "#{alg} #{if runtime_has?(alg), do: "verifies", else: "is refused"}" + + IO.puts( + "foreign_algorithm_test: on this runtime (fips #{:crypto.info_fips()}) " <> + Enum.join(branches, ", ") + ) + + :ok + end + + describe "AC6: the verifier answers" do + for alg <- ~w(ed25519 mldsa87), + {chain, at} <- [{"signed", 1}, {"checkpoint", {:checkpoint, 1}}] do + @alg alg + @chain chain + @at at + test "#{alg}, #{chain}: verified where the runtime has it, else no trust naming the row" do + export = fixture("#{@alg}_#{@chain}.json") + answer = Verifier.verify(export) + + if runtime_has?(@alg) do + assert {:ok, _} = answer + else + assert answer == {:error, 5, {:alg_unavailable, @at, @alg}} + end + end + end + + # `:crypto` raises the same shapes for a key it cannot use as for an algorithm it refuses, so a + # catch on the shape alone would call a malformed registry key a refused algorithm. + test "a registered key the runtime cannot use is named as the key, where it has the algorithm" do + export = short_key(fixture("ed25519_signed.json")) + [%{"key_id" => key_id}] = export["registry"] + + if runtime_has?("ed25519") do + assert Verifier.verify(export) == {:error, 5, {:key_undecodable, key_id}} + else + assert Verifier.verify(export) == {:error, 5, {:alg_unavailable, 1, "ed25519"}} + end + end + + @tag :tmp_dir + test "bin/verify_receipt.exs gives the same exit on every chain", %{tmp_dir: dir} do + script = Path.join(dir, "verify_receipt.exs") + File.cp!(@script, script) + + chains = + for name <- + File.ls!(@fixtures) |> Enum.filter(&String.ends_with?(&1, ".json")) |> Enum.sort(), + do: {name, fixture(name)} + + for {name, export} <- [{"short_key", short_key(fixture("ed25519_signed.json"))} | chains] do + file = Path.join(dir, name <> ".export") + File.write!(file, JSON.encode!(export)) + {out, code} = System.cmd("elixir", [script, file], cd: dir, stderr_to_stdout: true) + + assert code == Verifier.exit_code(Verifier.verify(export)), "#{name}:\n#{out}" + end + end + end + + # The chain with its registered Ed25519 key cut to 31 bytes. + defp short_key(export) do + update_in(export, ["registry", Access.at(0), "public_key_b64"], fn b64 -> + b64 |> Base.decode64!() |> binary_part(0, 31) |> Base.encode64() + end) + end + + describe "AC7: a writer over a checkpoint it cannot verify" do + setup do + %{keys_dir: dir} = KeyCustody.selected() + path = KeyRegistry.path(dir) + before = File.read!(path) + on_exit(fn -> File.write!(path, before) end) + %{keys_dir: dir} + end + + for alg <- ~w(ed25519 mldsa87) do + @alg alg + test "#{alg}: starts where the runtime has it, else stops with a named reason", %{ + keys_dir: dir + } do + export = fixture("#{@alg}_checkpoint.json") + [row] = export["registry"] + {:ok, _} = KeyRegistry.append(dir, row) + + scope = "foreign:" <> @alg <> ":" <> Trinity.UUID.generate() + [r] = export["receipts"] + [cp] = export["checkpoints"] + + RRepo.insert!(%Receipt{ + chain_scope: scope, + seq: r["seq"], + prev_hash: r["prev_hash"], + receipt_hash: r["receipt_hash"], + scheme: r["scheme"], + kind: r["kind"], + signed_payload: r["signed_payload"], + key_id: r["key_id"], + inserted_at: DateTime.utc_now() + }) + + RRepo.insert!(%Checkpoint{ + chain_scope: scope, + first_seq: cp["first_seq"], + last_seq: cp["last_seq"], + tail_hash: cp["tail_hash"], + scheme: cp["scheme"], + signed_payload: cp["signed_payload"], + signature: Base.decode64!(cp["signature_b64"]), + key_id: cp["key_id"], + reason: cp["reason"], + inserted_at: DateTime.utc_now() + }) + + Process.flag(:trap_exit, true) + on_exit(fn -> Receipts.stop_writer(scope) end) + + if runtime_has?(@alg) do + assert {:ok, pid} = ChainWriter.start_link(scope) + GenServer.stop(pid) + else + assert ChainWriter.start_link(scope) == + {:error, {:chain_inconsistent, scope, {:checkpoint_alg_unavailable, 1, @alg}}} + end + end + end + end +end From 3a31c6ad63f3e1aec43862325c4dc77d0a846176 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 08:41:52 -0400 Subject: [PATCH 2/2] fix(s139): the N6 negative vectors read their codes with to_atom, so the body test does not depend on load order Signed-off-by: Ayla Croft --- test/support/n6_vectors.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/support/n6_vectors.ex b/test/support/n6_vectors.ex index 3607440..32888a2 100644 --- a/test/support/n6_vectors.ex +++ b/test/support/n6_vectors.ex @@ -31,7 +31,9 @@ defmodule Trinity.TestSupport.N6Vectors do id = Path.basename(expect_path, ".expect") bytes = File.read!(Path.rootname(expect_path) <> ".cbor") %{"exit" => 1, "verdict_code" => code} = expect_path |> File.read!() |> JSON.decode!() - {defect, id, bytes, String.to_existing_atom(code)} + # `to_atom`, as in `samples/0`: under seed 789020 the body test asked for `:key_not_uint` + # before any module defining it was loaded (postgres job, PR #135). + {defect, id, bytes, String.to_atom(code)} end end