diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index ea2c8b2..b5ac906 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -417,4 +417,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 test/trinity/receipts/foreign_algorithm_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 test/trinity/receipts/v4_test.exs test/trinity/n6/esp384_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d2d1f..984b5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,25 @@ the runtime's `OTP_VERSION` file; a real boot on OTP 28.5.0.7, refused as headle as desktop; a runtime with 28.5.0.8's erts and an older ssl refused, naming ssl; and the decoder probe catching the defect on OTP 28.1.1 and 28.5.0.3. +### `slice/140`: receipts are signed statements in one byte form + +New receipts and checkpoints are N6 statements: deterministic CBOR inside COSE_Sign1, in the +single-signer carrier, signed with ML-DSA-87, Ed25519, or, on a node in FIPS mode, ECDSA P-384 as +COSE's ESP384. Each row is linked to the one before it by a SHA-384 identity that is the same +whether or not the row is signed, and a receipt's subject and decision are referenced by digest over +the exact bytes stored beside it, so a verifier reads what was signed and never a re-encoded copy. +Receipts written before stay as they were signed and still verify, and a chain that began earlier +continues into the new form. The verifier inside Trinity and the standalone script give the same +answer on every chain, the script loading the same N6 code from Trinity's sources when it meets a +new row. A `typ` no longer names an algorithm: the signing key's registration does, as the +profile's publisher has since decided. + +Verified by the test suite: ESP384 signing and verifying in the gate and in FIPS mode, its key id +checked against `sha384sum`, and its malformed signatures and keys refused; every receipt kind +built and read back; a chain crossing from the earlier form, and every tampering with a row, a link +or a checkpoint refused; attachments missing or altered reported without changing the verdict; and +the two verifiers agreeing on each case. + ## 2026-10-08 ### `slice/136`: the web pages ask who is there diff --git a/README.md b/README.md index 4cfa6b9..163da70 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,9 @@ enforces it. model's window. - **Acts.** Filesystem, web and shell tools behind a permission gate with fingerprint-bound approvals; every effect passes one membrane and leaves a signed, hash-chained receipt you can - verify offline (`mix trinity.receipts.verify`). A FIPS build leg in CI proves the receipt scheme - under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any + verify offline (`mix trinity.receipts.verify`). Since slice 140 a receipt is deterministic CBOR in + COSE_Sign1, signed with ML-DSA-87, Ed25519 or, in FIPS mode, ECDSA P-384, and older receipts still + verify. A FIPS build leg in CI proves the receipt scheme under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any path: the secrets live in their own directory outside the data directory, a filesystem root that overlaps either refuses the boot, and every path is checked for links, hard links, `/proc` and protected inodes before the file is opened and again on the open file (slice 135). @@ -379,8 +380,9 @@ two: a *Signed Statement* is issued by whoever made the statement, and a *Receip Transparency Service and must carry proof of inclusion in a verifiable log. Trinity runs no transparency service, so in that vocabulary Trinity issues Signed Statements and never Receipts. The house name is not changing, because "receipt" is the right word for what it is to the person who owns -the machine, but nobody should infer SCITT conformance from it. The field by field mapping, including -the three fields that have no equivalent, is in +the machine, but nobody should infer SCITT conformance from it. Since slice 140 its envelope is +COSE_Sign1, the one SCITT uses, still without the CWT issuer and subject claims a SCITT statement +carries. The field by field mapping, including the fields that have no equivalent, is in [`docs/receipt-scheme-mapping.md`](docs/receipt-scheme-mapping.md). ## Connecting Trinity to the platform diff --git a/bin/verify_receipt.exs b/bin/verify_receipt.exs index fabe9bf..21ede0d 100755 --- a/bin/verify_receipt.exs +++ b/bin/verify_receipt.exs @@ -7,6 +7,7 @@ # wrote; the registry travels inside the file. # # elixir verify_receipt.exs receipts.json [--schemes receipt_v3_ed25519,receipt_v2_ed25519] +# [--n6 ] # # 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, or a signature this runtime @@ -21,6 +22,15 @@ # query receipt is covered by a checkpoint whose tail is in the chain and whose signature # verifies the same way. test/trinity/receipts/standalone_verifier_test.exs asserts this # file and the in-app verifier agree on the same inputs. +# +# Slice 140: a v4 row or checkpoint is an N6 statement, and checking one needs a strict N6 decoder, +# far more than this file should hold. So for those this script loads, from source, the N6 files +# and Trinity.Receipts.V4, which depend on nothing but each other and :crypto, and runs the same +# rules the in-app verifier runs (V4.verify_row/3, V4.verify_checkpoint/3) after the same registry +# resolution as above. They are read from ../lib beside this file in a checkout, or from the +# directory --n6 names. A chain with no v4 row needs neither, and the script loads nothing. A +# finding about a v4 row's stored attachments (its subject or decision bytes missing or not what its +# signed body names) is printed after the verdict and does not change it. defmodule VerifyReceipt do # Every scheme version this script can read. `v2` is slice 024's; `v3` is slice 026's and carries @@ -33,12 +43,25 @@ defmodule VerifyReceipt do "receipt_v2_mldsa87" => {"mldsa87", :mldsa87, :none, nil}, "receipt_v3_ed25519" => {"ed25519", :eddsa, :none, :ed25519}, "receipt_v3_p384" => {"p384", :ecdsa, :sha384, :secp384r1}, - "receipt_v3_mldsa87" => {"mldsa87", :mldsa87, :none, nil} + "receipt_v3_mldsa87" => {"mldsa87", :mldsa87, :none, nil}, + "receipt_v4_ed25519" => {"ed25519", :eddsa, :none, :ed25519}, + "receipt_v4_p384" => {"p384", :ecdsa, :sha384, :secp384r1}, + "receipt_v4_mldsa87" => {"mldsa87", :mldsa87, :none, nil} } @signed_kinds ~w(decision effect boot cap) + # The sources a v4 row needs, relative to Trinity's lib directory, in the order they are loaded. + @n6_sources ~w(n6/profile.ex n6/cbor.ex n6/digest.ex n6/body.ex n6/ec2.ex n6/cose.ex n6/carrier.ex receipts/v4.ex) + + # Named at run time, so compiling this file before the sources are loaded warns about nothing. + defp v4, do: Module.concat(["Trinity", "Receipts", "V4"]) + def main(argv) do - {opts, args, _} = OptionParser.parse(argv, strict: [schemes: :string]) + # OTP 28.5.0.8 warns on stdout when :crypto loads before its application ("Module 'crypto' + # loaded without application 'crypto' being loaded"), which a plain `elixir` run does; loading + # the application first keeps the verdict the only line (slice 140, measured on that runtime). + _ = Application.load(:crypto) + {opts, args, _} = OptionParser.parse(argv, strict: [schemes: :string, n6: :string]) case args do [path] -> @@ -49,31 +72,53 @@ defmodule VerifyReceipt do end case File.read(path) do - {:ok, bin} -> run(JSON.decode(bin), schemes) + {:ok, bin} -> bin |> JSON.decode() |> load_n6(opts[:n6]) |> run(schemes) {:error, reason} -> usage("cannot read #{path}: #{inspect(reason)}") end _ -> - usage("usage: elixir verify_receipt.exs [--schemes a,b]") + usage("usage: elixir verify_receipt.exs [--schemes a,b] [--n6 ]") + end + end + + defp load_n6({:ok, %{"receipts" => rs, "checkpoints" => cps}} = export, dir) do + if Enum.any?(rs ++ cps, &n6?/1) do + root = dir || Path.expand("../lib", __DIR__) + files = Enum.map(@n6_sources, &Path.join([root, "trinity", &1])) + + case Enum.reject(files, &File.regular?/1) do + [] -> Enum.each(files, &Code.require_file/1) + _ -> usage("v4 rows need Trinity's N6 sources: --n6 (#{root} has none)") + end end + + export end + defp load_n6(other, _dir), do: other + + defp n6?(%{"scheme" => s}) when is_binary(s), do: String.starts_with?(s, "receipt_v4_") + defp n6?(_), do: false + defp usage(msg) do IO.puts(:stderr, msg) System.halt(2) end - defp run({:ok, %{"receipts" => rs, "checkpoints" => cps, "registry" => reg}}, schemes) do + defp run({:ok, %{"receipts" => rs, "checkpoints" => cps, "registry" => reg} = export}, schemes) do + n6 = [registry: reg, entry: :commercial, issuer: export["issuer"]] + result = - with :ok <- walk(Enum.sort_by(rs, & &1["seq"]), reg, schemes, 0, nil), - :ok <- checkpoints(cps, Map.new(rs, &{&1["seq"], &1}), reg, schemes), + with {:ok, findings} <- walk(Enum.sort_by(rs, & &1["seq"]), reg, schemes, 0, nil, n6, []), + :ok <- checkpoints(cps, Map.new(rs, &{&1["seq"], &1}), reg, schemes, n6), :ok <- coverage(rs, cps) do - {:ok, length(rs), length(cps)} + {:ok, length(rs), length(cps), findings} end case result do - {:ok, n, c} -> + {:ok, n, c, findings} -> IO.puts("verified: #{n} receipts, #{c} checkpoints") + Enum.each(findings, &IO.puts("finding: #{inspect(&1)}")) System.halt(0) {:error, code, reason} -> @@ -88,22 +133,45 @@ defmodule VerifyReceipt do defp label(5), do: "trust not established" defp label(6), do: "compromised key" - defp walk([], _reg, _schemes, _seq, _hash), do: :ok + defp walk([], _reg, _schemes, _seq, _prev, _n6, findings), do: {:ok, findings} - defp walk([r | rest], reg, schemes, prev_seq, prev_hash) do + # A v4 row: the registry resolution here, then V4's rules, then the next row. + defp walk([%{"scheme" => "receipt_v4_" <> _} = r | rest], reg, schemes, prev_seq, prev, n6, findings) do seq = r["seq"] with :ok <- expect(seq == prev_seq + 1, 1, {:seq_gap, prev_seq, seq}), - :ok <- expect(r["prev_hash"] == prev_hash, 1, {:prev_hash_mismatch, seq}), + {:ok, _fam, _row} <- resolve(r["scheme"], r["key_id"], reg, schemes, seq), + {:ok, info} <- v4_row(r, prev, n6) do + walk(rest, reg, schemes, seq, r, n6, findings ++ info.findings) + end + end + + defp walk([r | rest], reg, schemes, prev_seq, prev, n6, findings) do + seq = r["seq"] + + with :ok <- expect(seq == prev_seq + 1, 1, {:seq_gap, prev_seq, seq}), + :ok <- expect(r["prev_hash"] == (prev && prev["receipt_hash"]), 1, {:prev_hash_mismatch, seq}), {:ok, fam, row} <- resolve(r["scheme"], r["key_id"], reg, schemes, seq), bytes = pae("trinity/receipt/" <> r["scheme"], r["signed_payload"]), :ok <- expect(hash(bytes) == r["receipt_hash"], 1, {:hash_mismatch, seq}), :ok <- body_matches(r, seq), :ok <- signature(r, bytes, fam, row, seq) do - walk(rest, reg, schemes, seq, r["receipt_hash"]) + walk(rest, reg, schemes, seq, r, n6, findings) + end + end + + defp v4_row(r, prev, n6) do + case apply(v4(), :verify_row, [r, prev && link(prev), n6]) do + {:error, 1, {:n6, seq, :signature_invalid}} -> {:error, 1, {:signature_invalid, seq}} + other -> other end end + # A row's link id, as the next v4 row names it: a v4 row's statement_id (its receipt_hash), or + # SHA-384 over a v2 or v3 row's PAE bytes. + defp link(%{"scheme" => "receipt_v4_" <> _, "receipt_hash" => h}), do: Base.decode16!(h, case: :lower) + defp link(r), do: :crypto.hash(:sha384, pae("trinity/receipt/" <> r["scheme"], r["signed_payload"])) + defp signature(r, bytes, fam, row, seq) do if r["kind"] in @signed_kinds do with {:ok, sig} <- b64(r["signature_b64"], 1, {:signature_undecodable, seq}), @@ -140,26 +208,43 @@ defmodule VerifyReceipt do end end - defp checkpoints(cps, by_seq, reg, schemes) do + defp checkpoints(cps, by_seq, reg, schemes, n6) do Enum.reduce_while(cps, :ok, fn cp, :ok -> - last = cp["last_seq"] - - r = - with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}}, - :ok <- expect(tail["receipt_hash"] == cp["tail_hash"], 1, {:checkpoint_tail_mismatch, last}), - :ok <- expect(is_integer(cp["first_seq"]) and cp["first_seq"] <= last, 1, {:checkpoint_range, last}), - {:ok, fam, row} <- resolve(cp["scheme"], cp["key_id"], reg, schemes, {:checkpoint, last}), - {:ok, body} <- JSON.decode(cp["signed_payload"]) |> or_error({:error, 1, {:checkpoint_body_not_json, last}}), - :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 - check(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub, {:checkpoint, last}, cp["key_id"], {:checkpoint_signature_invalid, last}) - end - + r = checkpoint(cp, by_seq, reg, schemes, n6) if r == :ok, do: {:cont, :ok}, else: {:halt, r} end) end + defp checkpoint(%{"body_b64" => _} = cp, by_seq, reg, schemes, n6) do + last = cp["last_seq"] + + with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}}, + :ok <- expect(is_integer(cp["first_seq"]) and cp["first_seq"] <= last, 1, {:checkpoint_range, last}), + {:ok, _fam, _row} <- resolve(cp["scheme"], cp["key_id"], reg, schemes, {:checkpoint, last}) do + case apply(v4(), :verify_checkpoint, [cp, link(tail), n6]) do + :ok -> :ok + {:error, 1, {:checkpoint_body_mismatch, _}} -> {:error, 1, {:checkpoint_tail_mismatch, last}} + {:error, 1, {:n6, _at, :signature_invalid}} -> {:error, 1, {:checkpoint_signature_invalid, last}} + error -> error + end + end + end + + defp checkpoint(cp, by_seq, reg, schemes, _n6) do + last = cp["last_seq"] + + with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}}, + :ok <- expect(tail["receipt_hash"] == cp["tail_hash"], 1, {:checkpoint_tail_mismatch, last}), + :ok <- expect(is_integer(cp["first_seq"]) and cp["first_seq"] <= last, 1, {:checkpoint_range, last}), + {:ok, fam, row} <- resolve(cp["scheme"], cp["key_id"], reg, schemes, {:checkpoint, last}), + {:ok, body} <- JSON.decode(cp["signed_payload"]) |> or_error({:error, 1, {:checkpoint_body_not_json, last}}), + :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 + check(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub, {:checkpoint, last}, cp["key_id"], {:checkpoint_signature_invalid, last}) + end + end + defp coverage(rs, cps) do covered = cps |> Enum.flat_map(fn cp -> Enum.to_list(cp["first_seq"]..cp["last_seq"]//1) end) |> MapSet.new() uncovered = rs |> Enum.filter(&(&1["kind"] == "query" and not MapSet.member?(covered, &1["seq"]))) |> Enum.map(& &1["seq"]) diff --git a/docs/05-data-model.md b/docs/05-data-model.md index fa5f5ea..ce71a49 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -259,6 +259,15 @@ keys are `scheme, seq, chain_scope, prev_hash, kind, subject, decision, fingerpr `canonicalization_version`, `authority`, `tool_definition_digest`), `inserted_at`. Unique `(chain_scope, seq)` and `receipt_hash`. Chain scopes: `session:` and `boot`. +As built at slice 140 (receipts v4, `Trinity.Receipts.V4`): new rows are N6 statements, deterministic CBOR in +the single-signer COSE_Sign1 carrier, under `receipt_v4_`. Four nullable binary columns are added and v2 +and v3 rows are never rewritten: `body` (the N6 body: `issuer`, `prev`, `seq`, `chain_scope`, `at` in microseconds, +`clock`, and `subject` and `decision` as references by SHA-384 digest, length and media type), `carrier` (for a +signed kind), `subject_bytes` and `decision_bytes` (the exact RFC 8785 bytes the references name, verified as +stored). A v4 row's `receipt_hash` is its `statement_id` (SHA-384, hex), its `prev_hash` its predecessor's, or for +the first v4 row after a v3 one, SHA-384 over that row's PAE bytes; `signed_payload` is the empty string, since the +column is NOT NULL. `subject` (map) is kept for the receipts page and is never what a verifier reads. + ### receipt_checkpoints (Slice 024) | column | type | notes | |---|---|---| @@ -268,6 +277,8 @@ and `receipt_hash`. Chain scopes: `session:` and `boot`. | scheme, signed_payload, signature, key_id | | signed like a receipt, over the PAE of `trinity/checkpoint/` and the canonical body | | reason | string | "count" \| "time" \| "shutdown" \| "rehydrate" \| "manual" | A row, never a write onto a receipt: `receipts` stays append-only and a checkpoint states its own coverage. +Since slice 140 a checkpoint is a v4 statement too: `body` and `carrier` (nullable binary columns), `tail_hash` the +tail's link id, `signed_payload` empty, and `signature` the carrier's signature. ### mcp_servers (Slice 060, as built) One row per server the client connects to; the row's `name` is the namespace segment of every tool it contributes. diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 100720b..d84b401 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -522,6 +522,9 @@ where the runtime carries it; the boot receipt names the choice and the authorit hash in unsigned metadata (the R21 default). `bin/verify_receipt.exs` verifies an export with `elixir` alone, from an empty directory, with the exit vocabulary 0, 1, 2, 5, 6. +That is the envelope of `v2` and `v3` rows. Since slice 140 new rows are N6 statements in COSE_Sign1, and the +key's N6 thumbprint replaces `key_id` inside the signed bytes; see the slice 140 section below. + ## The runtime floor, and ML-DSA-87 by doing it (Slice 137, as built) Before anything else starts, and in every profile, `Trinity.Application` refuses a runtime below @@ -629,6 +632,40 @@ Trinity signs uses the layer. 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. +### Receipts are N6 statements (Slice 140, as built) + +The publisher answered Trinity's four remaining questions on 2026-10-20, and Trinity's receipts now +sign through the layer. New rows and checkpoints are written under `receipt_v4_` +(`Trinity.Receipts.V4`); rows already signed under `v2` and `v3` are never rewritten and still verify. + +- **The kind is the `typ`, the algorithm is the key's.** Each kind has one `typ` and one schema + number, the body's key 0, in Trinity's provisional block 0x83; a body under another kind's `typ` + is refused (`typ_schema_mismatch`). The `typ` no longer names an algorithm, as slice 139 had it: + the key's registration does, and a header naming another algorithm than the key's is refused + (`alg_not_permitted`). Every signed body names its issuer (key 1), or it is refused + (`issuer_missing`). +- **FIPS mode signs with ESP384.** ECDSA on P-384 with SHA-384 (-51, RFC 9864) is admitted on the + `commercial` profile for a node in FIPS mode, where the provider refuses Ed25519 and ML-DSA-87. + The signature is the 96-byte `r || s` of RFC 9053 section 2.1 and nothing else + (`signature_encoding`); `s` and `n - s` both verify, so no verifier splits from another on + low-s. The key must be an uncompressed P-384 point on the curve (`key_invalid`), and its `kid` is + the RFC 9679 thumbprint over exactly 107 bytes, each coordinate padded to 48. It is never admitted + at the `nss` entry point; `config :trinity, :receipts, profile: :nss` with any signer but + ML-DSA-87 is refused at selection, which a regulated boot turns into a refusal to start. +- **What is signed, and what is referenced.** The body signs what a verifier judges: the chain + position, the scope, the predecessor, the time, the clock. `subject` and `decision` are referenced + by SHA-384 over their RFC 8785 bytes, which are stored beside the row and hashed as stored, never + re-encoded from a decoded map. Both are maps Trinity builds; no receipt carries a tool call's + arguments or its result, which enter as the approval fingerprint and a digest. A missing or altered + attachment is a finding beside the verdict, never a change to it, because the signature does not + cover those bytes. +- **One link, signed or not.** A row's link is its `statement_id`, SHA-384 over its kind's `typ` + and its body, so a query row has the same identity before and after a checkpoint covers it. The + first v4 row of an older chain links to SHA-384 over its v3 predecessor's signed bytes. +- **The stranger's verifier reads it too.** `bin/verify_receipt.exs` loads the N6 sources and + `Trinity.Receipts.V4` from Trinity's `lib/` (`--n6` names it) and runs the same rules as the + in-app verifier, which a test holds it to; a chain with no v4 row still needs nothing but OTP. + ## 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 67f1cd7..eecdabb 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -23,6 +23,7 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status | 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 | +| Receipts as N6 statements (receipts v4): ESP384 for FIPS-mode nodes (RFC 9864's -51; the `r \|\| s` of RFC 9053 section 2.1; key id RFC 9679 over RFC 9053's EC2 members), `statement_id` as the link, content Trinity originates referenced by digest over its RFC 8785 bytes | `Trinity.Receipts.V4`, `Trinity.N6.EC2`, slice 140, to the profile publisher's Decision of 2026-10-20 | `test/trinity/n6/esp384_test.exs`, `test/trinity/receipts/`; `test/fixtures/n6/cose/sign1/esp384_*` | `not claimed`: the profile is provisional and has no published vectors; schema numbers and `typ` values are provisional and re-issued at its v1; the `fips` job proves ESP384 signs and verifies in the mode on its provider, which is not a claim about any validation | 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/authority-adapter.md b/docs/authority-adapter.md index 41540b7..8b99f12 100644 --- a/docs/authority-adapter.md +++ b/docs/authority-adapter.md @@ -122,6 +122,13 @@ An implementation that does not export it is one this machine never forwards to, its scopes simply does not drain. See `lib/trinity/receipts/queue.ex` and `test/trinity/receipts/queue_test.exs`. +**Since slice 140 an envelope may be a v4 row.** Its `scheme` is `receipt_v4_`, its signed +bytes are `body_b64` (an N6 statement body) inside `carrier_b64` (the single-signer COSE_Sign1 +carrier), and `subject_b64` and `decision_b64` carry the bytes the body references by digest. +`signed_payload` is empty and `signature_b64` absent. A far side that verifies envelopes offline +needs the N6 rules for these, as `bin/verify_receipt.exs` does (`docs/regulated/receipts-verify.md`); +envelopes of rows written earlier keep their `v2` or `v3` form. + ## `act_hash` is opaque An authority layer may bind its decision to an act identifier. **Trinity treats it as opaque.** It diff --git a/docs/receipt-scheme-mapping.md b/docs/receipt-scheme-mapping.md index 7646d8c..5caabf0 100644 --- a/docs/receipt-scheme-mapping.md +++ b/docs/receipt-scheme-mapping.md @@ -24,27 +24,29 @@ emits is a SCITT Receipt, and this project makes no such claim. The name predate tree and is not being changed, because "receipt" is the right word for what it is to the person who owns the machine; but a reader who knows SCITT should not have to work this out for themselves. -## Two scheme versions, since slice 026 +## Three scheme versions `receipt_v2_` is slice 024's and carries ten keys. `receipt_v3_` is slice 026's and carries eleven: the ten plus `clock`, a hybrid logical clock for relating two chains that ran during -a partition. New rows are written under `v3`; `v2` rows are still verified under `v2`, row by row, -under each row's own scheme. +a partition. `receipt_v4_` is slice 140's: each row is an N6 statement, deterministic CBOR in +a COSE_Sign1, described in its own section below. New rows are written under `v4`; `v2` and `v3` +rows are still verified under their own scheme, row by row. -That is a scheme bump rather than an edit: no byte signed under `v2` changed, and +Each is a scheme bump rather than an edit: no byte signed under `v2` or `v3` changed, and `Trinity.Receipts.Signer.accepted_schemes/0` is what the verifier allows by default, so bumping the -version did not make previously signed rows unverifiable. The field list below is `v3`. +version did not make previously signed rows unverifiable. The first `v4` row of a chain that began +earlier names its `v3` predecessor by SHA-384 over that row's signed bytes. -## The envelope Trinity signs with today +## The envelope of v2 and v3 rows The signature is not over the canonical JSON directly. It is over DSSE's pre-authentication encoding of a payload type and that JSON, where the payload type is `trinity/receipt/` (`Trinity.Receipts.Envelope.receipt_type/1`). So the envelope is already a published construct, just a different one from SCITT's: DSSE rather than COSE. That matters for the last section. -## The field mapping +## The field mapping of v2 and v3 rows -Trinity's signed payload is RFC 8785 canonical JSON of exactly these eleven keys +A `v3` row's signed payload is RFC 8785 canonical JSON of exactly these eleven keys (`Trinity.Receipts.ChainWriter`, the `body` map): `scheme`, `seq`, `chain_scope`, `prev_hash`, `kind`, `subject`, `decision`, `fingerprint`, `at`, @@ -67,6 +69,52 @@ not covered by the signature and are not mapped below. | `clock` | **no equivalent** | A hybrid logical clock (slice 026): `wall`, `counter`, `node`. SCITT has no notion of one because a transparency service supplies the ordering its receipts need. Trinity has no such service, so ordering across a partition has to travel in the statement, and it is signed for the reason the mapping document gives below: a merge that orders two chains by an unsigned field orders them by something any writer could rewrite. | | `seq`, `chain_scope`, `prev_hash` | **no equivalent** | These are the hash chain. In SCITT the equivalent guarantee comes from a Transparency Service's Verifiable Data Structure and is carried in a Receipt's proofs, not in the statement. Trinity's chain is the whole of its tamper-evidence, and it is local. | +## Receipts v4: N6 statements in COSE_Sign1 + +Since slice 140 a row is an N6 statement (the N6 profile, v0 draft r3 with its publisher's answers, +provisional): a body of deterministic CBOR with integer keys, signed, for decision, effect, boot and +cap rows, as a COSE_Sign1 (RFC 9052) in the single-signer carrier. Query rows are not signed one by +one and are covered by checkpoints, as before; a checkpoint is a statement of its own kind +(`Trinity.Receipts.V4`). + +What moved out of the body, and where to: + +- **The algorithm and the key** are COSE's: `alg` (label 1) and `kid` (label 4) in the protected + header. `alg` is -50 (ML-DSA-87), -19 (Ed25519) or, in FIPS mode, -51 (ESP384); `kid` is the + RFC 9679 thumbprint of the key, SHA-384. The key's registration names its algorithm, and a header + that names another is refused. +- **The kind** is COSE's `typ` (label 16, RFC 9596), one media type per kind: + `application/vnd.example.b83.receipt-.v0+cose`, provisional, with the body's key 0 the + kind's schema number. A body under another kind's `typ` is refused. +- **`subject` and `decision`** are not in the signed body. Each is a reference in it: the SHA-384 of + the map's RFC 8785 bytes, their length and media type (`application/json`). The bytes are stored + beside the row, and a verifier hashes them as stored. A missing or altered attachment is reported + as a finding and never changes the verdict on the signed statement, because the signature does + not cover those bytes. No receipt carries a tool call's arguments or its result: the arguments + enter as the approval fingerprint, the result as a digest. +- **The link** is the predecessor's `statement_id`, SHA-384 over its kind's `typ` and its body, the + same whether or not the row is signed. A row's `receipt_hash` column is its own. + +The body of a row, by key: + +| Key | Name | Note | +|---|---|---| +| 0 | `schema` | The kind's schema number, `0x83xxxx01`, provisional. | +| 1 | `issuer` | This installation, `trinity:` and its device id. Corresponds to CWT `iss` (claim 1), which a v2 or v3 row lacked. | +| 7 | `prev` | The predecessor's link, absent at seq 1. | +| 16 | `seq` | The chain position. | +| 17 | `chain_scope` | The chain. | +| 18 | `at` | Microseconds since the Unix epoch, UTC. Corresponds to CWT `iat`, in another unit. | +| 19 | `clock` | The hybrid logical clock: wall, counter, node. | +| 20 | `subject` | The reference to the subject's bytes. | +| 21 | `decision` | The reference to the decision's bytes, absent when the row has none. | +| 22 | `fingerprint` | The approval fingerprint, when the row has one. | + +The CWT claims SCITT asks for in the protected header (label 15, `iss` and `sub`) are not there: +no receipt `typ` is SCITT-renderable in this version, and `sub` would need a single string where a +Trinity subject is a map. A v4 receipt is therefore a COSE_Sign1 Signed Statement in shape, and still +not a SCITT one. + ## Why the field names are not being renamed The obvious next step looks like renaming Trinity's fields to their COSE and CWT equivalents so a @@ -79,12 +127,11 @@ None of that exists here. That is a claim made by appearance rather than by a row in `docs/09-standards-register.md`, which is the specific failure this project's standards register exists to prevent. -**What would be real, rather than cosmetic**, is emitting the statement as a `COSE_Sign1` object with -CWT claims `iss` and `sub` in its protected header, in place of the DSSE pre-authentication encoding -described above. That makes a Trinity statement genuinely readable by a SCITT-aware verifier rather -than merely familiar-looking. It is a change of signing envelope, with a scheme bump and a -dual-reading verifier behind it, and it is a decision for the owner rather than a rename anybody can -do. +**What would be real, rather than cosmetic**, was emitting the statement as a `COSE_Sign1` object, +in place of the DSSE pre-authentication encoding described above, with a scheme bump and a +dual-reading verifier behind it. Slice 140 did that for the envelope (v4, above). CWT `iss` and `sub` +in the protected header, which would make a receipt readable by a SCITT-aware verifier, are still +absent, for the reasons given there. ## What this page lets a reader conclude @@ -93,6 +140,9 @@ do. - Every element of the signed payload maps to a published construct except four: `seq`, `prev_hash` and `chain_scope`, which are the ones SCITT delegates to a service Trinity does not run, and `clock`, which exists for the same reason, since ordering across a partition is what a - transparency service would otherwise provide. + transparency service would otherwise provide. That holds for v4's `seq`, `prev`, `chain_scope` + and `clock` too. +- From v4 a receipt's envelope is COSE_Sign1, and its algorithm, key and kind are COSE header + parameters; its `subject` and `decision` are referenced by digest rather than signed in place. - `subject_ref` and `meta` are unsigned row columns. Nothing should be inferred from them. - No conformance to RFC 9943 is claimed, and none should be inferred. diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md index ae0efdb..6a56fe7 100644 --- a/docs/regulated/control-mapping.md +++ b/docs/regulated/control-mapping.md @@ -81,7 +81,7 @@ register does not claim is never Trinity's. | `AU-4(1)` | Trinity emits telemetry and keeps its receipts locally; it forwards logs nowhere | `docs/telemetry.md` | none | the deployment's | Shipping logs and metrics to storage off the host | CRM "Shipping logs or metrics anywhere" | | `AU-5` | When the signer is unavailable an alarm is raised and every effect is denied until a signer returns; under `TRINITY_PROFILE=regulated` the node does not boot at all | `lib/trinity/receipts/alarm.ex`, `lib/trinity/profile.ex` | `test/trinity/effects/membrane_test.exs` "AC5: the signing key removed mid-run: the next effect is denied, the alarm sounds, no unsigned receipt row exists; a read is refused too, because its decision cannot be receipted"; `Trinity.Profile.check_receipts/2`; `test/trinity/regulated_boot_node_test.exs` "case 4's configuration with case 1's broken key store does not start" | shared | Watching for the alarm, and alerting someone | CRM "Raising an alarm when the signer is unavailable" | | `AU-8` | Each receipt carries a signed hybrid logical clock that never goes backwards within a chain; its reading is the host's own clock | `lib/trinity/receipts/clock.ex` | `test/trinity/receipts/clock_test.exs` "a wall clock that went BACKWARDS still produces a greater clock" | shared | A trusted time source, where the time itself must be authoritative | Register "Trusted time for receipts": real-world dependency | -| `AU-9(3)` | Receipts are hash-linked and signed before the row is written, and a verifier that does not need the application finds a gap, a wrong link, an altered byte or a forged signature | `lib/trinity/receipts/verifier.ex`, `bin/verify_receipt.exs` | `test/trinity/receipts/verifier_test.exs` "AC3: a chain with a gap, a wrong prev_hash, or a forged signature is 1"; `test/trinity/receipts/standalone_verifier_test.exs` "AC7: a stranger's run from an empty directory: verified is 0; the outcomes carry their codes" | Trinity's | none | CRM "Receipt signing key generation and use" | +| `AU-9(3)` | Receipts are hash-linked and signed before the row is written, and a verifier that does not need the application finds a gap, a wrong link, an altered byte or a forged signature. Since slice 140 a receipt's subject and decision are referenced by digest from its signed body, and bytes missing or altered beside it are reported by both verifiers | `lib/trinity/receipts/verifier.ex`, `lib/trinity/receipts/v4.ex`, `bin/verify_receipt.exs` | `test/trinity/receipts/verifier_test.exs` "AC3: a chain with a gap, a wrong prev_hash, or a forged signature is 1"; `test/trinity/receipts/standalone_verifier_test.exs` "AC7: a stranger's run from an empty directory: verified is 0; the outcomes carry their codes"; `test/trinity/receipts/standalone_verifier_test.exs` "the script and the in-app verifier agree on every outcome"; `test/trinity/receipts/standalone_verifier_test.exs` "AC8: a missing or altered attachment is a finding in both, and the verdict stands" | Trinity's | none | CRM "Receipt signing key generation and use" | | `AU-10` | Effect, decision, boot and cap receipts are signed one by one, and query receipts are covered by signed checkpoints. Standalone, one process decides, acts and signs, so a signature binds a record to a key, not to an independent party | `lib/trinity/receipts/chain_writer.ex`, `lib/trinity/receipts/key_custody.ex` | `test/trinity/receipts/chain_writer_test.exs` "rows chain: gapless seq, each prev_hash the previous receipt_hash, signed kinds verify, query rows unsigned"; `test/trinity/receipts/chain_writer_test.exs` "after N query receipts a checkpoint names the tail and its coverage; its signature verifies" | shared | An external authority, so that the party that decides is not the party that records, and custody of the signing key | CRM "Who decides whether an effect may happen"; Register "NIST SP 800-53 AU family: non-repudiation of every governed act (AU-10)": :unknown | | `AU-11` | The chain writer is the only code that creates or removes a receipt row | `lib/trinity/receipts/chain_writer.ex` | `test/trinity/receipts/chain_writer_test.exs` "the census: ChainWriter is the only inserter into receipts; the planted bypass is named" | shared | How long the receipts store is kept | CRM "Receipt chain retention" | | `AU-11` | There is no records schedule and no legal hold in the tree | `docs/regulated/customer-responsibility-matrix.md` | none | the deployment's | A records schedule, and legal hold | CRM "A records schedule, or legal hold" | @@ -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, 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-13` | Signed statements have one byte form: deterministic CBOR inside COSE_Sign1 with ML-DSA-87, or on the `commercial` profile only Ed25519 (slice 139) or, for a node in FIPS mode, ECDSA P-384 as ESP384 with its 96-byte signature (slice 140). Receipts and checkpoints are written in it since slice 140. The decoder refuses any input that is not exactly the deterministic encoding of an allowed value, naming the first defect; the header admits only `alg` -50 (and -19 or -51 at `commercial`), the key's RFC 9679 thumbprint as `kid`, and `typ`; the key's registration names its algorithm and a header naming another is refused; one `typ` per kind, and a body under another kind's is refused; a single-signer carrier refuses a `typ` that needs two signers and a body without an issuer; a verifier that cannot run the header's algorithm answers no trust, never a pass, and so does the receipt verifier for a row whose algorithm the runtime refuses. A census classifies every hashing and signing call in the tree and names the sites that still re-encode data from elsewhere before hashing it | `lib/trinity/n6/`, `lib/trinity/receipts/v4.ex`, `test/fixtures/n6/`, `scripts/n6_vectors.exs` | `test/trinity/n6/cbor_test.exs` "every negative vector is refused with its code"; `test/trinity/n6/cose_test.exs` "every negative header is refused with its code, at either entry point"; `test/trinity/n6/carrier_test.exs` "AC5: every sample gives its expected outcome under its context"; `test/trinity/n6/cose_test.exs` "kid for ACVP's published key is SHA-384 over exactly the RFC 9679 input"; `test/trinity/n6/cose_signing_test.exs` "one flipped byte in the protected header, the payload or the signature is invalid"; `test/trinity/n6/esp384_test.exs` "DER, or 95 or 97 bytes, is signature_encoding"; `test/trinity/n6/esp384_test.exs` "a compressed point, a P-256 key and a point off the curve are key_invalid"; `test/fips/receipts_test.exs` "no effect is denied for want of a signer: an effect runs, its receipts are P-384 and verify"; `test/trinity/n6/census_test.exs` "every hashing and signing call in lib/ is in the census, with its count and a class" | Trinity's | none | none | | `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 29f753b..7e4c391 100644 --- a/docs/regulated/crypto-inventory.md +++ b/docs/regulated/crypto-inventory.md @@ -14,9 +14,10 @@ to that provider, on that host, not to this software. **Anything not established | Capability | Trinity module | Underlying | CMVP cert | Notes | |---|---|---|---|---| | 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, FIPS mode | `lib/trinity/receipts/signer/p384.ex` | `:crypto.sign(:ecdsa, :sha384, …, :secp384r1)`, line 25 | **UNKNOWN** | ECDSA P-384 with SHA-384. Selected when the mode is enabled. Since slice 140 its DER signature is carried as ESP384's 96-byte `r \|\| s` (`lib/trinity/n6/ec2.ex`) | | 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 | +| Receipt hashing, v2 and v3 rows | `lib/trinity/receipts/envelope.ex:36` | `:crypto.hash(:sha256, …)` | **UNKNOWN** | Over the DSSE pre-authentication encoding, not the bare payload. Rows written before slice 140; still verified | +| Receipt identity and link, v4 rows (slice 140) | `lib/trinity/receipts/v4.ex` | `:crypto.hash(:sha384, …)` | **UNKNOWN** | `statement_id` over the kind's `typ` and the N6 body; the link to a v3 predecessor over its PAE bytes | | 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` | | Data key wrapping | `lib/trinity/keys/local.ex:179` | `:crypto.crypto_one_time_aead(:aes_256_gcm, …)` | **UNKNOWN** | The key encryption key wraps the data key beside the ciphertext | @@ -27,9 +28,10 @@ to that provider, on that host, not to this software. **Anything not established | Device identifier | `lib/trinity/receipts/clock.ex` | `:crypto.strong_rand_bytes/1` | Not applicable | Random, names nothing about the host | | JWK thumbprint | `lib/trinity/receipts/signer.ex` | `:crypto.hash(:sha256, …)` | Not applicable | RFC 7638 | | Canonical JSON | `lib/trinity/receipts/envelope.ex:15` | `Jcs.encode/1` | Not applicable | RFC 8785, not a cryptographic primitive | -| N6 statement signing (slice 138) | `lib/trinity/n6/cose.ex` | `:crypto.sign(:mldsa87, :none, …)` and `:crypto.verify/5`, FIPS 204, pure, empty context | **UNKNOWN** | Over RFC 9052's ToBeSigned. Not used by any statement yet. Where the runtime refuses ML-DSA-87 (FIPS mode on Red Hat's provider; OpenSSL 3.0) verification answers no trust, never a crash | -| N6 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 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. Receipts v4 sign through it since slice 140. 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 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. Receipts v4 sign through it since slice 140 | +| N6 statement signing, `commercial` profile, FIPS mode (slice 140) | `lib/trinity/n6/cose.ex`, `lib/trinity/n6/ec2.ex` | `:crypto.sign(:ecdsa, :sha384, …, :secp384r1)` and `:crypto.verify/5` | **UNKNOWN** | ESP384, COSE -51 (RFC 9864): the 96-byte `r \|\| s` of RFC 9053 section 2.1, converted from and to the DER `:crypto` uses. Refused at the `nss` entry point. Signs and verifies in FIPS mode on the leg's provider | +| N6 key id | `lib/trinity/n6/cose.ex`, `lib/trinity/n6/ec2.ex` | `:crypto.hash(:sha384, …)` | Not applicable | RFC 9679 thumbprint over RFC 9964's AKP members (ML-DSA-87), or RFC 9053's OKP (Ed25519) or EC2 (P-384, 107 bytes, coordinates padded to 48) members | | N6 foreign-bytes digest | `lib/trinity/n6/digest.ex` | `:crypto.hash(:sha384, …)` | **UNKNOWN** | Over bytes exactly as received, never re-encoded | | Deterministic CBOR | `lib/trinity/n6/cbor.ex` | pure Elixir | Not applicable | RFC 8949 section 4.2.1, not a cryptographic primitive | diff --git a/docs/regulated/receipts-verify.md b/docs/regulated/receipts-verify.md index c50acec..59a8d7f 100644 --- a/docs/regulated/receipts-verify.md +++ b/docs/regulated/receipts-verify.md @@ -16,6 +16,15 @@ The second is the one that matters for an assessment. A verifier that has to sta Trinity's records is asking you to trust the thing under examination. `bin/verify_receipt.exs` reads an exported file and needs nothing from this application. +For rows written since slice 140 (`receipt_v4_*`, N6 statements) the script loads, from source, the +N6 files and `Trinity.Receipts.V4` from Trinity's `lib/` directory: beside the script in a checkout, +or named with `--n6 ` when the script is copied elsewhere. Those files depend on +nothing but each other and OTP, and they are the same code the in-app verifier runs, which +`test/trinity/receipts/standalone_verifier_test.exs` holds the two to. A chain with no v4 row needs +none of it. Without the sources a v4 chain is exit 2, a usage error, never a verdict: + + elixir verify_receipt.exs receipts.json --n6 /path/to/trinity/lib + ## The task, quoted From `lib/mix/tasks/trinity.receipts.verify.ex`: @@ -89,10 +98,18 @@ timestamp. ## Scheme versions an assessor will meet -Rows exist under two scheme versions and both verify. `receipt_v2_*` carries ten signed fields; -`receipt_v3_*` carries eleven, the extra one being the clock. A chain that spans the change verifies -row by row under each row's own scheme. The mapping to published constructs, including the three +Rows exist under three scheme versions and all verify. `receipt_v2_*` carries ten signed fields; +`receipt_v3_*` carries eleven, the extra one being the clock; `receipt_v4_*` (slice 140) is an N6 +statement in COSE_Sign1, whose algorithm, key and kind are header parameters and whose `subject` and +`decision` are referenced by digest over bytes stored beside the row. A chain that spans a change +verifies row by row under each row's own scheme. The mapping to published constructs, including the fields that have no equivalent, is `docs/receipt-scheme-mapping.md`. +**Findings about v4 attachments.** The bytes a v4 row's `subject` and `decision` references name are +not covered by its signature. If they are missing or do not match, both verifiers print a `finding:` +line after the verdict and the exit does not change: the signed statement is as valid as it was, and +what the finding says is that the readable copy of its content is absent or not the one that was +signed. + That document also states, in terms, that Trinity issues SCITT **Signed Statements** and never SCITT **Receipts**, because it runs no Transparency Service. No conformance to RFC 9943 is claimed. diff --git a/lib/mix/tasks/trinity.receipts.verify.ex b/lib/mix/tasks/trinity.receipts.verify.ex index f13ddc8..21e5462 100644 --- a/lib/mix/tasks/trinity.receipts.verify.ex +++ b/lib/mix/tasks/trinity.receipts.verify.ex @@ -42,9 +42,13 @@ defmodule Mix.Tasks.Trinity.Receipts.Verify do code = Verifier.exit_code(outcome) case outcome do - {:ok, %{receipts: n, checkpoints: c}} -> + {:ok, %{receipts: n, checkpoints: c} = ok} -> Mix.shell().info("verified: #{n} receipts, #{c} checkpoints, exit #{code}") + # Slice 140: a v4 row's stored subject or decision bytes missing or not what its signed body + # names. A finding beside the verdict, which it does not change. + for f <- Map.get(ok, :findings, []), do: Mix.shell().info("finding: #{inspect(f)}") + {:error, ^code, reason} -> Mix.shell().error("#{label(code)}: #{inspect(reason)}, exit #{code}") end diff --git a/lib/trinity.ex b/lib/trinity.ex index 58a42f4..95b52a9 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -17,7 +17,8 @@ defmodule Trinity do # stays extractable. It is listed here because this boundary's code calls into it; the arrow # points one way and the compiler holds it that way. use Boundary, - deps: [Trinity.Keys], + # Slice 140: receipts v4 are N6 statements (`Trinity.Receipts.V4`). + deps: [Trinity.Keys, Trinity.N6], exports: [ Paths, diff --git a/lib/trinity/n6.ex b/lib/trinity/n6.ex index 6b5cf56..0fba24a 100644 --- a/lib/trinity/n6.ex +++ b/lib/trinity/n6.ex @@ -3,7 +3,7 @@ defmodule Trinity.N6 do @moduledoc """ 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). + (slice 138) and, on the `commercial` profile, Ed25519 (slice 139) and ESP384 (slice 140). 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,15 +12,19 @@ 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.EC2`: ESP384's P-384 keys, thumbprint and `r || s` signatures (slice 140); * `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, and the publisher's answers of 2026-10-19); see - `Trinity.N6.Profile`. Nothing Trinity signed + The profile is provisional (v0, draft r3, and the publisher's answers of 2026-10-19 and + 2026-10-20); 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, Carrier, Profile] + use Boundary, + top_level?: true, + deps: [], + exports: [CBOR, Body, Digest, COSE, Carrier, EC2, Profile] end diff --git a/lib/trinity/n6/body.ex b/lib/trinity/n6/body.ex index 83258ce..c2aae32 100644 --- a/lib/trinity/n6/body.ex +++ b/lib/trinity/n6/body.ex @@ -25,7 +25,9 @@ defmodule Trinity.N6.Body do Checks run in this order, each answering the first failure: not a map (`body_not_map`); key 0 missing (`schema_version_missing`), not an unsigned integer (`key_not_uint`), reserved - (`schema_reserved`), unknown with this kind (`unknown_schema`); then keys in ascending order, + (`schema_reserved`), in the shared test block 0x80 outside a declared test mode + (`test_schema_outside_test_mode`, slice 140), unknown with this kind (`unknown_schema`), the order + of the publisher's correction of 2026-10-20 17:27Z; then keys in ascending order, undeclared (`unknown_key`); then required keys (`required_key_missing`); then each value in key order (`wrong_type`, `set_not_canonical`, or a digest's own code). `body_not_map`, `required_key_missing` and `wrong_type` are this tree's codes where the profile has none yet. @@ -60,13 +62,13 @@ defmodule Trinity.N6.Body do @doc """ Decodes bytes as a body of `kind` under one of `schemas`: the strict decode of - `Trinity.N6.CBOR`, then `check/3`. + `Trinity.N6.CBOR`, then `check/4`. `opts`: `test_mode: true` admits the shared test block. """ - @spec decode(binary(), String.t(), [schema()]) :: + @spec decode(binary(), String.t(), [schema()], keyword()) :: {:ok, map(), schema()} | {:error, Profile.code(), non_neg_integer() | nil} - def decode(bytes, kind, schemas) when is_binary(bytes) do + def decode(bytes, kind, schemas, opts \\ []) when is_binary(bytes) do with {:ok, value} <- CBOR.decode(bytes), - {:ok, schema} <- check(value, kind, schemas) do + {:ok, schema} <- check(value, kind, schemas, opts) do {:ok, value, schema} else {:error, code, offset} -> {:error, code, offset} @@ -78,9 +80,10 @@ defmodule Trinity.N6.Body do Encodes a body after checking it, so a body this tree builds is one it would accept. The decode limits apply too: a body that could not be read back is not built. """ - @spec encode(map(), String.t(), [schema()]) :: {:ok, binary()} | {:error, Profile.code()} - def encode(value, kind, schemas) do - with {:ok, _schema} <- check(value, kind, schemas), + @spec encode(map(), String.t(), [schema()], keyword()) :: + {:ok, binary()} | {:error, Profile.code()} + def encode(value, kind, schemas, opts \\ []) do + with {:ok, _schema} <- check(value, kind, schemas, opts), {:ok, bytes} <- CBOR.encode(value), {:ok, _} <- CBOR.decode(bytes) do {:ok, bytes} @@ -90,12 +93,19 @@ defmodule Trinity.N6.Body do end end - @doc "Checks a decoded value against the body rules and the schema its key 0 names." - @spec check(CBOR.value(), String.t(), [schema()]) :: {:ok, schema()} | {:error, Profile.code()} - def check(value, _kind, _schemas) when not is_map(value), do: {:error, :body_not_map} + @doc """ + Checks a decoded value against the body rules and the schema its key 0 names. `opts`: + `test_mode: true` admits the shared test block (0x80), which every other verifier refuses. + """ + @spec check(CBOR.value(), String.t(), [schema()], keyword()) :: + {:ok, schema()} | {:error, Profile.code()} + def check(value, kind, schemas, opts \\ []) + + def check(value, _kind, _schemas, _opts) when not is_map(value), do: {:error, :body_not_map} - def check(value, kind, schemas) do + def check(value, kind, schemas, opts) do with {:ok, version} <- version(value), + :ok <- test_mode(version, Keyword.get(opts, :test_mode, false)), {:ok, schema} <- schema(kind, version, schemas), :ok <- fields(Map.delete(value, 0), schema.fields) do {:ok, schema} @@ -115,6 +125,12 @@ defmodule Trinity.N6.Body do defp version(%{0 => _}), do: {:error, :key_not_uint} defp version(_), do: {:error, :schema_version_missing} + defp test_mode(version, false) do + if Profile.test_schema?(version), do: {:error, :test_schema_outside_test_mode}, else: :ok + end + + defp test_mode(_version, true), do: :ok + defp schema(kind, version, schemas) do case Enum.find(schemas, &(&1.kind == kind and &1.version == version)) do nil -> {:error, :unknown_schema} diff --git a/lib/trinity/n6/carrier.ex b/lib/trinity/n6/carrier.ex index 3c935af..c1c569f 100644 --- a/lib/trinity/n6/carrier.ex +++ b/lib/trinity/n6/carrier.ex @@ -12,8 +12,8 @@ defmodule Trinity.N6.Carrier do 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`). + `SINGLE_SCHEMA` is 0xFFFF0003, assigned by the publisher's Decision of 2026-10-20 (point 2; slice + 139 had used the same value as a placeholder). ## Refusals @@ -27,6 +27,14 @@ defmodule Trinity.N6.Carrier do (`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. + + Once the signature verifies, the body it covers (slice 140, the publisher's Decision of + 2026-10-20, point 3): its key 0 must be the schema number the `typ`'s entry names, where the + entry names one (`typ_schema_mismatch`), and a full-payload single-signer body must carry key 1, + `issuer` (`issuer_missing`), so two signers over identical bytes cannot pass a copy across + issuers as the same statement. Both read only signed bytes, after the signature, as r3's issuer + check (T-d) does; the order is provisional with the rest of the profile's step order (VB3). A + payload that is not an N6 map is the codec's code, or `body_not_map`. """ alias Trinity.N6.{CBOR, COSE, Profile} @@ -65,13 +73,37 @@ defmodule Trinity.N6.Carrier do with {:ok, parts} <- decode(bytes), {:ok, %{entry: entry}} <- COSE.decode_protected(parts.protected, table, entry_point), - :ok <- single(entry) do - COSE.verify(parts, opts) + :ok <- single(entry), + {:ok, verdict} <- COSE.verify(parts, opts), + :ok <- body(parts.payload, entry) do + {:ok, verdict} else + {:error, exit, code} -> {:error, exit, code} {:error, code} -> {:error, Profile.exit_of(code), code} end end + defp body(payload, entry) do + case CBOR.decode(payload) do + {:ok, %{} = body} -> body_rules(body, entry) + {:ok, _other} -> {:error, :body_not_map} + {:error, code, _offset} -> {:error, code} + end + end + + defp body_rules(body, entry) do + cond do + is_map_key(entry, :schema) and Map.get(body, 0) != entry.schema -> + {:error, :typ_schema_mismatch} + + not is_map_key(body, 1) -> + {:error, :issuer_missing} + + true -> + :ok + end + end + defp codec(bytes) do case CBOR.decode(bytes) do {:ok, value} -> {:ok, value} diff --git a/lib/trinity/n6/cose.ex b/lib/trinity/n6/cose.ex index 78c2e88..d43dc63 100644 --- a/lib/trinity/n6/cose.ex +++ b/lib/trinity/n6/cose.ex @@ -4,7 +4,9 @@ defmodule Trinity.N6.COSE do @moduledoc """ 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). + `commercial` profile only (slice 139, the profile publisher's answers of 2026-10-19); ESP384 + (-51) on `commercial` only, for FIPS-mode nodes (slice 140, the publisher's Decision of + 2026-10-20; `Trinity.N6.EC2`). ## The protected header @@ -12,6 +14,7 @@ defmodule Trinity.N6.COSE do `alg` -50 is ML-DSA-87 (RFC 9964), pure, with an empty context string (RFC 9964 section 5). `alg` -19 is Ed25519 (RFC 9864), pure EdDSA as RFC 8032 section 5.1 defines it. + `alg` -51 is ESP384 (RFC 9864), ECDSA on P-384 with SHA-384, its signature the 96-byte `r || s`. `kid` is the RFC 9679 COSE Key Thumbprint, SHA-384, encoded under RFC 8949 section 4.2.1: @@ -20,19 +23,24 @@ defmodule Trinity.N6.COSE do 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. + `a3 01 01 20 06 21 58 20` followed by the key; + * for ESP384, over the EC2 members, the 107 bytes `Trinity.N6.EC2` names. + + Neither the OKP nor the EC2 thumbprint has an `alg`, so the binding comes from the key's + registration: a key is registered with its one algorithm, and a header whose `alg` is not it is + refused (`alg_not_permitted`). Slice 139 bound each `typ` to one algorithm instead; the + publisher's Decision of 2026-10-20 (point 3) replaced that with one `typ` per kind, which is what + this tree does since slice 140. `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. + Each entry names its carrier and signers (`Trinity.N6.Carrier` reads those), optionally the + schema number its body's key 0 must be, 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`. + -50 is permitted; at `:commercial`, -50, -19 and -51. Both 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, @@ -57,27 +65,38 @@ 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 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 + refuses Ed25519). A runtime that refuses the algorithm is never a crash and never a pass. + + After the header, in this order: the key the `kid` names must be known (5); registered for the + header's `alg` (`alg_not_permitted`); for ESP384 a valid P-384 point (`key_invalid`); its + thumbprint the `kid` (`kid_not_thumbprint`); the signature its algorithm's size (ESP384: + `signature_encoding`; the others: `carrier_malformed`); and the signature valid. The attribution classes 6 and 7 need the profile's key descriptors and are not decided here. ## Keys - A key names its algorithm: `%{alg: -50 | -19, public: pub, private: priv}` to sign, and + A key names its algorithm: `%{alg: -50 | -19 | -51, 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). + derives it from a seed. An Ed25519 private key is its 32-byte secret (RFC 8032 section 5.1.5). An + ESP384 private key is its 48-byte scalar, and its public key the 97-byte uncompressed point. """ - alias Trinity.N6.{CBOR, Profile} + alias Trinity.N6.{CBOR, EC2, Profile} @mldsa87 -50 @ed25519 -19 - @sizes %{@mldsa87 => %{public: 2592, signature: 4627}, @ed25519 => %{public: 32, signature: 64}} - @permitted %{nss: [@mldsa87], commercial: [@mldsa87, @ed25519]} + @esp384 -51 + @sizes %{ + @mldsa87 => %{public: 2592, signature: 4627}, + @ed25519 => %{public: 32, signature: 64}, + @esp384 => %{public: 97, signature: 96} + } + @permitted %{nss: [@mldsa87], commercial: [@mldsa87, @ed25519, @esp384]} @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] @@ -88,20 +107,20 @@ 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 "An algorithm N6 permits somewhere: -50 (ML-DSA-87) or -19 (Ed25519)." - @type alg :: -50 | -19 + @typedoc "An algorithm N6 permits somewhere: -50 (ML-DSA-87), -19 (Ed25519) or -51 (ESP384)." + @type alg :: -50 | -19 | -51 @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. + The `typ` values an entry point accepts: each one's carrier, signers, the schema number its body's + key 0 must be (when the table says), and whether it is SCITT-renderable. """ @type typ_table :: %{ optional(String.t()) => %{ required(:scitt) => boolean(), - required(:alg) => alg(), + optional(:schema) => non_neg_integer(), optional(:carrier) => :single | :envelope | :note, optional(:signers) => :single | :pair } @@ -111,7 +130,7 @@ defmodule Trinity.N6.COSE do @type key :: %{ optional(:alg) => alg(), public: binary(), - private: {:seed, binary()} | binary() + private: {:seed, binary()} | {:expandedkey, binary()} | binary() } @doc "The RFC 9679 SHA-384 thumbprint of an ML-DSA-87 public key: its `kid`." @@ -126,6 +145,8 @@ defmodule Trinity.N6.COSE do def thumbprint(@ed25519, pub) when byte_size(pub) == 32, do: :crypto.hash(:sha384, @okp_prefix <> pub) + def thumbprint(@esp384, pub) when byte_size(pub) == 97, do: EC2.thumbprint(pub) + @doc """ The protected header bytes for a `kid` and `typ`, with CWT `{iss, sub}` when given, under `alg` (-50 when not given). @@ -160,7 +181,6 @@ defmodule Trinity.N6.COSE do :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, %{alg: alg, kid: kid, typ: typ, cwt: cwt, entry: entry}} end @@ -196,10 +216,6 @@ defmodule Trinity.N6.COSE 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} @@ -251,6 +267,12 @@ defmodule Trinity.N6.COSE do signed(protected, payload, &:crypto.sign(:eddsa, :none, &1, [priv, :ed25519])) end + def sign(payload, typ, %{alg: @esp384, public: pub, private: priv}, opts) + when is_binary(payload) and byte_size(pub) == 97 and is_binary(priv) do + protected = protected_header(EC2.thumbprint(pub), typ, Keyword.get(opts, :cwt), @esp384) + signed(protected, payload, &esp384_sign(&1, priv)) + 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 @@ -259,10 +281,22 @@ defmodule Trinity.N6.COSE do 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. + # The forms of an ML-DSA-87 private key OTP signs with: the expanded key, bare or tagged, and the + # seed. defp mldsa87_private!(priv) when is_binary(priv) and byte_size(priv) == 4896, do: priv + defp mldsa87_private!({:expandedkey, key} = priv) when byte_size(key) == 4896, do: priv defp mldsa87_private!({:seed, seed} = priv) when byte_size(seed) == 32, do: priv + @doc """ + An ESP384 signature over bytes: `:crypto`'s DER, as the 96-byte `r || s`. For a caller that holds + the private key elsewhere (the receipts' key custody) and signs ToBeSigned itself. + """ + @spec esp384_sign(binary(), binary()) :: binary() + def esp384_sign(bytes, priv) when is_binary(bytes) and is_binary(priv) do + {:ok, raw} = :ecdsa |> :crypto.sign(:sha384, bytes, [priv, :secp384r1]) |> EC2.from_der() + raw + end + defp signed(protected, payload, sign) do signature = sign.(to_be_signed(protected, payload)) {:ok, %{protected: protected, payload: payload, signature: signature}} @@ -286,6 +320,7 @@ defmodule Trinity.N6.COSE do with {:ok, %{alg: alg, kid: kid, typ: typ}} <- decode_protected(protected, table, entry_point), {:ok, pub} <- known_key(keys.(kid), alg), + :ok <- key_valid(alg, pub), :ok <- thumbprint_matches(alg, pub, kid), :ok <- signature_size(alg, signature), :ok <- signature_valid(alg, to_be_signed(protected, payload), signature, pub) do @@ -295,15 +330,20 @@ defmodule Trinity.N6.COSE do end end - # 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. + # A key registered under another algorithm than the header's: the publisher's Decision of + # 2026-10-20 (point 1, "the algorithm check") refuses a protected `alg` that differs from the key + # descriptor's with `alg_not_permitted`. Slice 139 answered `kid_not_thumbprint`, the draft's C5, + # which assumed the thumbprint carried `alg`; for OKP and EC2 it does not. defp known_key({:ok, %{alg: alg, public: pub}}, alg) when is_binary(pub), do: {:ok, pub} defp known_key({:ok, %{alg: _other, public: pub}}, _alg) when is_binary(pub), - do: {:error, :kid_not_thumbprint} + do: {:error, :alg_not_permitted} defp known_key(_unknown, _alg), do: {:error, :policy_stale_or_unknown_key} + defp key_valid(@esp384, pub), do: if(EC2.valid_key?(pub), do: :ok, else: {:error, :key_invalid}) + defp key_valid(_alg, _pub), do: :ok + defp thumbprint_matches(alg, pub, kid) do if byte_size(pub) == @sizes[alg].public and thumbprint(alg, pub) == kid, do: :ok, @@ -311,7 +351,11 @@ defmodule Trinity.N6.COSE do end defp signature_size(alg, sig) do - if byte_size(sig) == @sizes[alg].signature, do: :ok, else: {:error, :carrier_malformed} + cond do + byte_size(sig) == @sizes[alg].signature -> :ok + alg == @esp384 -> {:error, :signature_encoding} + true -> {:error, :carrier_malformed} + end end defp signature_valid(alg, tbs, sig, pub) do @@ -325,6 +369,9 @@ defmodule Trinity.N6.COSE do defp crypto_verify(@ed25519, tbs, sig, pub), do: :crypto.verify(:eddsa, :none, tbs, sig, [pub, :ed25519]) + defp crypto_verify(@esp384, tbs, sig, pub), + do: :crypto.verify(:ecdsa, :sha384, tbs, EC2.to_der(sig), [pub, :secp384r1]) + # 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); diff --git a/lib/trinity/n6/digest.ex b/lib/trinity/n6/digest.ex index dfe488c..542a17c 100644 --- a/lib/trinity/n6/digest.ex +++ b/lib/trinity/n6/digest.ex @@ -27,6 +27,14 @@ defmodule Trinity.N6.Digest do @spec of(binary()) :: [integer() | {:bytes, binary()}] def of(bytes) when is_binary(bytes), do: [@sha384, {:bytes, :crypto.hash(:sha384, bytes)}] + @doc """ + The Digest whose value is a SHA-384 hash already computed: an identifier such as a + `statement_id`, named by its hash (the publisher's Decision of 2026-10-20, point 3: `prev` is + `[-43, statement_id]`). Slice 140. + """ + @spec of_value(binary()) :: [integer() | {:bytes, binary()}] + def of_value(hash) when byte_size(hash) == 48, do: [@sha384, {:bytes, hash}] + @doc "A ForeignRef to bytes exactly as given, with their media type and an optional locator." @spec foreign_ref(binary(), String.t(), String.t() | nil) :: %{ non_neg_integer() => CBOR.value() diff --git a/lib/trinity/n6/ec2.ex b/lib/trinity/n6/ec2.ex new file mode 100644 index 0000000..cb69611 --- /dev/null +++ b/lib/trinity/n6/ec2.ex @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.EC2 do + @moduledoc """ + ESP384 (-51) keys and signatures under N6 (slice 140): ECDSA on P-384 with SHA-384, RFC 9864's + fully specified identifier, admitted on the `commercial` profile for FIPS-mode nodes (the + publisher's Decision of 2026-10-20, point 1). + + ## The key + + A public key is the uncompressed point `0x04 || x || y`, 97 bytes, as `:crypto` returns it. A + compressed point, another curve, or a point not on P-384 is refused (`key_invalid`, this tree's + code: the Decision says the verifier checks the key type, the curve and the point, and names no + code for it). + + The `kid` is SHA-384 of the RFC 9679 thumbprint input over the EC2 required members + `{1: 2, -1: 2, -2: x, -3: y}` (EC2 and P-384 are 2 and 2 in RFC 9053 section 7.1), encoded under + RFC 8949 section 4.2.1 directly, so exactly the 107 bytes + `a4 01 02 20 02 21 58 30 || x || 22 58 30 || y`. `x` and `y` are always 48 bytes, left-padded + with zeros: a coordinate with a leading zero byte keeps it, or about one key in 128 would get + another `kid`. + + ## The signature + + The 96-byte `r || s` of RFC 9053 section 2.1, each half 48 bytes. `:crypto` signs and verifies + DER (`ECDSA-Sig-Value`, RFC 3279), so `from_der/1` and `to_der/1` convert. A signature that is not + exactly 96 bytes is `signature_encoding`; no DER is ever parsed from a statement. Both `s` and + `n - s` verify, as OpenSSL accepts both: a verifier that enforced low-s would split verdicts with + every other. + """ + + @p 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFF + @b 0xB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF + + @doc "The P-384 group order `n`." + @spec order() :: pos_integer() + def order, + do: + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973 + + @doc "Whether bytes are an uncompressed P-384 point on the curve." + @spec valid_key?(binary()) :: boolean() + def valid_key?(<<4, x::unsigned-size(384), y::unsigned-size(384)>>) when x < @p and y < @p, + do: rem(y * y - (x * x * x - 3 * x + @b), @p) == 0 + + def valid_key?(_other), do: false + + @doc "The RFC 9679 SHA-384 thumbprint of a P-384 public key: its `kid`." + @spec thumbprint(binary()) :: binary() + def thumbprint(<<4, x::binary-size(48), y::binary-size(48)>>), + do: :crypto.hash(:sha384, thumbprint_input(x, y)) + + @doc "The 107 bytes the thumbprint hashes." + @spec thumbprint_input(binary(), binary()) :: binary() + def thumbprint_input(x, y) when byte_size(x) == 48 and byte_size(y) == 48, + do: <<0xA4, 0x01, 0x02, 0x20, 0x02, 0x21, 0x58, 0x30>> <> x <> <<0x22, 0x58, 0x30>> <> y + + @doc "The 96-byte `r || s` of a DER `ECDSA-Sig-Value`." + @spec from_der(binary()) :: {:ok, binary()} | :error + def from_der(<<0x30, len, rest::binary>>) when byte_size(rest) == len do + with {:ok, r, rest} <- der_integer(rest), + {:ok, s, ""} <- der_integer(rest), + true <- r < 2 ** 384 and s < 2 ** 384 do + {:ok, <>} + else + _ -> :error + end + end + + def from_der(_other), do: :error + + @doc "The DER `ECDSA-Sig-Value` for a 96-byte `r || s`." + @spec to_der(binary()) :: binary() + def to_der(<>) do + body = der_integer_bytes(r) <> der_integer_bytes(s) + <<0x30, byte_size(body)>> <> body + end + + # Short-form lengths only: two P-384 integers are at most 49 bytes each, so the sequence is at most + # 102 bytes and every length is below 128. + defp der_integer(<<0x02, len, bytes::binary-size(len), rest::binary>>) when len in 1..49, + do: {:ok, :binary.decode_unsigned(bytes), rest} + + defp der_integer(_other), do: :error + + defp der_integer_bytes(n) do + bytes = :binary.encode_unsigned(n) + bytes = if :binary.first(bytes) >= 0x80, do: <<0>> <> bytes, else: bytes + <<0x02, byte_size(bytes)>> <> bytes + end + + @doc "`r || s` with `s` replaced by `n - s`: the other signature that verifies the same bytes." + @spec negate_s(binary()) :: binary() + def negate_s(<>), + do: r <> <> +end diff --git a/lib/trinity/n6/profile.ex b/lib/trinity/n6/profile.ex index 8380016..338cdd0 100644 --- a/lib/trinity/n6/profile.ex +++ b/lib/trinity/n6/profile.ex @@ -13,14 +13,21 @@ defmodule Trinity.N6.Profile do 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. + one classical algorithm on the `commercial` profile. Slice 140 adds the publisher's Decision of + 2026-10-20 (17:24Z, with its 17:27Z correction): ESP384 (-51) for FIPS-mode nodes, the schema + numbers, `statement_id` as the link, one `typ` per kind, and ForeignRefs for text-keyed content. + `pin/0` names both. ## 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. + still Open in it (its section 12.1 item 3); the publisher's Decision of 2026-10-20 assigns the + single-signer carrier's, 0xFFFF0003, the value slice 139 had used as a placeholder. + + A statement kind's key 0 is `(block << 24) | (kind << 8) | version` (`statement_schema/3`): a + block of 0x80 is the shared test block, accepted only in a verifier's declared test mode; blocks + 0x81 to 0x8F are provisional, one per publishing board, Trinity's 0x83; numbers below 0x80000000 + are N21's, none issued yet. Everything provisional is re-issued under N21 at v1. ## Codes @@ -47,8 +54,11 @@ defmodule Trinity.N6.Profile do @pin %{ version: "n6/v0", draft: "r3", - answers: + answers: [ "the profile publisher's answers of 2026-10-19 (single-signer carrier, Ed25519 on commercial)", + "the profile publisher's Decision of 2026-10-20 17:24Z and its 17:27Z correction (ESP384, " <> + "schema numbers, statement_id, one typ per kind, ForeignRefs)" + ], status: :provisional, draft_sha384: "54ab5e549259b87b18c51b4d1a3dbbbbb230b74ae562d5c2ef8ec43288c6c2dafc6376bff51ae205da6043bb9da7b7f2" @@ -60,9 +70,12 @@ defmodule Trinity.N6.Profile do @schemas %{ envelope: {0xFFFF0001, :draft}, statement_id: {0xFFFF0002, :draft}, - single: {0xFFFF0003, :placeholder} + single: {0xFFFF0003, :publisher} } + @test_block 0x80 + @trinity_block 0x83 + # code => {exit class, who defines it} @codes %{ # Section 2, the encoding rules. @@ -92,6 +105,9 @@ defmodule Trinity.N6.Profile do unknown_schema: {1, :draft}, unknown_key: {1, :draft}, schema_reserved: {1, :draft}, + # The publisher's Decision of 2026-10-20, point 2, checked between `schema_reserved` and + # `unknown_schema` (its 17:27Z correction). + test_schema_outside_test_mode: {1, :publisher}, set_not_canonical: {1, :draft}, body_not_map: {1, :trinity}, required_key_missing: {1, :trinity}, @@ -116,13 +132,27 @@ defmodule Trinity.N6.Profile do # 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}, + # The publisher's Decision of 2026-10-20: an ESP384 signature that is not the 96-byte r || s + # (point 1), a key-0 value whose `typ` is another kind's (point 3), a full-payload + # single-signer body without key 1 (point 3). + signature_encoding: {1, :publisher}, + typ_schema_mismatch: {1, :publisher}, + issuer_missing: {1, :publisher}, + # A registered key that is not what its algorithm needs: for ESP384, a compressed point, another + # curve, or a point not on P-384. The Decision asks for the check and names no code. + key_invalid: {1, :trinity}, + # Point 4: the bytes a ForeignRef names. Missing bytes are a finding (no trust where the content + # must be judged); bytes that do not match are invalid for the attachment, never for the signed + # statement. + foreign_bytes_missing: {5, :publisher}, + foreign_digest_mismatch: {1, :publisher}, # Section 9. claim: {0, :draft}, usage: {2, :draft}, policy_stale_or_unknown_key: {5, :draft}, - # 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. + # The runtime cannot run the header's algorithm (ML-DSA-87, Ed25519 or ESP384; 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} } @@ -143,20 +173,37 @@ defmodule Trinity.N6.Profile do def limits, do: @limits @doc "Every code, with its exit class and whether the draft defines it." - @spec codes() :: %{code() => {0 | 1 | 2 | 5, :draft | :trinity}} + @spec codes() :: %{code() => {0 | 1 | 2 | 5, :draft | :publisher | :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). + Open there) or `:publisher` (the publisher's answers, ahead of the draft that will carry them). """ - @spec schemas() :: %{atom() => {non_neg_integer(), :draft | :placeholder}} + @spec schemas() :: %{atom() => {non_neg_integer(), :draft | :publisher}} 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 """ + A statement kind's key 0: `(block << 24) | (kind << 8) | version`, with `kind` 1 to 65,535 and + `version` 1 to 255. + """ + @spec statement_schema(0..0x8F, 1..65_535, 1..255) :: non_neg_integer() + def statement_schema(block, kind, version) + when block in 0..0x8F and kind in 1..65_535 and version in 1..255, + do: Bitwise.bsl(block, 24) + Bitwise.bsl(kind, 8) + version + + @doc "Trinity's provisional block, 0x83." + @spec trinity_block() :: 0x83 + def trinity_block, do: @trinity_block + + @doc "Whether a key-0 value is in the shared test block, 0x80." + @spec test_schema?(non_neg_integer()) :: boolean() + def test_schema?(n) when is_integer(n), do: Bitwise.bsr(n, 24) == @test_block + @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.ex b/lib/trinity/receipts.ex index 57a51f3..ba141b1 100644 --- a/lib/trinity/receipts.ex +++ b/lib/trinity/receipts.ex @@ -12,8 +12,10 @@ defmodule Trinity.Receipts do count of live sessions plus one. """ use Boundary, - deps: [Trinity], + # Slice 140: receipts v4 are N6 statements; `Trinity.N6` is a dependency of the root boundary. + deps: [Trinity, Trinity.N6], exports: [ + V4, Receipt, Checkpoint, KeyCustody, @@ -155,6 +157,8 @@ defmodule Trinity.Receipts do "format" => "trinity-receipts-export/1", "chain_scope" => scope, "exported_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + # Slice 140: the issuer every v4 body names, which the verifier holds each one to. + "issuer" => "trinity:" <> Trinity.Receipts.Clock.node_id(), "receipts" => scope |> list() |> Enum.map(&Receipt.to_export/1), "checkpoints" => scope |> checkpoints() |> Enum.map(&Checkpoint.to_export/1), "registry" => registry diff --git a/lib/trinity/receipts/chain_writer.ex b/lib/trinity/receipts/chain_writer.ex index d3ded42..8080b4b 100644 --- a/lib/trinity/receipts/chain_writer.ex +++ b/lib/trinity/receipts/chain_writer.ex @@ -8,11 +8,14 @@ defmodule Trinity.Receipts.ChainWriter do process, so a predecessor read and a successor append cannot interleave and the chain cannot fork. `append/2` is a call: the caller learns whether its receipt is in the chain. - Per row: the body (`scheme`, `seq`, `chain_scope`, `prev_hash`, `kind`, `subject`, - `decision`, `fingerprint`, `at`, `key_id`) is canonicalised, wrapped in the PAE with the - receipt type, hashed; decision, effect, boot and cap receipts are signed through - `Trinity.Receipts.KeyCustody` before the row is written, and a signer that cannot sign - means no row and `{:error, {:signer_unavailable, reason}}` (never an unsigned row; AC5). + Per row, since slice 140 (`Trinity.Receipts.V4`): the body is an N6 statement (`issuer`, `prev`, + `seq`, `chain_scope`, `at`, `clock`, and `subject` and `decision` as ForeignRefs over their RFC 8785 + bytes, which are stored beside it), its `receipt_hash` its `statement_id`; decision, effect, boot + and cap receipts are signed in the single-signer carrier through `Trinity.Receipts.KeyCustody` + before the row is written, and a signer that cannot sign means no row and + `{:error, {:signer_unavailable, reason}}` (never an unsigned row; AC5). Rows a chain already holds + under v2 or v3 stay as they were signed; the first v4 row links to the last of them by + `V4.legacy_link/1`. Query receipts are chained unsigned and covered by a checkpoint every N rows, every T milliseconds after the first uncovered row, on shutdown, and on rehydrate before a new row is accepted (amendment 5, AC9). @@ -30,6 +33,8 @@ defmodule Trinity.Receipts.ChainWriter do import Ecto.Query + alias Trinity.N6.COSE + alias Trinity.Receipts.{ Checkpoint, Clock, @@ -38,7 +43,8 @@ defmodule Trinity.Receipts.ChainWriter do KeyRegistry, Queue, Receipt, - Signer + Signer, + V4 } alias Trinity.Repo.Receipts, as: Repo @@ -48,9 +54,11 @@ defmodule Trinity.Receipts.ChainWriter do @default_every 100 @default_after_ms 5_000 + # `prev_link` is the tail's link id, 48 bytes: its `statement_id`, or for a v2 or v3 tail the + # SHA-384 of its PAE bytes (`V4.legacy_link/1`). defstruct scope: nil, seq: 0, - prev_hash: nil, + prev_link: nil, clock: nil, uncovered_first: nil, uncovered_count: 0, @@ -188,12 +196,12 @@ defmodule Trinity.Receipts.ChainWriter do forward? = Map.get(attrs, :forward, true) with :ok <- check_queue_bound(forward?, state), - {:ok, %{scheme: scheme, key_id: key_id}} <- selection(), - {row, at, clock} = build_row(attrs, kind, scheme, key_id, state), + {:ok, selection} <- selection(), + {row, at, clock} = build_row(attrs, kind, selection, state), :ok <- check_clock(clock, state), - bytes = Envelope.pae(Envelope.receipt_type(scheme), row.signed_payload), - {:ok, signature} <- sign_if_needed(kind, bytes) do - insert_row(%{row | signature: signature, inserted_at: at}, state, clock, forward?) + {:ok, row} <- row_ok(row), + {:ok, carrier} <- sign_if_needed(kind, row.body, selection) do + insert_row(signed(row, carrier, at), state, clock, forward?) else {:error, {:clock_regression, offered, held}} -> refuse_clock_regression(offered, held, state) @@ -250,7 +258,6 @@ defmodule Trinity.Receipts.ChainWriter do # the attributes, as the merge path and an adapter's replay both do. A row that claims to precede # one already in the chain is refused, and the refusal is itself a receipt, because a refusal that # leaves no record is indistinguishable from the call never having been made. - defp check_clock(nil, _state), do: :ok defp check_clock(_clock, %{clock: nil}), do: :ok defp check_clock(clock, %{clock: held}) do @@ -286,13 +293,12 @@ defmodule Trinity.Receipts.ChainWriter do end defp do_append_unchecked(attrs, state) do - with {:ok, %{scheme: scheme, key_id: key_id}} <- selection(), - {row, at, clock} = - build_row(Map.delete(attrs, :clock), "decision", scheme, key_id, state), - bytes = Envelope.pae(Envelope.receipt_type(scheme), row.signed_payload), - {:ok, signature} <- sign_if_needed("decision", bytes) do + with {:ok, selection} <- selection(), + {row, at, clock} = build_row(Map.delete(attrs, :clock), "decision", selection, state), + {:ok, row} <- row_ok(row), + {:ok, carrier} <- sign_if_needed("decision", row.body, selection) do insert_row( - %{row | signature: signature, inserted_at: at}, + signed(row, carrier, at), state, clock, Map.get(attrs, :forward, true) @@ -300,45 +306,42 @@ defmodule Trinity.Receipts.ChainWriter do end end - defp build_row(attrs, kind, scheme, key_id, state) do + defp build_row(attrs, kind, selection, state) do seq = state.seq + 1 at = DateTime.utc_now() - - clock = - cond do - not Signer.clocked?(scheme) -> nil - offered = Map.get(attrs, :clock) -> offered - true -> Clock.next(state.clock) - end + clock = Map.get(attrs, :clock) || Clock.next(state.clock) + subject = Map.get(attrs, :subject, %{}) + subject_bytes = Envelope.canonical(subject) + decision_bytes = Map.get(attrs, :decision) && Envelope.canonical(Map.get(attrs, :decision)) body = - %{ - "scheme" => scheme, - "seq" => seq, - "chain_scope" => state.scope, - "prev_hash" => state.prev_hash, - "kind" => kind, - "subject" => Map.get(attrs, :subject, %{}), - "decision" => Map.get(attrs, :decision), - "fingerprint" => Map.get(attrs, :fingerprint), - "at" => DateTime.to_iso8601(at), - "key_id" => key_id - } - |> then(fn b -> if clock, do: Map.put(b, "clock", Clock.to_map(clock)), else: b end) - - payload = Envelope.canonical(body) - hash = Envelope.hash(Envelope.pae(Envelope.receipt_type(scheme), payload)) + V4.row_body(kind, %{ + issuer: issuer(), + seq: seq, + chain_scope: state.scope, + prev: state.prev_link, + at: DateTime.to_unix(at, :microsecond), + clock: {clock.wall, clock.counter, clock.node}, + subject: subject_bytes, + decision: decision_bytes, + fingerprint: Map.get(attrs, :fingerprint) + }) row = %Receipt{ chain_scope: state.scope, seq: seq, - prev_hash: state.prev_hash, - receipt_hash: hash, - scheme: scheme, + prev_hash: state.prev_link && V4.hex(state.prev_link), + receipt_hash: statement_hex(kind, body), + scheme: selection.scheme, kind: kind, - signed_payload: payload, - key_id: key_id, - subject: Map.get(attrs, :subject, %{}), + # v2 and v3 rows keep their RFC 8785 body here; a v4 row's body is `body`. The column is NOT + # NULL and SQLite cannot relax that in place. + signed_payload: "", + body: body, + subject_bytes: subject_bytes, + decision_bytes: decision_bytes, + key_id: selection.key_id, + subject: subject, subject_ref: Map.get(attrs, :subject_ref), meta: Map.get(attrs, :meta, %{}) } @@ -346,13 +349,35 @@ defmodule Trinity.Receipts.ChainWriter do {row, at, clock} end + defp statement_hex(kind, {:ok, body}), do: V4.hex(V4.statement_id(kind, body)) + defp statement_hex(_kind, {:error, _code}), do: nil + + # A signed row keeps its N6 signature in `signature` as well as in its carrier, as a checkpoint + # does, so "signed" reads the same for a v4 row as for a v3 one; the carrier is what a verifier + # reads. + defp signed(row, nil, at), do: %{row | inserted_at: at} + + defp signed(row, carrier, at) do + {:ok, %{signature: signature}} = Trinity.N6.Carrier.decode(carrier) + %{row | carrier: carrier, signature: signature, inserted_at: at} + end + + # A body the profile refuses is a bug in the caller's fields (a float in a fingerprint, say), never + # a row: it is refused with the profile's code. + defp row_ok(%Receipt{body: {:ok, body}} = row), do: {:ok, %{row | body: body}} + defp row_ok(%Receipt{body: {:error, code}}), do: {:error, {:body_not_n6, code}} + + # The issuer every v4 body names: this installation, by the device id its clock already carries. + # Provisional until the profile's issuer key descriptor (r3 section 8.2) exists. + defp issuer, do: "trinity:" <> Clock.node_id() + # The receipt and its queue entry land together or not at all. A receipt in the chain with no # queue entry is one the far side will never see and nothing will ever notice; a queue entry with # no receipt is a promise about a row that does not exist. defp insert_row(%Receipt{} = row, state, clock, forward?) do case Repo.transaction(fn -> insert_and_queue(row, forward?) end) do {:ok, receipt} -> - state = %{state | seq: receipt.seq, prev_hash: receipt.receipt_hash, clock: clock} + state = %{state | seq: receipt.seq, prev_link: link(receipt), clock: clock} {:ok, receipt, track_uncovered(receipt, state)} {:error, reason} -> @@ -376,21 +401,29 @@ defmodule Trinity.Receipts.ChainWriter do end end - defp sign_if_needed(kind, bytes) do - if Receipt.signed?(kind) do - case KeyCustody.sign(bytes) do - {:ok, sig} -> - # fix(s024) at slice 032: a signer that signs again clears the alarm, as Alarm's - # doc has said since 024; before this nothing did. - if Trinity.Receipts.Alarm.set?(), do: Trinity.Receipts.Alarm.clear() - {:ok, sig} + defp sign_if_needed(kind, body, selection) do + if Receipt.signed?(kind), do: sign_body(kind, body, selection), else: {:ok, nil} + end - {:error, reason} -> - Trinity.Receipts.Alarm.signer_unavailable(reason) - {:error, {:signer_unavailable, reason}} - end - else - {:ok, nil} + # The carrier for a body: its ToBeSigned signed by the key in custody, as the key's algorithm + # needs (`V4.carrier/4`). + defp sign_body(kind, body, %{n6_alg: alg, public: public}) do + protected = V4.protected(kind, alg, public) + + case KeyCustody.sign(COSE.to_be_signed(protected, body)) do + {:ok, sig} -> + # fix(s024) at slice 032: a signer that signs again clears the alarm, as Alarm's + # doc has said since 024; before this nothing did. + if Trinity.Receipts.Alarm.set?(), do: Trinity.Receipts.Alarm.clear() + + case V4.carrier(protected, body, alg, sig) do + {:ok, carrier} -> {:ok, carrier} + :error -> {:error, {:signer_unavailable, :signature_not_convertible}} + end + + {:error, reason} -> + Trinity.Receipts.Alarm.signer_unavailable(reason) + {:error, {:signer_unavailable, reason}} end end @@ -436,55 +469,50 @@ defmodule Trinity.Receipts.ChainWriter do defp write_checkpoint(%{uncovered_first: nil} = state, _reason), do: {:ok, nil, state} defp write_checkpoint(state, reason) do - with {:ok, %{scheme: scheme, key_id: key_id}} <- selection(), - {row, bytes} = build_checkpoint(state, reason, scheme, key_id), - {:ok, signature} <- sign_checkpoint(bytes) do - insert_checkpoint(%{row | signature: signature}, state) + with {:ok, selection} <- selection(), + {:ok, row} <- build_checkpoint(state, reason, selection), + {:ok, carrier} <- sign_body("checkpoint", row.body, selection), + {:ok, %{signature: signature}} <- Trinity.N6.Carrier.decode(carrier) do + # The signature column is NOT NULL from slice 024; a v4 checkpoint keeps its N6 signature + # there too, and the carrier is what a verifier reads. + insert_checkpoint(%{row | carrier: carrier, signature: signature}, state) end end - defp build_checkpoint(state, reason, scheme, key_id) do + defp build_checkpoint(state, reason, selection) do at = DateTime.utc_now() + boot = Trinity.Receipts.boot_hash() - body = %{ - "scheme" => scheme, - "chain_scope" => state.scope, - "boot_receipt_hash" => Trinity.Receipts.boot_hash(), - "first_seq" => state.uncovered_first, - "last_seq" => state.seq, - "tail_hash" => state.prev_hash, - "key_id" => key_id, - "reason" => reason, - "at" => DateTime.to_iso8601(at) - } - - payload = Envelope.canonical(body) - - row = %Checkpoint{ - chain_scope: state.scope, - boot_receipt_hash: body["boot_receipt_hash"], + fields = %{ + issuer: issuer(), first_seq: state.uncovered_first, last_seq: state.seq, - tail_hash: state.prev_hash, - scheme: scheme, - signed_payload: payload, - key_id: key_id, + chain_scope: state.scope, + tail: state.prev_link, + boot: boot, reason: reason, - inserted_at: at + at: DateTime.to_unix(at, :microsecond) } - {row, Envelope.pae(Envelope.checkpoint_type(scheme), payload)} - end - - defp sign_checkpoint(bytes) do - case KeyCustody.sign(bytes) do - {:ok, signature} -> - if Trinity.Receipts.Alarm.set?(), do: Trinity.Receipts.Alarm.clear() - {:ok, signature} - - {:error, why} -> - Trinity.Receipts.Alarm.signer_unavailable(why) - {:error, {:signer_unavailable, why}} + case V4.checkpoint_body(fields) do + {:ok, body} -> + {:ok, + %Checkpoint{ + chain_scope: state.scope, + boot_receipt_hash: boot, + first_seq: state.uncovered_first, + last_seq: state.seq, + tail_hash: V4.hex(state.prev_link), + scheme: selection.scheme, + signed_payload: "", + body: body, + key_id: selection.key_id, + reason: reason, + inserted_at: at + }} + + {:error, code} -> + {:error, {:body_not_n6, code}} end end @@ -505,6 +533,13 @@ defmodule Trinity.Receipts.ChainWriter do # none, and a chain that crosses the bump simply starts its clock at the first `v3` row. defp tail_clock(nil), do: nil + defp tail_clock(%Receipt{body: body, kind: kind}) when is_binary(body) do + case V4.read(kind, body) do + {:ok, %{19 => %{1 => w, 2 => c, 3 => {:text, n}}}} -> %Clock{wall: w, counter: c, node: n} + _ -> nil + end + end + defp tail_clock(%Receipt{signed_payload: payload}) do with {:ok, body} <- JSON.decode(payload), {:ok, clock} <- Clock.from_map(body["clock"]) do @@ -535,7 +570,7 @@ defmodule Trinity.Receipts.ChainWriter do %__MODULE__{ scope: scope, seq: (tail && tail.seq) || 0, - prev_hash: tail && tail.receipt_hash, + prev_link: tail && link(tail), clock: tail_clock(tail), uncovered_first: first, uncovered_count: count @@ -545,6 +580,12 @@ defmodule Trinity.Receipts.ChainWriter do defp check_tail(nil), do: :ok + defp check_tail(%Receipt{body: body} = tail) when is_binary(body) do + if V4.hex(V4.statement_id(tail.kind, body)) == tail.receipt_hash, + do: :ok, + else: {:error, {:tail_hash_mismatch, tail.seq}} + end + defp check_tail(%Receipt{} = tail) do bytes = Envelope.pae(Envelope.receipt_type(tail.scheme), tail.signed_payload) @@ -553,8 +594,42 @@ defmodule Trinity.Receipts.ChainWriter do else: {:error, {:tail_hash_mismatch, tail.seq}} end + # A row's link id: a v4 row's `statement_id` (its `receipt_hash`), or SHA-384 over a v2 or v3 row's + # PAE bytes. + defp link(%Receipt{body: body, receipt_hash: hash}) when is_binary(body), + do: Base.decode16!(hash, case: :lower) + + defp link(%Receipt{} = r), + do: V4.legacy_link(Envelope.pae(Envelope.receipt_type(r.scheme), r.signed_payload)) + defp check_checkpoint(nil, _scope), do: :ok + defp check_checkpoint(%Checkpoint{body: body} = cp, scope) when is_binary(body) do + row = Repo.one(from r in Receipt, where: r.chain_scope == ^scope and r.seq == ^cp.last_seq) + + with true <- row != nil || {:error, {:checkpoint_tail_not_in_chain, cp.last_seq}}, + {:ok, rows} <- KeyRegistry.read(keys_dir()) do + opts = [registry: rows, entry: KeyCustody.profile()] + + case V4.verify_checkpoint(Checkpoint.to_export(cp), link(row), opts) do + :ok -> + :ok + + {:error, 5, {:alg_unavailable, _at, alg}} -> + {:error, {:checkpoint_alg_unavailable, cp.last_seq, alg}} + + {:error, _exit, {:checkpoint_body_mismatch, _}} -> + {:error, {:checkpoint_tail_not_in_chain, cp.last_seq}} + + {:error, 1, {:n6, _at, :signature_invalid}} -> + {:error, {:checkpoint_signature_invalid, cp.last_seq}} + + {:error, _exit, reason} -> + {:error, {:checkpoint_invalid, cp.last_seq, reason}} + end + end + end + defp check_checkpoint(%Checkpoint{} = cp, scope) do row = Repo.one(from r in Receipt, where: r.chain_scope == ^scope and r.seq == ^cp.last_seq) @@ -588,14 +663,15 @@ defmodule Trinity.Receipts.ChainWriter do defp ok_or({:ok, v}, _), do: {:ok, v} defp ok_or(:error, err), do: err - defp public_key(key_id) do - dir = - case KeyCustody.selected() do - %{keys_dir: d} -> d - _ -> KeyCustody.keys_dir() - end + defp keys_dir do + case KeyCustody.selected() do + %{keys_dir: d} -> d + _ -> KeyCustody.keys_dir() + end + end - with {:ok, rows} <- KeyRegistry.read(dir), + defp public_key(key_id) do + with {:ok, rows} <- KeyRegistry.read(keys_dir()), %{} = row <- KeyRegistry.lookup(rows, key_id) || {:error, {:unknown_key_id, key_id}} do KeyRegistry.public_key(row) |> ok_or({:error, {:key_without_public, key_id}}) end diff --git a/lib/trinity/receipts/checkpoint.ex b/lib/trinity/receipts/checkpoint.ex index 5901133..93eb29d 100644 --- a/lib/trinity/receipts/checkpoint.ex +++ b/lib/trinity/receipts/checkpoint.ex @@ -6,6 +6,10 @@ defmodule Trinity.Receipts.Checkpoint do shape, research amendment D): which boot it belongs to, the first and last seq it covers, the tail hash at `last_seq`, and a signature over the PAE of its own canonical body. A row, never a write onto a receipt. + + Since slice 140 a checkpoint is a v4 statement (`Trinity.Receipts.V4`): `body` its N6 body, + `carrier` its single-signer carrier, `tail_hash` the tail row's link id in hex, and + `signed_payload` empty. Checkpoints written before keep their RFC 8785 body and DSSE signature. """ use Ecto.Schema @@ -24,6 +28,8 @@ defmodule Trinity.Receipts.Checkpoint do field :signature, :binary field :key_id, :string field :reason, :string + field :body, :binary + field :carrier, :binary field :inserted_at, :utc_datetime_usec end @@ -38,9 +44,11 @@ defmodule Trinity.Receipts.Checkpoint do "tail_hash" => c.tail_hash, "scheme" => c.scheme, "signed_payload" => c.signed_payload, - "signature_b64" => Base.encode64(c.signature), + "signature_b64" => c.signature && Base.encode64(c.signature), "key_id" => c.key_id, "reason" => c.reason } + |> Trinity.Receipts.Receipt.put_b64("body_b64", c.body) + |> Trinity.Receipts.Receipt.put_b64("carrier_b64", c.carrier) end end diff --git a/lib/trinity/receipts/key_custody.ex b/lib/trinity/receipts/key_custody.ex index 5ff8428..d412774 100644 --- a/lib/trinity/receipts/key_custody.ex +++ b/lib/trinity/receipts/key_custody.ex @@ -54,6 +54,8 @@ defmodule Trinity.Receipts.KeyCustody do algorithm: Signer.algorithm(), scheme: String.t(), key_id: String.t(), + public: binary(), + n6_alg: -50 | -19 | -51, key_path: Path.t(), keys_dir: Path.t(), impl: module(), @@ -94,11 +96,14 @@ defmodule Trinity.Receipts.KeyCustody do {:ok, algorithm} <- select(), impl = Signer.impl(algorithm), {:ok, key_id} <- ensure_key(dir, impl), - {:ok, key_id} <- maybe_migrate(dir, impl, key_id) do + {:ok, key_id} <- maybe_migrate(dir, impl, key_id), + {:ok, public} <- registered_public(dir, key_id) do selection = %{ algorithm: algorithm, scheme: impl.scheme(), key_id: key_id, + public: public, + n6_alg: Trinity.Receipts.V4.n6_alg(Atom.to_string(algorithm)), key_path: key_path(dir, algorithm), keys_dir: dir, impl: impl, @@ -114,6 +119,31 @@ defmodule Trinity.Receipts.KeyCustody do end end + # Slice 140: a v4 header names the key by its N6 thumbprint, recomputed from the registered public + # key, so the selection carries it. + defp registered_public(dir, key_id) do + with {:ok, rows} <- KeyRegistry.read(dir), + %{} = row <- KeyRegistry.lookup(rows, key_id) || {:error, {:key_not_in_registry, key_id}}, + {:ok, pub} <- KeyRegistry.public_key(row) do + {:ok, pub} + else + :error -> {:error, {:key_without_public, key_id}} + other -> other + end + end + + @doc """ + The N6 entry point receipts are written and verified under (slice 140): `config :trinity, + :receipts, profile:`, `:commercial` (the default) or `:nss`. + """ + @spec profile() :: :commercial | :nss + def profile do + case Application.get_env(:trinity, :receipts, [])[:profile] do + :nss -> :nss + _ -> :commercial + end + end + @doc "The selection made at boot, or `nil` before it, or `{:unavailable, reason}`." @spec selected() :: selection() | {:unavailable, term()} | nil def selected, do: :persistent_term.get(@key, nil) @@ -152,6 +182,16 @@ defmodule Trinity.Receipts.KeyCustody do true -> :ed25519 end + # Slice 140: the `nss` profile admits ML-DSA-87 (-50) and nothing else (N6 r3, C1, and the + # publisher's Decision of 2026-10-20: -19 and -51 are `commercial` only). Refused here, naming + # both, so no receipt is ever written that an `nss` verifier would refuse; a regulated boot then + # refuses, and a default one denies every effect. + if profile() == :nss and algorithm != :mldsa87, + do: {:error, {:receipts_profile_algorithm, :nss, algorithm}}, + else: available(algorithm) + end + + defp available(algorithm) do cond do # Slice 137: for ML-DSA-87 the reason names the known-answer step that failed, so a refused # regulated boot says which operation the runtime could not do. @@ -169,7 +209,10 @@ defmodule Trinity.Receipts.KeyCustody do end end - @doc "Signs the PAE bytes with the selected key, read from its file at this call." + @doc """ + Signs bytes with the selected key, read from its file at this call: a v4 statement's ToBeSigned + since slice 140, the PAE before it. + """ @spec sign(binary()) :: {:ok, binary()} | {:error, :signer_unavailable | term()} def sign(bytes) when is_binary(bytes) do case selected() do diff --git a/lib/trinity/receipts/merge.ex b/lib/trinity/receipts/merge.ex index f574689..f7eb563 100644 --- a/lib/trinity/receipts/merge.ex +++ b/lib/trinity/receipts/merge.ex @@ -140,6 +140,16 @@ defmodule Trinity.Receipts.Merge do defp device_of(nil), do: nil + # Slice 140: a v4 row's device is its body's clock node (key 19). + defp device_of(%{"body_b64" => b64, "kind" => kind}) when is_binary(b64) do + with {:ok, body} <- Base.decode64(b64), + {:ok, %{19 => %{3 => {:text, node}}}} <- Trinity.Receipts.V4.read(kind, body) do + node + else + _ -> nil + end + end + defp device_of(row) do with payload when is_binary(payload) <- row["signed_payload"], {:ok, body} <- JSON.decode(payload), diff --git a/lib/trinity/receipts/receipt.ex b/lib/trinity/receipts/receipt.ex index 0ce33e6..7701c2d 100644 --- a/lib/trinity/receipts/receipt.ex +++ b/lib/trinity/receipts/receipt.ex @@ -2,10 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.Receipts.Receipt do @moduledoc """ - One row of a receipt chain (slice 024, docs/05). `signed_payload` is the RFC 8785 body the - signature covers through the PAE; `receipt_hash` is SHA-256 over those PAE bytes and the - next row's `prev_hash`. `signature` is present for decision, effect, boot and cap receipts - and absent for query receipts, which a checkpoint covers. Never updated, never deleted. + One row of a receipt chain (slice 024, docs/05). Never updated, never deleted. + + A v2 or v3 row: `signed_payload` is the RFC 8785 body the signature covers through the PAE; + `receipt_hash` is SHA-256 over those PAE bytes and the next row's `prev_hash`; `signature` is + present for decision, effect, boot and cap receipts and absent for query receipts, which a + checkpoint covers. + + A v4 row (slice 140, `Trinity.Receipts.V4`): `body` is its N6 statement body, `receipt_hash` its + `statement_id` in hex, `prev_hash` the predecessor's link id in hex, `carrier` the single-signer + carrier for a signed kind (`signature` holds the same signature, though a verifier reads the + carrier), and `subject_bytes` and `decision_bytes` the exact RFC 8785 bytes its + ForeignRefs name. `signed_payload` is empty. The `subject` map is kept for the receipts page and + is never what a verifier reads. """ use Ecto.Schema @@ -26,6 +35,10 @@ defmodule Trinity.Receipts.Receipt do field :subject, :map, default: %{} field :subject_ref, :string field :meta, :map, default: %{} + field :body, :binary + field :carrier, :binary + field :subject_bytes, :binary + field :decision_bytes, :binary field :inserted_at, :utc_datetime_usec end @@ -55,5 +68,13 @@ defmodule Trinity.Receipts.Receipt do "key_id" => r.key_id, "meta" => r.meta } + |> put_b64("body_b64", r.body) + |> put_b64("carrier_b64", r.carrier) + |> put_b64("subject_b64", r.subject_bytes) + |> put_b64("decision_b64", r.decision_bytes) end + + @doc false + def put_b64(map, _key, nil), do: map + def put_b64(map, key, bytes), do: Map.put(map, key, Base.encode64(bytes)) end diff --git a/lib/trinity/receipts/signer.ex b/lib/trinity/receipts/signer.ex index 44b04e0..c4cf0f5 100644 --- a/lib/trinity/receipts/signer.ex +++ b/lib/trinity/receipts/signer.ex @@ -23,9 +23,10 @@ defmodule Trinity.Receipts.Signer do `receipt__`. Slice 026 moved the current version from `v2` to `v3`, because `v3` carries a hybrid logical - clock in the signed payload and `v2` does not. Both are accepted on read (`accepted_schemes/0`): - a chain that spans the bump verifies row by row under each row's own scheme, which is what makes - this a scheme bump rather than an edit to bytes already signed. + clock in the signed payload and `v2` does not. Slice 140 moved it to `v4`, an N6 statement in the + single-signer carrier (`Trinity.Receipts.V4`). Every version is accepted on read + (`accepted_schemes/0`): a chain that spans a bump verifies row by row under each row's own + scheme, which is what makes this a scheme bump rather than an edit to bytes already signed. """ @callback scheme() :: String.t() @@ -65,12 +66,13 @@ defmodule Trinity.Receipts.Signer do do: Map.fetch!(@implementations, algorithm) # Every scheme version this tree can read, oldest first. `v2` is slice 024's; `v3` is slice 026's - # and carries the clock. A version is removed from here only when no chain in the world still has - # a row of it, which is not a thing this project can know, so in practice it is never removed. - @scheme_versions ["v2", "v3"] + # and carries the clock; `v4` is slice 140's, N6. A version is removed from here only when no chain + # in the world still has a row of it, which is not a thing this project can know, so in practice + # it is never removed. + @scheme_versions ["v2", "v3", "v4"] # The version new rows are written under. - @current_scheme_version "v3" + @current_scheme_version "v4" @doc "The scheme versions this tree can verify, oldest first." @spec scheme_versions() :: [String.t()] @@ -108,6 +110,15 @@ defmodule Trinity.Receipts.Signer do def parse_scheme(_), do: :error + @doc "True when a scheme is an N6 statement (`v4`, slice 140), read by `Trinity.Receipts.V4`." + @spec n6?(String.t()) :: boolean() + def n6?(scheme) do + case parse_scheme(scheme) do + {:ok, version, _family} -> version >= "v4" + :error -> false + end + end + @doc """ True when a scheme's signed payload carries a hybrid logical clock. diff --git a/lib/trinity/receipts/signer/ed25519.ex b/lib/trinity/receipts/signer/ed25519.ex index 3722024..0dd707b 100644 --- a/lib/trinity/receipts/signer/ed25519.ex +++ b/lib/trinity/receipts/signer/ed25519.ex @@ -8,7 +8,7 @@ defmodule Trinity.Receipts.Signer.Ed25519 do def algorithm, do: :ed25519 @impl true - def scheme, do: "receipt_v3_ed25519" + def scheme, do: "receipt_v4_ed25519" @impl true def available?, do: :ed25519 in :crypto.supports(:curves) and :crypto.info_fips() != :enabled diff --git a/lib/trinity/receipts/signer/mldsa87.ex b/lib/trinity/receipts/signer/mldsa87.ex index 48a9212..4dad1b9 100644 --- a/lib/trinity/receipts/signer/mldsa87.ex +++ b/lib/trinity/receipts/signer/mldsa87.ex @@ -20,7 +20,7 @@ defmodule Trinity.Receipts.Signer.MLDSA87 do def algorithm, do: :mldsa87 @impl true - def scheme, do: "receipt_v3_mldsa87" + def scheme, do: "receipt_v4_mldsa87" # Slice 137, F-137-1. This was `:mldsa87 in :crypto.supports(:public_keys)`, which never holds in # FIPS mode: OTP lists no ML-DSA type there whatever the provider can do (`pkey.c`, diff --git a/lib/trinity/receipts/signer/p384.ex b/lib/trinity/receipts/signer/p384.ex index c86156d..4734ba5 100644 --- a/lib/trinity/receipts/signer/p384.ex +++ b/lib/trinity/receipts/signer/p384.ex @@ -12,7 +12,7 @@ defmodule Trinity.Receipts.Signer.P384 do def algorithm, do: :p384 @impl true - def scheme, do: "receipt_v3_p384" + def scheme, do: "receipt_v4_p384" @impl true def available?, do: :secp384r1 in :crypto.supports(:curves) diff --git a/lib/trinity/receipts/v4.ex b/lib/trinity/receipts/v4.ex new file mode 100644 index 0000000..92e22d7 --- /dev/null +++ b/lib/trinity/receipts/v4.ex @@ -0,0 +1,437 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.V4 do + @moduledoc """ + Receipts v4 (slice 140): every row and checkpoint is an N6 statement, signed rows and checkpoints + in the single-signer carrier, under the profile publisher's Decision of 2026-10-20 (17:24Z, and its + 17:27Z correction). + + ## Kinds, numbers and `typ` + + Six kinds in Trinity's provisional block 0x83, version 1: key 0 is + `(0x83 << 24) | (kind << 8) | 1` and the `typ` is + `application/vnd.example.b83.receipt-.v0+cose`, one `typ` per kind whatever the key's + algorithm, which the key's registration names (`kinds/0`, `typ/1`, `schema_number/1`). All of it is + provisional and re-issued under N21 at v1. + + ## The body + + Common keys: 1 `issuer` (required in every signed body, and written in every body), 7 `prev` (the + predecessor's link id as a Digest; absent only at seq 1). A row, 16 to 22: `seq`, `chain_scope`, + `at` (signed microseconds since the Unix epoch, UTC), `clock` (`{1: wall, 2: counter, 3: node}`), + `subject` and `decision` (ForeignRefs, below), `fingerprint`. A checkpoint, 16 to 23: `first_seq`, + `chain_scope`, `at`, `last_seq`, `tail` (the tail row's link id as a Digest), `boot` (the boot + receipt's `receipt_hash`, as text, since the boot chain may begin before v4), `reason`. + + These are exactly the fields a reader of a receipt judged before v4 (the verifier's body-to-column + check, the retired-key window, the merge's device), and what a checkpoint covers. Nothing else of + `subject` or `decision` is judged by anything, so both stay opaque. + + ## `subject` and `decision` + + Each is the RFC 8785 JSON of a map Trinity builds, carried as a ForeignRef + `{0: Digest, 1: length, 2: "application/json"}`, the bytes stored beside the row and verified as + stored, never re-encoded. No receipt carries a tool call's arguments or its result (those enter as + the approval fingerprint and `content_digest`); values of outside origin inside the maps are + decoded strings whose exact received bytes are kept nowhere, so the maps are content Trinity + originates, for which the Decision allows RFC 8785 (point 4; Trinity's correction of 2026-10-20 + 17:48Z asks the publisher to confirm). Bytes missing are `foreign_bytes_missing`, a finding; bytes + that do not match are `foreign_digest_mismatch`, invalid for the attachment and never for the + signed statement. + + ## Linking + + A row's link id is its `statement_id`, SHA-384 over `N6enc({0: 0xFFFF0002, 1: typ, 2: body})`, + the same whether or not the row is signed, and its `receipt_hash` column is that id in hex. The + first v4 row after a v2 or v3 row links to SHA-384 over that row's PAE bytes, the bytes its own + SHA-256 `receipt_hash` covers, because a Digest carries SHA-384 only (`legacy_link/1`). + + This module depends on `Trinity.N6` and nothing else, so `bin/verify_receipt.exs` loads it from + source beside the N6 files and runs the same rules as the in-app verifier. + """ + + alias Trinity.N6.{Body, Carrier, CBOR, COSE, Digest, EC2, Profile} + + @kinds %{ + "decision" => 1, + "effect" => 2, + "query" => 3, + "boot" => 4, + "cap" => 5, + "checkpoint" => 6 + } + @signed ~w(decision effect boot cap checkpoint) + @json "application/json" + @n6_alg %{"ed25519" => -19, "p384" => -51, "mldsa87" => -50} + + @foreign_ref {:map, + %{ + 0 => {:digest, :digest, :required}, + 1 => {:length, :uint, :required}, + 2 => {:media_type, :tstr, :required} + }} + + @row_fields %{ + 1 => {:issuer, :tstr, :required}, + 7 => {:prev, :digest, :optional}, + 16 => {:seq, :uint, :required}, + 17 => {:chain_scope, :tstr, :required}, + 18 => {:at, :int, :required}, + 19 => + {:clock, + {:map, + %{ + 1 => {:wall, :uint, :required}, + 2 => {:counter, :uint, :required}, + 3 => {:node, :tstr, :required} + }}, :required}, + 20 => {:subject, @foreign_ref, :required}, + 21 => {:decision, @foreign_ref, :optional}, + 22 => {:fingerprint, :tstr, :optional} + } + + @checkpoint_fields %{ + 1 => {:issuer, :tstr, :required}, + 16 => {:first_seq, :uint, :required}, + 17 => {:chain_scope, :tstr, :required}, + 18 => {:at, :int, :required}, + 20 => {:last_seq, :uint, :required}, + 21 => {:tail, :digest, :required}, + 22 => {:boot, :tstr, :optional}, + 23 => {:reason, :tstr, :required} + } + + @doc "The six kinds and their numbers in block 0x83." + @spec kinds() :: %{String.t() => pos_integer()} + def kinds, do: @kinds + + @doc "Key 0 of a kind's body." + @spec schema_number(String.t()) :: non_neg_integer() + def schema_number(kind), + do: Profile.statement_schema(Profile.trinity_block(), Map.fetch!(@kinds, kind), 1) + + @doc "The one `typ` of a kind." + @spec typ(String.t()) :: String.t() + def typ(kind) when is_map_key(@kinds, kind), + do: "application/vnd.example.b83.receipt-" <> kind <> ".v0+cose" + + @doc "The `typ` table every v4 verifier uses: one single-signer entry per signed kind." + @spec typ_table() :: COSE.typ_table() + def typ_table do + Map.new(@signed, fn kind -> + {typ(kind), + %{scitt: false, carrier: :single, signers: :single, schema: schema_number(kind)}} + end) + end + + @doc "The body schemas, as `Trinity.N6.Body` takes them." + @spec schemas() :: [Body.schema()] + def schemas do + for {kind, _} <- @kinds do + fields = if kind == "checkpoint", do: @checkpoint_fields, else: @row_fields + %{kind: body_kind(kind), version: schema_number(kind), fields: fields} + end + end + + defp body_kind(kind), do: "trinity.receipt." <> kind + + @doc "Whether a kind's statements are signed one by one (`query` rows are checkpointed)." + @spec signed?(String.t()) :: boolean() + def signed?(kind), do: kind in @signed + + @doc "The N6 algorithm of a registry row's algorithm name." + @spec n6_alg(String.t()) :: COSE.alg() + def n6_alg(name), do: Map.fetch!(@n6_alg, name) + + ## Building + + @doc """ + The body bytes of a row. `fields`: `issuer`, `seq`, `chain_scope`, `prev` (48 bytes or nil), `at` + (microseconds), `clock` (`{wall, counter, node}`), `subject` and `decision` (their RFC 8785 bytes, + `decision` nil when the row has none), `fingerprint` (or nil). + """ + @spec row_body(String.t(), map()) :: {:ok, binary()} | {:error, Profile.code()} + def row_body(kind, f) when kind != "checkpoint" do + {wall, counter, node} = f.clock + + body = + %{ + 0 => schema_number(kind), + 1 => {:text, f.issuer}, + 16 => f.seq, + 17 => {:text, f.chain_scope}, + 18 => f.at, + 19 => %{1 => wall, 2 => counter, 3 => {:text, node}}, + 20 => Digest.foreign_ref(f.subject, @json) + } + |> put_if(7, f.prev && Digest.of_value(f.prev)) + |> put_if(21, f.decision && Digest.foreign_ref(f.decision, @json)) + |> put_if(22, f.fingerprint && {:text, f.fingerprint}) + + Body.encode(body, body_kind(kind), schemas()) + end + + @doc """ + The body bytes of a checkpoint. `fields`: `issuer`, `first_seq`, `last_seq`, `chain_scope`, `tail` + (the tail row's link id, 48 bytes), `boot` (text or nil), `reason`, `at` (microseconds). + """ + @spec checkpoint_body(map()) :: {:ok, binary()} | {:error, Profile.code()} + def checkpoint_body(f) do + body = + %{ + 0 => schema_number("checkpoint"), + 1 => {:text, f.issuer}, + 16 => f.first_seq, + 17 => {:text, f.chain_scope}, + 18 => f.at, + 20 => f.last_seq, + 21 => Digest.of_value(f.tail), + 23 => {:text, f.reason} + } + |> put_if(22, f.boot && {:text, f.boot}) + + Body.encode(body, body_kind("checkpoint"), schemas()) + end + + defp put_if(map, _key, nil), do: map + defp put_if(map, _key, false), do: map + defp put_if(map, key, value), do: Map.put(map, key, value) + + @doc "A body's `statement_id`: SHA-384 over `N6enc({0: 0xFFFF0002, 1: typ, 2: body})`." + @spec statement_id(String.t(), binary()) :: binary() + def statement_id(kind, body) when is_binary(body) do + preimage = + CBOR.encode!(%{ + 0 => Profile.schema(:statement_id), + 1 => {:text, typ(kind)}, + 2 => {:bytes, body} + }) + + :crypto.hash(:sha384, preimage) + end + + @doc "The link id of a v2 or v3 row, as a v4 row names it: SHA-384 over its PAE bytes." + @spec legacy_link(binary()) :: binary() + def legacy_link(pae_bytes) when is_binary(pae_bytes), do: :crypto.hash(:sha384, pae_bytes) + + @doc """ + The protected header for a kind under a key: `kid` the key's thumbprint, `alg` its algorithm. + """ + @spec protected(String.t(), COSE.alg(), binary()) :: binary() + def protected(kind, alg, public), + do: COSE.protected_header(COSE.thumbprint(alg, public), typ(kind), nil, alg) + + @doc """ + The carrier for a body, given the signature over its ToBeSigned in the form the key's algorithm + returns from `:crypto` (DER for ESP384, converted here to `r || s`). + """ + @spec carrier(binary(), binary(), COSE.alg(), binary()) :: {:ok, binary()} | :error + def carrier(protected, body, alg, signature) do + with {:ok, signature} <- n6_signature(alg, signature) do + {:ok, Carrier.encode(%{protected: protected, payload: body, signature: signature})} + end + end + + defp n6_signature(-51, der), do: EC2.from_der(der) + defp n6_signature(_alg, signature), do: {:ok, signature} + + ## Verifying + + @doc """ + Verifies one v4 row of an export, given its predecessor's link id (nil at seq 1). `opts`: + `registry` (the export's rows), `entry` (`:commercial` or `:nss`), `issuer` (the issuer every + row must name, when the export names one). Answers the row's link id, its signed `at`, its + clock's node and any findings about its attachments, or `{:error, exit, reason}`. + """ + @spec verify_row(map(), binary() | nil, keyword()) :: + {:ok, %{link: binary(), at: integer(), node: String.t(), findings: [tuple()]}} + | {:error, 1 | 5, term()} + def verify_row(r, prev_link, opts) do + seq = r["seq"] + kind = r["kind"] + + with :ok <- expect(is_map_key(@kinds, kind) and kind != "checkpoint", 1, {:unknown_kind, seq}), + {:ok, body} <- b64(r["body_b64"], {:body_missing, seq}), + link = statement_id(kind, body), + :ok <- expect(hex(link) == r["receipt_hash"], 1, {:hash_mismatch, seq}), + {:ok, value} <- decode_body(body, kind, seq), + :ok <- columns(value, r, prev_link, seq), + :ok <- issuer(value, opts, seq), + :ok <- signature(kind, r, body, opts, seq) do + {:ok, + %{ + link: link, + at: value[18], + node: text(value[19][3]), + findings: attachments(value, r, seq) + }} + end + end + + defp decode_body(body, kind, seq) do + case Body.decode(body, body_kind(kind), schemas()) do + {:ok, value, _schema} -> {:ok, value} + {:error, code, _offset} -> {:error, 1, {:n6, seq, code}} + end + end + + # The columns a reader filters and sorts by must be what the signed body says. + defp columns(value, r, prev_link, seq) do + prev_ok = + case prev_link do + nil -> not is_map_key(value, 7) and r["prev_hash"] == nil + link -> value[7] == Digest.of_value(link) and r["prev_hash"] == hex(link) + end + + cond do + not prev_ok -> + {:error, 1, {:prev_hash_mismatch, seq}} + + value[16] != seq or text(value[17]) != r["chain_scope"] -> + {:error, 1, {:body_column_mismatch, seq}} + + true -> + :ok + end + end + + defp issuer(value, opts, seq) do + case Keyword.get(opts, :issuer) do + nil -> :ok + issuer -> expect(text(value[1]) == issuer, 1, {:issuer_mismatch, seq}) + end + end + + defp signature(kind, r, body, opts, at) do + case {signed?(kind), r["carrier_b64"]} do + {false, nil} -> :ok + {false, _carrier} -> {:error, 1, {:unexpected_signature, at}} + {true, nil} -> {:error, 1, {:signature_missing, at}} + {true, b64} -> verify_carrier(b64, body, r["key_id"], opts, at) + end + end + + defp verify_carrier(b64, body, key_id, opts, at) do + with {:ok, carrier} <- b64(b64, {:signature_undecodable, at}), + {:ok, parts} <- n6(Carrier.decode(carrier), at), + :ok <- expect(parts.payload == body, 1, {:carrier_payload_mismatch, at}), + {:ok, row} <- registry_row(opts, key_id, at) do + case Carrier.verify(carrier, keys: keys(row), typ: typ_table(), entry: entry(opts)) do + {:ok, _} -> :ok + {:error, 5, :alg_unavailable} -> {:error, 5, {:alg_unavailable, at, row["algorithm"]}} + {:error, exit, code} -> {:error, exit, {:n6, at, code}} + end + end + end + + defp n6({:ok, parts}, _at), do: {:ok, parts} + defp n6({:error, code}, at), do: {:error, 1, {:n6, at, code}} + + defp registry_row(opts, key_id, at) do + case opts + |> Keyword.get(:registry, []) + |> Enum.filter(&(&1["key_id"] == key_id)) + |> List.last() do + nil -> {:error, 5, {:unknown_key_id, at, key_id}} + row -> {:ok, row} + end + end + + # The registry row is the one the row's `key_id` column names; its `kid` is recomputed from the + # row's public key under the row's algorithm, so a header naming another key is unknown here. + defp keys(row) do + with {:ok, pub} <- b64(row["public_key_b64"], :no_public), + {:ok, alg} <- Map.fetch(@n6_alg, row["algorithm"]) do + key_for(thumbprint(alg, pub), %{alg: alg, public: pub}) + else + _ -> fn _ -> :error end + end + end + + defp key_for(kid, key), do: fn k -> if k == kid, do: {:ok, key}, else: :error end + + defp thumbprint(alg, pub) do + COSE.thumbprint(alg, pub) + rescue + FunctionClauseError -> nil + end + + defp entry(opts), do: Keyword.get(opts, :entry, :commercial) + + @doc "The findings about a row's attachments: none, or which bytes are missing or wrong." + @spec attachments(map(), map(), term()) :: [tuple()] + def attachments(value, r, seq) do + for {key, field, column} <- [{20, :subject, "subject_b64"}, {21, :decision, "decision_b64"}], + ref = value[key], + ref != nil, + finding = attachment(ref, r[column], field, seq), + finding != nil, + do: finding + end + + defp attachment(_ref, nil, field, seq), do: {:foreign_bytes_missing, seq, field} + + defp attachment(%{0 => digest, 1 => length}, b64, field, seq) do + case Base.decode64(b64) do + {:ok, bytes} when byte_size(bytes) == length -> + if Digest.matches?(digest, bytes), do: nil, else: {:foreign_digest_mismatch, seq, field} + + _ -> + {:foreign_digest_mismatch, seq, field} + end + end + + @doc """ + Verifies one v4 checkpoint, given the link id of the row at its `last_seq`. `opts` as + `verify_row/3`. + """ + @spec verify_checkpoint(map(), binary(), keyword()) :: :ok | {:error, 1 | 5, term()} + def verify_checkpoint(cp, tail_link, opts) do + last = cp["last_seq"] + at = {:checkpoint, last} + + with {:ok, body} <- b64(cp["body_b64"], {:checkpoint_body_missing, last}), + {:ok, value} <- decode_body(body, "checkpoint", at), + :ok <- + expect( + value[20] == last and value[16] == cp["first_seq"] and + text(value[17]) == cp["chain_scope"] and value[21] == Digest.of_value(tail_link) and + cp["tail_hash"] == hex(tail_link), + 1, + {:checkpoint_body_mismatch, last} + ), + :ok <- issuer(value, opts, at) do + signature("checkpoint", cp, body, opts, at) + end + end + + @doc "Reads a v4 body's signed `at` and clock node, for readers that only display them." + @spec read(String.t(), binary()) :: {:ok, map()} | :error + def read(kind, body) do + case Body.decode(body, body_kind(kind), schemas()) do + {:ok, value, _} -> {:ok, value} + _ -> :error + end + end + + ## Helpers + + defp b64(nil, reason), do: {:error, 1, reason} + + defp b64(s, reason) when is_binary(s) do + case Base.decode64(s) do + {:ok, bin} -> {:ok, bin} + :error -> {:error, 1, reason} + end + end + + defp text({:text, t}), do: t + defp text(_), do: nil + + @doc "Lowercase hex." + @spec hex(binary()) :: String.t() + def hex(bin), do: Base.encode16(bin, case: :lower) + + defp expect(true, _code, _reason), do: :ok + defp expect(false, code, reason), do: {:error, code, reason} +end diff --git a/lib/trinity/receipts/verifier.ex b/lib/trinity/receipts/verifier.ex index e07cdf3..6869197 100644 --- a/lib/trinity/receipts/verifier.ex +++ b/lib/trinity/receipts/verifier.ex @@ -22,16 +22,26 @@ defmodule Trinity.Receipts.Verifier do query receipt must be covered by a checkpoint whose tail hash is in the chain and whose signature verifies the same way; a covered range never has a gap. + Since slice 140 a row or checkpoint under a `v4` scheme is an N6 statement, checked by + `Trinity.Receipts.V4` with the same registry resolution first: its `receipt_hash` is its + `statement_id`, its `prev_hash` its predecessor's link id (a `v2` or `v3` predecessor's is SHA-384 + over its PAE bytes), its body is strictly decoded under its kind's schema and must say what the + columns say, and a signed kind's carrier verifies under the registered key at the entry point + (`entry:`, `:commercial` by default). The bytes of `subject` and `decision` are checked against the + ForeignRefs the signed body holds; a missing or mismatched attachment is a finding, returned beside + the verdict, and never changes it, because those bytes are not what the signature covers. + 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, 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} + alias Trinity.Receipts.{Envelope, Signer, V4} @type outcome :: - {:ok, %{receipts: non_neg_integer(), checkpoints: non_neg_integer()}} + {:ok, + %{receipts: non_neg_integer(), checkpoints: non_neg_integer(), findings: [tuple()]}} | {:error, code :: 1 | 5 | 6, reason :: term()} @signed_kinds ~w(decision effect boot cap) @@ -45,13 +55,15 @@ defmodule Trinity.Receipts.Verifier do Verifies an export map (`receipts`, `checkpoints`, `registry`). `opts`: `schemes:` the allowed scheme strings (all three by default); `require_coverage:` whether every query receipt needs a checkpoint (true by default; the writer covers the tail on shutdown and - rehydrate, so an export taken mid-window may carry uncovered rows and the caller says so). + rehydrate, so an export taken mid-window may carry uncovered rows and the caller says so); + `entry:` the N6 entry point for v4 rows (`:commercial` by default); `issuer:` the issuer every v4 + body must name (the export's `issuer` by default). """ @spec verify(map(), keyword()) :: outcome() def verify(export, opts \\ []) def verify( - %{"receipts" => receipts, "checkpoints" => checkpoints, "registry" => registry}, + %{"receipts" => receipts, "checkpoints" => checkpoints, "registry" => registry} = export, opts ) do schemes = @@ -59,10 +71,16 @@ defmodule Trinity.Receipts.Verifier do require_coverage = Keyword.get(opts, :require_coverage, true) - with :ok <- walk(receipts, registry, schemes), - :ok <- check_checkpoints(checkpoints, receipts, registry, schemes), + n6 = [ + registry: registry, + entry: Keyword.get(opts, :entry, :commercial), + issuer: Keyword.get(opts, :issuer, export["issuer"]) + ] + + with {:ok, findings} <- walk(receipts, registry, schemes, n6), + :ok <- check_checkpoints(checkpoints, receipts, registry, schemes, n6), :ok <- check_coverage(receipts, checkpoints, require_coverage) do - {:ok, %{receipts: length(receipts), checkpoints: length(checkpoints)}} + {:ok, %{receipts: length(receipts), checkpoints: length(checkpoints), findings: findings}} end end @@ -70,21 +88,77 @@ defmodule Trinity.Receipts.Verifier do ## The chain - defp walk(receipts, registry, schemes) do + defp walk(receipts, registry, schemes, n6) do receipts |> Enum.sort_by(& &1["seq"]) - |> Enum.reduce_while({:ok, 0, nil}, fn r, {:ok, prev_seq, prev_hash} -> - case check_receipt(r, prev_seq, prev_hash, registry, schemes) do - :ok -> {:cont, {:ok, r["seq"], r["receipt_hash"]}} + |> Enum.reduce_while({:ok, 0, nil, []}, fn r, {:ok, prev_seq, prev, findings} -> + case check_any(r, prev_seq, prev, registry, schemes, n6) do + {:ok, found} -> {:cont, {:ok, r["seq"], r, findings ++ found}} {:error, _, _} = e -> {:halt, e} end end) |> case do - {:ok, _, _} -> :ok + {:ok, _, _, findings} -> {:ok, findings} error -> error end end + # A v4 row is checked by `V4`, a v2 or v3 row as before. The predecessor is named by its link id + # (a v4 row) or by its `receipt_hash` (a v2 or v3 row); `link/1` gives the former for either. + defp check_any(r, prev_seq, prev, registry, schemes, n6) do + if Signer.n6?(r["scheme"] || "") do + check_v4(r, prev_seq, prev, registry, schemes, n6) + else + with :ok <- check_receipt(r, prev_seq, prev && prev["receipt_hash"], registry, schemes), + do: {:ok, []} + end + end + + defp check_v4(r, prev_seq, prev, registry, schemes, n6) do + seq = r["seq"] + + with :ok <- expect(seq == prev_seq + 1, 1, {:seq_gap, prev_seq, seq}), + {:ok, _impl, row} <- resolve(r["scheme"], r["key_id"], registry, schemes, seq), + {:ok, info} <- v4_row(r, prev, n6), + :ok <- within_validity_at(info.at, row, seq, r["key_id"]) do + {:ok, info.findings} + end + end + + # The same reason a v3 row's bad signature gives, so an operator reads one vocabulary. + defp v4_row(r, prev, n6) do + case V4.verify_row(r, prev && link(prev), n6) do + {:error, 1, {:n6, seq, :signature_invalid}} -> {:error, 1, {:signature_invalid, seq}} + other -> other + end + end + + @doc """ + A row's link id, as the next v4 row names it: a v4 row's `statement_id` (its `receipt_hash`), or + SHA-384 over a v2 or v3 row's PAE bytes. + """ + @spec link(map()) :: binary() + def link(r) do + if Signer.n6?(r["scheme"] || "") do + Base.decode16!(r["receipt_hash"], case: :lower) + else + V4.legacy_link(Envelope.pae(Envelope.receipt_type(r["scheme"]), r["signed_payload"])) + end + end + + # Slice 100's retired-key window, for a v4 row's signed `at` (microseconds since the epoch). + defp within_validity_at(at, %{"status" => "retired", "valid_until" => until}, seq, key_id) + when is_binary(until) and is_integer(at) do + with {:ok, until, _} <- DateTime.from_iso8601(until), + {:ok, at} <- DateTime.from_unix(at, :microsecond) do + expect(DateTime.compare(at, until) != :gt, 1, {:key_retired, seq, key_id}) + else + _ -> :ok + end + end + + defp within_validity_at(_at, _row, _seq, _key_id), do: :ok + defp check_receipt(r, prev_seq, prev_hash, registry, schemes) do seq = r["seq"] @@ -179,18 +253,46 @@ defmodule Trinity.Receipts.Verifier do ## Checkpoints - defp check_checkpoints(checkpoints, receipts, registry, schemes) do + defp check_checkpoints(checkpoints, receipts, registry, schemes, n6) do by_seq = Map.new(receipts, &{&1["seq"], &1}) Enum.reduce_while(checkpoints, :ok, fn cp, :ok -> - case check_checkpoint(cp, by_seq, registry, schemes) do + case check_checkpoint(cp, by_seq, registry, schemes, n6) do :ok -> {:cont, :ok} e -> {:halt, e} end end) end - defp check_checkpoint(cp, by_seq, registry, schemes) do + defp check_checkpoint(%{"body_b64" => _} = cp, by_seq, registry, schemes, n6) do + last = cp["last_seq"] + + with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}}, + :ok <- + expect( + is_integer(cp["first_seq"]) and cp["first_seq"] <= last, + 1, + {:checkpoint_range, last} + ), + {:ok, _impl, _row} <- + resolve(cp["scheme"], cp["key_id"], registry, schemes, {:checkpoint, last}) do + case V4.verify_checkpoint(cp, link(tail), n6) do + :ok -> + :ok + + {:error, 1, {:checkpoint_body_mismatch, _}} -> + {:error, 1, {:checkpoint_tail_mismatch, last}} + + {:error, 1, {:n6, at, :signature_invalid}} -> + {:error, 1, {:checkpoint_signature_invalid, elem(at, 1)}} + + error -> + error + end + end + end + + defp check_checkpoint(cp, by_seq, registry, schemes, _n6) do last = cp["last_seq"] with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}}, diff --git a/lib/trinity_web/live/receipts_live.ex b/lib/trinity_web/live/receipts_live.ex index 4cefaca..be2c47c 100644 --- a/lib/trinity_web/live/receipts_live.ex +++ b/lib/trinity_web/live/receipts_live.ex @@ -160,10 +160,12 @@ defmodule TrinityWeb.ReceiptsLive do
{subject(r)}
-
{decision(r)}
+
+ {decision(r)} +
- {if r.signature, do: gettext("signed"), else: gettext("checkpointed")} + {if r.signature || r.carrier, do: gettext("signed"), else: gettext("checkpointed")} {String.slice(r.receipt_hash, 0, 16)} @@ -250,6 +252,15 @@ defmodule TrinityWeb.ReceiptsLive do |> Enum.join(" · ") end + # Slice 140: a v4 row's decision is the stored bytes its ForeignRef names, shown as they are; a + # display, never a verdict, which the verifier gives. + defp decision(%{body: body, decision_bytes: bytes}) when is_binary(body) do + case bytes && JSON.decode(bytes) do + {:ok, d} when is_map(d) -> d |> Enum.map_join(" ", fn {k, v} -> "#{k}=#{value(v)}" end) + _ -> "" + end + end + defp decision(%{signed_payload: p}) do case JSON.decode(p) do {:ok, %{"decision" => d}} when is_map(d) -> diff --git a/priv/repo_receipts/migrations/20261020180000_receipts_v4_columns.exs b/priv/repo_receipts/migrations/20261020180000_receipts_v4_columns.exs new file mode 100644 index 0000000..d60ae30 --- /dev/null +++ b/priv/repo_receipts/migrations/20261020180000_receipts_v4_columns.exs @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Repo.Receipts.Migrations.ReceiptsV4Columns do + @moduledoc """ + Slice 140. Receipts v4 are N6 statements (`Trinity.Receipts.V4`): each row keeps its body, its + single-signer carrier when its kind is signed, and the exact bytes its two ForeignRefs name, as + bytes, never as a re-serialising JSON column. Checkpoints keep their body and carrier. + + Every column is nullable: v2 and v3 rows have none of them and are never rewritten. A v4 row's + `signed_payload` is the empty string, because the column is NOT NULL and SQLite cannot relax that + in place. + """ + use Ecto.Migration + + def change do + alter table(:receipts) do + add :body, :binary + add :carrier, :binary + add :subject_bytes, :binary + add :decision_bytes, :binary + end + + alter table(:receipt_checkpoints) do + add :body, :binary + add :carrier, :binary + end + end +end diff --git a/scripts/n6_vectors.exs b/scripts/n6_vectors.exs index 5601f92..15394c9 100644 --- a/scripts/n6_vectors.exs +++ b/scripts/n6_vectors.exs @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 # -# Writes Trinity's provisional N6 vectors (slice 138) into the directory given, in the layout of the +# Writes Trinity's provisional N6 vectors (slices 138 to 140) into the directory given, in the layout of the # N6 profile v0 draft r3, section 10: # # elixir scripts/n6_vectors.exs test/fixtures/n6 @@ -19,10 +19,18 @@ 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. + # Slice 139: the `typ` its Ed25519 (-19) samples use on the `commercial` profile, and one whose entry + # needs two signers, which a single-signer carrier must refuse. Since slice 140 a `typ` names no + # algorithm (the publisher's Decision of 2026-10-20, point 3): the key's registration does. The + # names are only names. @typ_ed "application/vnd.example.test-ed25519.v1+cose" @typ_twin "application/vnd.example.twin-test.v1+cose" + # Slice 140: the `typ` the ESP384 (-51) samples use. + @typ_esp "application/vnd.example.test-esp384.v1+cose" + + # Slice 140: every full-payload single-signer body carries key 1, `issuer` (the publisher's Decision + # of 2026-10-20, point 3), here under the RFC 6761 `.invalid` name. + @issuer "trinity:test.invalid" # RFC 8032 section 7.1, test 1: a published Ed25519 key, so no key of this tree's is published. @ed_pub "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a" @@ -30,11 +38,32 @@ defmodule N6Vectors do # 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. + # mode). The suite checks it signs and verifies wherever the runtime has Ed25519. `@ed_sig` is + # slice 139's, over the body {0: 1, 16: "slice 139"}, which has no `issuer`; since slice 140 that + # sample is `issuer_missing`. `@ed_sig_issuer` is over the same body with key 1, `@ed_sig_schema_2` + # over it with key 0 = 2. @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). + @ed_sig_issuer "ff0ca9413ae325a29a74f71ff62f8cc8d3197d2aa715ac1e43aa74c112c0d51b5325be34e6c097d5e120f9646fdaf7f0c40739e3750a5b49275f2d04bcc07d00" + @ed_sig_schema_2 "23b8e030b47818e0d28cad5805c155bed332b4fcb9169001acc38f3868278fb5ad4600d75f1ff50f2f693668860ef507ce1ee81c7a8fe9ae46eb83d083649904" + + # Slice 140, ESP384: RFC 6979 appendix A.2.6's published P-384 key (private + # 6b9d3dad...60d2edf5), its uncompressed public point, and one signature it made, as the 96-byte + # r || s, over the ESP384 sample's ToBeSigned. ECDSA here is randomised, so this is one valid + # signature of many, fixed as a literal; `n - s` is the other the samples use. The same signature as + # :crypto's DER (103 bytes) is the `signature_encoding` sample. + @ec_pub "04ec3a4e415b4e19a4568618029f427fa5da9a8bc4ae92e02e06aae5286b300c64def8f0ea9055866064a254515480bc138015d9b72d7d57244ea8ef9ac0c621896708a59367f9dfb9f54ca84b3f1c9db1288b231c3ae0d4fe7344fd2533264720" + @esp_sig "bed4807824fa21510c61d976ae13784810bbeb7dffd42d4ac341417bb832abed12514db5fdf349d981fea339633e97e1740e49429ab74ee078734ba75bcb3eee60de1507f3754b764cb820f3eae1102b7369d2d1bf0ecd6d39bac148aec3bb6b" + @esp_der "3065023100bed4807824fa21510c61d976ae13784810bbeb7dffd42d4ac341417bb832abed12514db5fdf349d981fea339633e97e10230740e49429ab74ee078734ba75bcb3eee60de1507f3754b764cb820f3eae1102b7369d2d1bf0ecd6d39bac148aec3bb6b" + # A P-384 public key whose x begins with a zero byte, generated for the thumbprint vector only (no + # private key is kept): a thumbprint that dropped the leading zero would give another kid. + @ec_pub_leading_zero "0400edc54551ef7c1c37c526427a30628bb4495fdd9721418fe5bc36358e0e3c67dabd04b38ffc8b355fd6f0af9f04c51f07fce50fca9d02d204ecba8bf37aee11fb29954efdd36a3ea368754a860ade1cf280b879084a3bbff3a15529f975f8d3" + # RFC 6979 appendix A.2.5's P-256 public key: registered as ESP384, a key on the wrong curve. + @p256_pub "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299" + # The P-384 group order, for `n - s`. + @p384_n 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973 + + # The single-signer carrier's schema number, 0xFFFF0003: a placeholder at slice 139, assigned by + # the publisher's Decision of 2026-10-20 (point 2). @single_schema "1a ff ff 00 03" # FIPS 180-2, Appendix D.1: SHA-384("abc"). @@ -51,11 +80,15 @@ defmodule N6Vectors do 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. + # bytewise order 0x01, 0x20, 0x21, a 32-byte string's head being 58 20. No `alg`: the key's + # registration names it. ed_pub = h(@ed_pub) ed_kid = :crypto.hash(:sha384, h("a3 01 01 20 06 21 58 20") <> ed_pub) + # RFC 9679 over the EC2 members of RFC 9053 section 7.1: {1: 2, -1: 2, -2: x, -3: y}, keys in + # bytewise order 0x01, 0x20, 0x21, 0x22, each coordinate exactly 48 bytes (58 30): 107 bytes. + ec_kid = ec2_kid(h(@ec_pub)) + File.rm_rf!(root) for {id, hex, desc, source} <- encoding_positive(), do: positive(root, "encoding", id, h(hex), desc, source) @@ -64,7 +97,7 @@ 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, ed_kid) + header = header_vectors(kid, ed_kid, ec_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())) @@ -73,9 +106,19 @@ defmodule N6Vectors do 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) + write(root, "thumbprint/rfc6979_a26_p384.pub", h(@ec_pub)) + write(root, "thumbprint/rfc6979_a26_p384.kid", ec_kid) + write(root, "thumbprint/p384_x_leading_zero.pub", h(@ec_pub_leading_zero)) + write(root, "thumbprint/p384_x_leading_zero.kid", ec2_kid(h(@ec_pub_leading_zero))) - for {id, bytes, exit, code, entry} <- sign1_samples(kid, ed_kid), - do: sample(root, id, bytes, exit, code, entry) + # Keys a sample's context registers on purpose wrongly: the RFC 6979 key compressed, and a P-256 + # point (RFC 6979 appendix A.2.5's public key), each registered as ESP384. + <<4, ec_x::binary-size(48), ec_y::binary-size(48)>> = h(@ec_pub) + write(root, "thumbprint/rfc6979_a26_p384_compressed.pub", <<2 + rem(:binary.last(ec_y), 2)>> <> ec_x) + write(root, "thumbprint/rfc6979_a25_p256.pub", h(@p256_pub)) + + for {id, bytes, exit, code, entry, keys} <- sign1_samples(kid, ed_kid, ec_kid), + do: sample(root, id, bytes, exit, code, entry, keys) abc = "abc" digest = :crypto.hash(:sha384, abc) @@ -89,8 +132,8 @@ 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 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(root, "VERSION", "n6/v0 draft r3 and the publisher's answers of 2026-10-19 and 2026-10-20 PROVISIONAL (Trinity candidate set)\n") + write(root, "README.md", readme(header.negative, sign1_samples(kid, ed_kid, ec_kid))) write_sums(root) end @@ -120,15 +163,18 @@ 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). 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. + table (the draft leaves both Open, VB6 and VB4). Each `typ` names its carrier, its signers and, + where the table says, the schema number its body's key 0 must be; no `typ` names an algorithm, + which the key's registration does (the publisher's Decision of 2026-10-20). `thumbprint/` uses + NIST ACVP's published ML-DSA-87 keyGen case (tgId 3, tcId 51), RFC 8032's Ed25519 test 1 key, + RFC 6979's P-384 key (A.2.6) and a P-384 public key whose `x` begins with a zero byte; 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. + leaves `ctx.json` Open, VB5). `SINGLE_SCHEMA` is 0xFFFF0003, as the publisher assigned it. The + signed samples are Ed25519, which is deterministic, and ESP384 with one fixed signature and its + `n - s`; 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 @@ -159,7 +205,7 @@ defmodule N6Vectors do | Sample | Entry point | Exit | Expected code | | -- | -- | -- | -- | - #{Enum.map_join(samples, "\n", fn {id, _, exit, code, entry} -> "| `#{id}` | `#{entry}` | #{exit} | `#{code}` |" end)} + #{Enum.map_join(samples, "\n", fn {id, _, exit, code, entry, _keys} -> "| `#{id}` | `#{entry}` | #{exit} | `#{code}` |" end)} """ end @@ -353,7 +399,12 @@ defmodule N6Vectors do {"negative_schema_version", "minus_1", "a2 00 20 10 61 78", "key_not_uint"}, {"carrier_schema_key0", "envelope_schema", "a2 00 1a ff ff 00 01 10 61 78", "schema_reserved"}, {"carrier_schema_key0", "top_of_range", "a2 00 1a ff ff ff ff 10 61 78", "schema_reserved"}, + # Slice 140, the publisher's order of 2026-10-20 17:27Z: a number in the shared test block is + # refused as such outside test mode, before `unknown_schema` could answer for it. + {"test_schema_outside_test_mode", "block_0x80", "a2 00 1a 80 00 01 01 10 61 78", "test_schema_outside_test_mode"}, {"unknown_schema", "version_2", "a2 00 02 10 61 78", "unknown_schema"}, + {"unknown_schema", "reserved_block_0x90", "a2 00 1a 90 00 00 00 10 61 78", "unknown_schema"}, + {"unknown_schema", "trinity_block_unissued", "a2 00 1a 83 00 ff 01 10 61 78", "unknown_schema"}, {"unknown_key", "key_19", "a3 00 01 10 61 78 13 00", "unknown_key"}, {"required_key_missing", "no_name", "a1 00 01", "required_key_missing"}, {"wrong_type", "name_as_uint", "a2 00 01 10 01", "wrong_type"}, @@ -371,15 +422,16 @@ 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, "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} + %{"typ" => @typ, "carrier" => "single", "signers" => "single", "scitt" => false}, + %{"typ" => @typ_scitt, "carrier" => "single", "signers" => "single", "scitt" => true}, + %{"typ" => @typ_ed, "carrier" => "single", "signers" => "single", "scitt" => false, "schema" => 1}, + %{"typ" => @typ_esp, "carrier" => "single", "signers" => "single", "scitt" => false, "schema" => 1}, + %{"typ" => @typ_twin, "carrier" => "envelope", "signers" => "pair", "scitt" => false} ] } end - defp header_vectors(kid, ed_kid) do + defp header_vectors(kid, ed_kid, ec_kid) do alg = h("01 38 31") kid_pair = h("04 58 30") <> kid typ = h("10 78 24") <> @typ @@ -388,17 +440,26 @@ defmodule N6Vectors do typ_ed = h("10 78 2c") <> @typ_ed typ_scitt = h("10 78 2a") <> @typ_scitt typ_other = h("10 78 25") <> @typ_other + esp_alg = h("01 38 32") + ec_kid_pair = h("04 58 30") <> ec_kid + typ_esp = h("10 78 2b") <> @typ_esp cwt = h("0f a2 01 63") <> "iss" <> h("02 63") <> "sub" %{ positive: [ {"alg_kid_typ", h("a3") <> alg <> kid_pair <> typ}, {"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} + {"ed25519_alg_kid_typ", h("a3") <> ed_alg <> ed_kid_pair <> typ_ed}, + # Slice 140: since a `typ` names no algorithm, these decode at `commercial` (slice 139 had them + # as `alg_not_bound_to_typ`); the key's registration decides, in the cose samples. + {"ed25519_alg_under_any_typ", h("a3") <> ed_alg <> ed_kid_pair <> typ}, + {"esp384_alg_kid_typ", h("a3") <> esp_alg <> ec_kid_pair <> typ_esp} ], negative: [ {"alg_not_mldsa87", "mldsa65", h("a3 01 38 30") <> kid_pair <> typ, "alg_not_permitted"}, {"alg_not_mldsa87", "eddsa", h("a3 01 27") <> kid_pair <> typ, "alg_not_permitted"}, + # Slice 140: ES384 (-35), the polymorphic identifier RFC 9864 deprecates, is not ESP384. + {"alg_not_mldsa87", "es384", h("a3 01 38 22") <> ec_kid_pair <> typ_esp, "alg_not_permitted"}, {"content_type", "label_3", h("a4") <> alg <> h("03 61 78") <> kid_pair <> typ, "content_type_forbidden"}, {"crit", "label_2", h("a4") <> alg <> h("02 81 01") <> kid_pair <> typ, "header_label_forbidden"}, {"label_0", "label_0", h("a4 00 00") <> alg <> kid_pair <> typ, "header_label_forbidden"}, @@ -414,65 +475,103 @@ 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"}, - # 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"} + {"header_noncanonical", "trailing_byte", h("a3") <> alg <> kid_pair <> typ <> h("00"), "header_noncanonical"} ] } end - ## Single-signer carriers (slice 139) + ## Single-signer carriers (slices 139 and 140) - # {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 + # {id, carrier bytes, exit, verdict code, entry point, keys}. `keys` is what the sample's context + # registers: nil for the three published keys, each under its own algorithm. Every signature is over + # ["Signature1", protected, h'', payload], RFC 9052 section 4.4. The Ed25519 payload is the N6 body + # {0: 1, 1: "trinity:test.invalid", 16: "slice 139"}, the ESP384 payload the same with "slice 140". + defp sign1_samples(kid, ed_kid, ec_kid) do + payload = body(h("01"), "slice 139") + payload_field = h("01 58 24") <> payload protected = h("a3 01 32 04 58 30") <> ed_kid <> h("10 78 2c") <> @typ_ed - sig = h(@ed_sig) + sig = h(@ed_sig_issuer) sign1 = h("84 58 65") <> protected <> h("a0 f6 58 40") <> sig good = h("a3 00") <> h(@single_schema) <> payload_field <> h("02") <> sign1 + carrier = fn payload_field, sign1 -> h("a3 00") <> h(@single_schema) <> payload_field <> h("02") <> sign1 end + ed_sign1 = fn sig -> h("84 58 65") <> protected <> h("a0 f6 58 40") <> sig end 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) + # Slice 139's body, without `issuer`, under its own literal signature; and the body with key 0 = 2. + no_issuer = h("01 4e a2 00 01 10 69") <> "slice 139" + schema_2 = h("01 58 24") <> body(h("02"), "slice 139") + + esp_payload_field = h("01 58 24") <> body(h("01"), "slice 140") + esp_protected = h("a3 01 38 32 04 58 30") <> ec_kid <> h("10 78 2b") <> @typ_esp + esp = fn sig_field -> carrier.(esp_payload_field, h("84 58 65") <> esp_protected <> h("a0 f6") <> sig_field) end + esp_sig = h(@esp_sig) + <> = esp_sig + n_minus_s = r <> <<@p384_n - s::unsigned-size(384)>> + ec_kid_hex = Base.encode16(ec_kid, case: :lower) + ed_kid_hex = Base.encode16(ed_kid, case: :lower) + [ - {"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"}, + {"ed25519_single", good, 0, "claim", "commercial", nil}, + {"ed25519_single_at_nss", good, 1, "alg_not_permitted", "nss", nil}, + {"ed25519_signature_flipped", carrier.(payload_field, ed_sign1.(flipped)), 1, "signature_invalid", "commercial", nil}, + {"ed25519_no_issuer", carrier.(no_issuer, ed_sign1.(h(@ed_sig))), 1, "issuer_missing", "commercial", nil}, + {"ed25519_typ_schema_mismatch", carrier.(schema_2, ed_sign1.(h(@ed_sig_schema_2))), 1, "typ_schema_mismatch", "commercial", nil}, + {"ed25519_key_registered_as_mldsa87", good, 1, "alg_not_permitted", "commercial", + [%{"alg" => -50, "public" => "thumbprint/rfc8032_test1_ed25519.pub", "kid" => ed_kid_hex}]}, + {"carrier_key0_envelope", h("a3 00 1a ff ff 00 01") <> payload_field <> h("02") <> sign1, 1, "carrier_malformed", "commercial", nil}, + {"carrier_extra_key", h("a4 00") <> h(@single_schema) <> payload_field <> h("02") <> sign1 <> h("03 00"), 1, "carrier_malformed", "commercial", nil}, + {"carrier_no_sign1", h("a2 00") <> h(@single_schema) <> payload_field, 1, "carrier_malformed", "commercial", nil}, + {"carrier_payload_text", h("a3 00") <> h(@single_schema) <> h("01 69") <> "slice 139" <> h("02") <> sign1, 1, "carrier_malformed", "commercial", nil}, + {"carrier_not_map", h("83") <> h(@single_schema) <> h("58 24") <> payload <> sign1, 1, "carrier_malformed", "commercial", nil}, + {"carrier_keys_out_of_order", h("a3 00") <> h(@single_schema) <> h("02") <> sign1 <> payload_field, 1, "noncanonical", "commercial", nil}, {"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"}, + carrier.(payload_field, h("84 58 65") <> protected <> h("a0 58 24") <> payload <> h("58 40") <> sig), + 1, "carrier_malformed", "commercial", nil}, + {"sign1_tagged", carrier.(payload_field, h("d2") <> sign1), 1, "tag_forbidden", "commercial", nil}, {"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"} + carrier.(payload_field, h("84 58 65") <> protected <> h("a1 04 41 00 f6 58 40") <> sig), + 1, "header_not_protected", "commercial", nil}, + {"pair_typ_in_single_carrier", carrier.(payload_field, twin_sign1), 1, "wrong_carrier_for_typ", "commercial", nil}, + # Slice 140: ESP384 (-51), the publisher's Decision of 2026-10-20, point 1. + {"esp384_single", esp.(h("58 60") <> esp_sig), 0, "claim", "commercial", nil}, + {"esp384_n_minus_s", esp.(h("58 60") <> n_minus_s), 0, "claim", "commercial", nil}, + {"esp384_at_nss", esp.(h("58 60") <> esp_sig), 1, "alg_not_permitted", "nss", nil}, + {"esp384_signature_der", esp.(h("58 67") <> h(@esp_der)), 1, "signature_encoding", "commercial", nil}, + {"esp384_signature_95_bytes", esp.(h("58 5f") <> binary_part(esp_sig, 0, 95)), 1, "signature_encoding", "commercial", nil}, + {"esp384_signature_97_bytes", esp.(h("58 61") <> esp_sig <> h("00")), 1, "signature_encoding", "commercial", nil}, + {"esp384_signature_flipped", esp.(h("58 60") <> binary_part(esp_sig, 0, 95) <> <<:binary.last(esp_sig) |> Bitwise.bxor(1)>>), 1, "signature_invalid", "commercial", nil}, + {"esp384_key_compressed", esp.(h("58 60") <> esp_sig), 1, "key_invalid", "commercial", + [%{"alg" => -51, "public" => "thumbprint/rfc6979_a26_p384_compressed.pub", "kid" => ec_kid_hex}]}, + {"esp384_key_p256", esp.(h("58 60") <> esp_sig), 1, "key_invalid", "commercial", + [%{"alg" => -51, "public" => "thumbprint/rfc6979_a25_p256.pub", "kid" => ec_kid_hex}]}, + {"esp384_key_registered_as_ed25519", esp.(h("58 60") <> esp_sig), 1, "alg_not_permitted", "commercial", + [%{"alg" => -19, "public" => "thumbprint/rfc6979_a26_p384.pub", "kid" => ec_kid_hex}]} ] end - defp sample(root, id, bytes, exit, code, entry) do + # {0: key0, 1: "trinity:test.invalid", 16: text}, 36 bytes for a 9-byte text. + defp body(key0, text), do: h("a3 00") <> key0 <> h("01 74") <> @issuer <> h("10 69") <> text + + defp ec2_kid(<<4, x::binary-size(48), y::binary-size(48)>>), + do: :crypto.hash(:sha384, h("a4 01 02 20 02 21 58 30") <> x <> h("22 58 30") <> y) + + @default_keys [ + %{"alg" => -19, "public" => "thumbprint/rfc8032_test1_ed25519.pub"}, + %{"alg" => -50, "public" => "thumbprint/acvp_mldsa87_tg3_tc51.pub"}, + %{"alg" => -51, "public" => "thumbprint/rfc6979_a26_p384.pub"} + ] + + defp sample(root, id, bytes, exit, code, entry, keys) 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"} - ] + "keys" => keys || @default_keys } write(root, "cose/sign1/#{id}.ctx.json", json(ctx)) diff --git a/test/fips/receipts_test.exs b/test/fips/receipts_test.exs index c0c5e57..13dd8c0 100644 --- a/test/fips/receipts_test.exs +++ b/test/fips/receipts_test.exs @@ -5,12 +5,16 @@ defmodule Trinity.Fips.ReceiptsTest do Slice 024 AC8, the FIPS half, on slice 003's leg: with FIPS mode enabled and P-384 available, no effect is denied for want of a signer, and the boot receipt names P-384. Tagged `:fips`: included where `TRINITY_FIPS_LEG=1`, excluded by tag elsewhere. + + Slice 140, AC2 and AC6 on the leg: the rows are v4 N6 statements signed with ESP384 (-51), the + 96-byte `r || s` in the single-signer carrier, and they verify in the mode. """ use Trinity.DataCase, async: false @moduletag :fips alias Trinity.Effects + alias Trinity.N6.Carrier alias Trinity.Permissions alias Trinity.Receipts alias Trinity.Receipts.{KeyCustody, Signer, Verifier} @@ -23,13 +27,13 @@ defmodule Trinity.Fips.ReceiptsTest do refute Signer.Ed25519.available?() assert Signer.P384.available?() assert {:ok, :p384} = KeyCustody.select() - assert %{algorithm: :p384, scheme: "receipt_v3_p384"} = KeyCustody.selected() + assert %{algorithm: :p384, scheme: "receipt_v4_p384", n6_alg: -51} = KeyCustody.selected() boot = Receipts.boot_receipt() assert boot.subject["signer"]["algorithm"] == "p384" - assert boot.subject["signer"]["scheme"] == "receipt_v3_p384" + assert boot.subject["signer"]["scheme"] == "receipt_v4_p384" assert boot.subject["fips"] == "enabled" - assert boot.scheme == "receipt_v3_p384" + assert boot.scheme == "receipt_v4_p384" end test "no effect is denied for want of a signer: an effect runs, its receipts are P-384 and verify" do @@ -51,13 +55,21 @@ defmodule Trinity.Fips.ReceiptsTest do rows = Receipts.list(scope) assert Enum.map(rows, & &1.kind) == ["decision", "effect", "effect"] - assert Enum.all?(rows, &(&1.scheme == "receipt_v3_p384" and is_binary(&1.signature))) + assert Enum.all?(rows, &(&1.scheme == "receipt_v4_p384" and is_binary(&1.carrier))) + + for row <- rows do + {:ok, parts} = Carrier.decode(row.carrier) + {:ok, %{1 => alg}} = Trinity.N6.CBOR.decode(parts.protected) + assert alg == -51 and byte_size(parts.signature) == 96 + end + + IO.puts("fips/receipts_test: v4 rows signed with ESP384 (-51), fips #{:crypto.info_fips()}") :ok = Receipts.stop_writer(scope) {:ok, export} = Receipts.export(scope) assert {:ok, %{receipts: 3}} = Verifier.verify(export) # And an Ed25519-only verifier refuses the chain at the scheme string, not at a signature. - assert {:error, 1, {:scheme_not_allowed, 1, "receipt_v3_p384"}} = - Verifier.verify(export, schemes: ["receipt_v3_ed25519"]) + assert {:error, 1, {:scheme_not_allowed, 1, "receipt_v4_p384"}} = + Verifier.verify(export, schemes: ["receipt_v4_ed25519"]) end end diff --git a/test/fixtures/n6/README.md b/test/fixtures/n6/README.md index 7d58815..a16f86a 100644 --- a/test/fixtures/n6/README.md +++ b/test/fixtures/n6/README.md @@ -14,15 +14,18 @@ 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). 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. + table (the draft leaves both Open, VB6 and VB4). Each `typ` names its carrier, its signers and, + where the table says, the schema number its body's key 0 must be; no `typ` names an algorithm, + which the key's registration does (the publisher's Decision of 2026-10-20). `thumbprint/` uses + NIST ACVP's published ML-DSA-87 keyGen case (tgId 3, tcId 51), RFC 8032's Ed25519 test 1 key, + RFC 6979's P-384 key (A.2.6) and a P-384 public key whose `x` begins with a zero byte; 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. + leaves `ctx.json` Open, VB5). `SINGLE_SCHEMA` is 0xFFFF0003, as the publisher assigned it. The + signed samples are Ed25519, which is deterministic, and ESP384 with one fixed signature and its + `n - s`; 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 @@ -66,6 +69,7 @@ profile's own vectors. These are candidate input, not the published set. | `bool_schema_version` | `key_not_uint` | | `negative_schema_version` | `key_not_uint` | | `carrier_schema_key0` | `schema_reserved` | +| `test_schema_outside_test_mode` | `test_schema_outside_test_mode` | | `unknown_schema` | `unknown_schema` | | `unknown_key` | `unknown_key` | | `required_key_missing` | `required_key_missing` | @@ -93,7 +97,6 @@ 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 @@ -110,6 +113,9 @@ profile's own vectors. These are candidate input, not the published set. | `ed25519_single` | `commercial` | 0 | `claim` | | `ed25519_single_at_nss` | `nss` | 1 | `alg_not_permitted` | | `ed25519_signature_flipped` | `commercial` | 1 | `signature_invalid` | +| `ed25519_no_issuer` | `commercial` | 1 | `issuer_missing` | +| `ed25519_typ_schema_mismatch` | `commercial` | 1 | `typ_schema_mismatch` | +| `ed25519_key_registered_as_mldsa87` | `commercial` | 1 | `alg_not_permitted` | | `carrier_key0_envelope` | `commercial` | 1 | `carrier_malformed` | | `carrier_extra_key` | `commercial` | 1 | `carrier_malformed` | | `carrier_no_sign1` | `commercial` | 1 | `carrier_malformed` | @@ -120,3 +126,13 @@ profile's own vectors. These are candidate input, not the published set. | `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` | +| `esp384_single` | `commercial` | 0 | `claim` | +| `esp384_n_minus_s` | `commercial` | 0 | `claim` | +| `esp384_at_nss` | `nss` | 1 | `alg_not_permitted` | +| `esp384_signature_der` | `commercial` | 1 | `signature_encoding` | +| `esp384_signature_95_bytes` | `commercial` | 1 | `signature_encoding` | +| `esp384_signature_97_bytes` | `commercial` | 1 | `signature_encoding` | +| `esp384_signature_flipped` | `commercial` | 1 | `signature_invalid` | +| `esp384_key_compressed` | `commercial` | 1 | `key_invalid` | +| `esp384_key_p256` | `commercial` | 1 | `key_invalid` | +| `esp384_key_registered_as_ed25519` | `commercial` | 1 | `alg_not_permitted` | diff --git a/test/fixtures/n6/SHA384SUMS b/test/fixtures/n6/SHA384SUMS index d8a6b5d..baad0d4 100644 --- a/test/fixtures/n6/SHA384SUMS +++ b/test/fixtures/n6/SHA384SUMS @@ -1,5 +1,5 @@ -98407a9a6fb2dda0329e79719aecf89b39c57d31556f92778496899720fa0b0d8d33e214ae3373d2aab273e7678794bf README.md -933f4c72dbb5f90e2b8e4c86fce10d9c5f9f9c200cd93b1552d9b8840112d90d4f407d93de02ed37ef4f26773f45e356 VERSION +06e6122290266259b43172c29fcc1a705408502be80b24161c19996c764fe5f4eba59b195f3e488a09df1bb11d2253fc README.md +12541e60a919c5350898baade01540aa3ded55897b594afc54501031c55571b9172cae3bff8eb40f5d20e7b2f6c47e9b 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 @@ -20,8 +20,14 @@ b3cee3d09d5ababe25e142a385186d1a63c8b5d92ff7968845d3df5a7ba1f5e4f1ea311dfc6b2695 c5a6ab7e084812fa66f542f8d500f87b0d0493993c8bca83fd6332d2009016989e8cb83a93c4fae1d8b08a8eb616510d body/negative/set_unsorted/2_then_1.expect 32b7c7cf13b56f1dd265ba7ca16b082b970e996d0c63db877b87344119b2425f17f8d34a638ba8a02465b113de727ff7 body/negative/set_unsorted/longer_head_first.cbor c5a6ab7e084812fa66f542f8d500f87b0d0493993c8bca83fd6332d2009016989e8cb83a93c4fae1d8b08a8eb616510d body/negative/set_unsorted/longer_head_first.expect +69d9b43a157f9987ad8bd7abeef48220248fa2b485ab0ca43632fbe826728c2751e35b9f0c6ee96976a106e75f27bfb3 body/negative/test_schema_outside_test_mode/block_0x80.cbor +9c1fbd7d5aa687aa78c4dff2bf49e7b6834b211892d8eae4303b34655a24790ebbccf73f3d86086a87ea891a3a1b5fe1 body/negative/test_schema_outside_test_mode/block_0x80.expect 880801a67356966a784ec8dd703776ecfa4e0e393a415956da4231da1a0e60520036b6cbdc024fe6040b1e8a123ca5bf body/negative/unknown_key/key_19.cbor 5831dd0658ea99fd684c14e702579484851165f1721347c82146b5cef5baf0dd1a8815ff4e658c464967a77e6cb37e93 body/negative/unknown_key/key_19.expect +60119ed257158d423f7b45b2d507837b887eb3e2a6d94eb2f088093b0b2f31479b92c9fbc90eef7de75e84137f966bf4 body/negative/unknown_schema/reserved_block_0x90.cbor +5fb66206c74a2ebffd03a7de0955f691fac9c364d38f2458ac327f0ad28704f4ca91ca726bc01d8793f5e455cdf8e93d body/negative/unknown_schema/reserved_block_0x90.expect +e70a719269e3320781b92916a091ba22e4a6c2cb14627c2d400881bb04d55064a7c535e1612631079fae836ca1936a54 body/negative/unknown_schema/trinity_block_unissued.cbor +5fb66206c74a2ebffd03a7de0955f691fac9c364d38f2458ac327f0ad28704f4ca91ca726bc01d8793f5e455cdf8e93d body/negative/unknown_schema/trinity_block_unissued.expect 9184dbb9a6f907d6db5b91f417c74b539b7405b8d87e7d3fdd27f2b199b2aa5f99c3613b65df5c95f953debc727f8eaf body/negative/unknown_schema/version_2.cbor 5fb66206c74a2ebffd03a7de0955f691fac9c364d38f2458ac327f0ad28704f4ca91ca726bc01d8793f5e455cdf8e93d body/negative/unknown_schema/version_2.expect bd260698c7910af7c315ba9d70987b143cd0da2988199d636f4640374a9af29bc5f5bf1a423d0dc32106d0b45e1bc9a7 body/negative/wrong_type/name_as_uint.cbor @@ -36,44 +42,83 @@ 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 +f8226db86289bc0126205cb9a44579759098930387e62a0ae1fce761b345c0a50e9bfbad5ffb3b4ab3fc0c14486189d7 cose/sign1/carrier_extra_key.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +141c226ab7f586be243f075e6fce593552ff261972eb2877fd6c3b5e596cbb4ad4b7998fe89624907b3ea121bc59f935 cose/sign1/carrier_key0_envelope.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +6f7c9271a6afdd97fdb4b84c014acc6c0c2ce578fdc23a257abf3c729ded8df2f78485ccee4dbfe451ab1951bfa860f0 cose/sign1/carrier_keys_out_of_order.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +df481a58958fc953aa04f84cf66d9e3d441daf8ea454925ecc4ce3e0ceffdec09d7f9d6fdaf12843bc65f5b2f87c77ea cose/sign1/carrier_no_sign1.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +03409d8277d59ef7009e207fe6b1340fb90800fc326d898c6f8e37b6202e36c468b200e38bfbcb15783b22f608b3b721 cose/sign1/carrier_not_map.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +dc1dd699b12fc164247068dd734880a71e6de8ba86cc81598906c639a9f0a9bde75aaf799a9b1ddc18da338ac95e75b3 cose/sign1/carrier_payload_text.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +b256b4aa0ea3c01c6adb02df28347d60c1dff4f34d4735aa033eea7214a28b4601ef4cdf561ab3ef9ec174bd0e59f5a0 cose/sign1/ed25519_key_registered_as_mldsa87.bin +e9f9523bad63808b2fa831d15c25b0a53b843599b96f9768515c20f810e40c51df8b4860c211783a3eaa67f1f8b7ea3e cose/sign1/ed25519_key_registered_as_mldsa87.ctx.json +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff cose/sign1/ed25519_key_registered_as_mldsa87.expect +a2627eafcf2d4b8652c4da26d757e4336f117dc70029311a3e9d422adaed7f304edc14a81259bbac89046cec106249f5 cose/sign1/ed25519_no_issuer.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/ed25519_no_issuer.ctx.json +4d78ad936dff62202b6f593553d4e596729353410ffaa59f6c77dc11e5d00e401aae3acd95308f9be95c690450ba381d cose/sign1/ed25519_no_issuer.expect +f0008ea3e37ce5d1d75d2de4de24baeb04a62fd27dab336f0b7c3755121f15c8d0fbc19a0d8b36386563a70e1a0300c8 cose/sign1/ed25519_signature_flipped.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +b256b4aa0ea3c01c6adb02df28347d60c1dff4f34d4735aa033eea7214a28b4601ef4cdf561ab3ef9ec174bd0e59f5a0 cose/sign1/ed25519_single.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +b256b4aa0ea3c01c6adb02df28347d60c1dff4f34d4735aa033eea7214a28b4601ef4cdf561ab3ef9ec174bd0e59f5a0 cose/sign1/ed25519_single_at_nss.bin +bbda60353f6402c61af91794a157904f0308469ccc4d7991d48802a8293860ebc83817246c748b264ceca669a747b84d 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 +22cdfa0577b83813ad82a2eece7a9a6a17c97913b33d13fdfb6f1ee153462f849b077fc63123495dca274a16517b4eff cose/sign1/ed25519_typ_schema_mismatch.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/ed25519_typ_schema_mismatch.ctx.json +136f6f4b2d9aeab9aa9a62d8131a31c516e6b96b1ba0f3e73c0466f7cb4ec96bb57610008f377fedca3a0ee24c895068 cose/sign1/ed25519_typ_schema_mismatch.expect +7a2506f83e05811aa0459f14515475f6c132039306ab8520bd572015f92fcde772f4e9c1c1cc235fe0e9ead98643736c cose/sign1/esp384_at_nss.bin +bbda60353f6402c61af91794a157904f0308469ccc4d7991d48802a8293860ebc83817246c748b264ceca669a747b84d cose/sign1/esp384_at_nss.ctx.json +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff cose/sign1/esp384_at_nss.expect +7a2506f83e05811aa0459f14515475f6c132039306ab8520bd572015f92fcde772f4e9c1c1cc235fe0e9ead98643736c cose/sign1/esp384_key_compressed.bin +0054f70bb3f8d132aa4cb4db1163a00384ccf385c3055722202ecc3ebdfa21313af0d9f5601138414f4461ca1f88801a cose/sign1/esp384_key_compressed.ctx.json +559e883df29a99d9aede9dc2242dd4b0ac030f5eff7b1e21ecaec46c7c953279b3f5a69f3ba41273bf4be7a63a4d2332 cose/sign1/esp384_key_compressed.expect +7a2506f83e05811aa0459f14515475f6c132039306ab8520bd572015f92fcde772f4e9c1c1cc235fe0e9ead98643736c cose/sign1/esp384_key_p256.bin +df9bcdc41b222ddfd0af55840cd1b6b485d156ce2e92547953068e67faf0d99852bf38947f2755e0b6554437ed057335 cose/sign1/esp384_key_p256.ctx.json +559e883df29a99d9aede9dc2242dd4b0ac030f5eff7b1e21ecaec46c7c953279b3f5a69f3ba41273bf4be7a63a4d2332 cose/sign1/esp384_key_p256.expect +7a2506f83e05811aa0459f14515475f6c132039306ab8520bd572015f92fcde772f4e9c1c1cc235fe0e9ead98643736c cose/sign1/esp384_key_registered_as_ed25519.bin +257657e4f4120cebd4e3b2f00a66f32abba354827a12703fe85fb2342039cc559e0c6ece7d48923147cd403d2bd67d0e cose/sign1/esp384_key_registered_as_ed25519.ctx.json +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff cose/sign1/esp384_key_registered_as_ed25519.expect +1d54deba45d6d9042506c4314de30f5f277aabdcb42b3dd8dcd3f2c3e3a958f4cd9a2cd996a4875f2dcbd5ba26034089 cose/sign1/esp384_n_minus_s.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/esp384_n_minus_s.ctx.json +129b24235ed01510f297a3444794725550dce67f27c960403338abd7d3b898bfa31a55909bc9138672791699a53874cf cose/sign1/esp384_n_minus_s.expect +d6fc9fde1cfd18fb5ecb482ce8b9ede05b39d149b95a33be55e1ef638e8634c928ed2bf6f3865eea2c24489c2a037053 cose/sign1/esp384_signature_95_bytes.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/esp384_signature_95_bytes.ctx.json +b65401dd45d3e184127b93f38e41ce38816926542fa8bac9ec20804609d1e99cf72057ecb4da10499354a60a2665350f cose/sign1/esp384_signature_95_bytes.expect +a325f407a5e9f6be53526b68238f76925ae1ba8678abb563021235f1e1edd878b93eb23f8d2ee2e1965db98bb89fc2ae cose/sign1/esp384_signature_97_bytes.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/esp384_signature_97_bytes.ctx.json +b65401dd45d3e184127b93f38e41ce38816926542fa8bac9ec20804609d1e99cf72057ecb4da10499354a60a2665350f cose/sign1/esp384_signature_97_bytes.expect +37b0d14d24abe8abca6a558bc376fbf0753a480a94e464a089e980c7c89b5ea0bbcc1e30f841bf1d5f2ef4c886103ecc cose/sign1/esp384_signature_der.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/esp384_signature_der.ctx.json +b65401dd45d3e184127b93f38e41ce38816926542fa8bac9ec20804609d1e99cf72057ecb4da10499354a60a2665350f cose/sign1/esp384_signature_der.expect +1915c70a47c53f93279e6c7b6beba09007ba56d97a92d86df03b09717810db354d9f5357abca3d7054a97a442b84ad6f cose/sign1/esp384_signature_flipped.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/esp384_signature_flipped.ctx.json +d9dac7ae9073b03b97b273d7bdd1102b2bb52c35ff68efd68dfef3d980038b66924993403b5be93d134ab49fbaa819f6 cose/sign1/esp384_signature_flipped.expect +7a2506f83e05811aa0459f14515475f6c132039306ab8520bd572015f92fcde772f4e9c1c1cc235fe0e9ead98643736c cose/sign1/esp384_single.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/esp384_single.ctx.json +129b24235ed01510f297a3444794725550dce67f27c960403338abd7d3b898bfa31a55909bc9138672791699a53874cf cose/sign1/esp384_single.expect +dae716a551a390cbe21d438a1de762941c5e7594cd203bcf82d38c9b22cd003f9a9966e17c4830ff19edd6dade1cd821 cose/sign1/pair_typ_in_single_carrier.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +43aa58dd3bdd4866f54c1dacfcd79347d6313772209c0ef9c5a4445844be7338766819623c2e54138df6dc56eb255767 cose/sign1/sign1_attached.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +11c4f7b070f2fc935c5c666b4a218cdce5399c4edee943e755d76000cd7824e2a6fe059ac9b7819513a6b6c10c8023d2 cose/sign1/sign1_tagged.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f 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 +b76b9f3495334e2462bbc00d382d899d7f9a9172580edefaf979c5c5c70aae97b67ba19e88907b07f9faf1b21d843757 cose/sign1/sign1_unprotected_kid.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/sign1_unprotected_kid.ctx.json c9e09f8bf67b2857043cd7029d06f50b97a0f28946f1e8d7c4022d27b43262f5a389c1c07be090a5528051b3cdd0f879 cose/sign1/sign1_unprotected_kid.expect cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7 digest/abc.in 23f24ce4bb582a0a59e71588f4cf5b2aaf21e5cc65b7ee88265940adec375c4b39187bc3fb85c33ed40c9fc87380346a digest/abc.ref.cbor @@ -335,12 +380,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 +c92b7cc4a2efdc798e2f59f151791548544770be50c8049813d918bc217eb7acc9f11ce42cb873c8fab161bcf82819ab header/negative/alg_not_mldsa87/es384.cbor +2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff header/negative/alg_not_mldsa87/es384.expect f158dcfe0b056f6ba777f297fa4de874c310130a13724c67fa0b098f585dc3635cd9a8ec9c2d5d389f1b4b6e48561087 header/negative/alg_not_mldsa87/mldsa65.cbor 2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff header/negative/alg_not_mldsa87/mldsa65.expect c1d2bca00f5d6a4559f83d346d955b2dfcedb3d446c9f98b946f556e690f6bf52b43264961f9a9a29ff4b5cec6c28cc0 header/negative/content_type/label_3.cbor @@ -376,8 +419,16 @@ c38c1bb721e5f1c4f62dd9b2a6db895af7350fbd905c5609ef068bc54b87f9453e80c26cb9574f86 d5da8e2d7de0437d378fbb09fac16fc4a1e073e1afc704648b66555623c86d73840241553dfc9e722cae66dccb9619f8 header/positive/alg_kid_cwt_typ_scitt.cbor a9b5e6706fec09c6f59343ef85022f91767b3939965b27a3053852638968d916b7b9142c315f241d6155171c7a4f0f98 header/positive/alg_kid_typ.cbor 759a6fa1580e08c85aef93715a74bc444dd8185c132db40ef9d1e08fb3eaa96f51067dfa99ab1a55339c1956520137fa header/positive/ed25519_alg_kid_typ.cbor -38a75cb2d7a8d721942a94f7ac6246a3162323240af9ea38b08fe1cb75f19fa28d985647e1538026a808928471580e81 header/profile-table.json +44f2e52792b35d1bee0fc942dcf6853bf6ea54b54019049e065857a8f45ff9ed66c963f94e204accc502160ac2669579 header/positive/ed25519_alg_under_any_typ.cbor +9e71ad7a44c4d3c63de426069549df47edfaab68dc4f8f661913b6c966730f097540ba34a228951b0e8aa2586b6a4fd8 header/positive/esp384_alg_kid_typ.cbor +abe17bb2fb3ef487fe33624be45a67742d153d0d046896919b0927517cf0c1a16cdd1f485addbcf4713c738d9696db4b header/profile-table.json aab9ecd6c7320915435631cae66cf8bc25a4c33d782c8fe5f81a55eb40e3df2d81450d101f068d52bed4f37a2ec0492e thumbprint/acvp_mldsa87_tg3_tc51.kid 954bc653b6b8f323b050607cb2db6560e0a6634ec020083892bfb7c2cd4370b9b3b44161739b9cc9a6a4d56ac17d3bc2 thumbprint/acvp_mldsa87_tg3_tc51.pub +266701110a216f563c0162bfdc90ca8518428bc7dd1e7dc4223edbd8dcf7f0deaaad28f3fe951d00931c44446350909d thumbprint/p384_x_leading_zero.kid +4a3d8f8b26132da377242ef893e609a63a8cdc1a7798f9842f67465001570ae444d14a6918526677fc320b22b4e39835 thumbprint/p384_x_leading_zero.pub +4bdeebaec52cef342a3959fc433690358b70349e97146fd132d0e0d97c533d82fb6293a47520656b16c2ebd7eebee862 thumbprint/rfc6979_a25_p256.pub +22d3078a4634ba0be9011b57ca1034d6b9a81848a5923a9a525c7d6fb1b34255b7791ada3b657c29a62bccf71249f52f thumbprint/rfc6979_a26_p384.kid +ebe1f3b0fed114eaf158783d8c3227f2d8e05ea982ef00c64a08460b4649011fe3da6e291a22af14445ccae1981dff5b thumbprint/rfc6979_a26_p384.pub +5d645cef1a406994a2578dcb5afa41437358382e6b98b9a2dd144d524d6ab79e4b8100ad963b14a1a828e6966ebb9c7b thumbprint/rfc6979_a26_p384_compressed.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 f52500b..a21d51e 100644 --- a/test/fixtures/n6/VERSION +++ b/test/fixtures/n6/VERSION @@ -1 +1 @@ -n6/v0 draft r3 and the publisher's answers of 2026-10-19 PROVISIONAL (Trinity candidate set) +n6/v0 draft r3 and the publisher's answers of 2026-10-19 and 2026-10-20 PROVISIONAL (Trinity candidate set) diff --git a/test/fixtures/n6/body/negative/test_schema_outside_test_mode/block_0x80.cbor b/test/fixtures/n6/body/negative/test_schema_outside_test_mode/block_0x80.cbor new file mode 100644 index 0000000..0c91fcc Binary files /dev/null and b/test/fixtures/n6/body/negative/test_schema_outside_test_mode/block_0x80.cbor differ diff --git a/test/fixtures/n6/body/negative/test_schema_outside_test_mode/block_0x80.expect b/test/fixtures/n6/body/negative/test_schema_outside_test_mode/block_0x80.expect new file mode 100644 index 0000000..40ec67f --- /dev/null +++ b/test/fixtures/n6/body/negative/test_schema_outside_test_mode/block_0x80.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"test_schema_outside_test_mode"} diff --git a/test/fixtures/n6/body/negative/unknown_schema/reserved_block_0x90.cbor b/test/fixtures/n6/body/negative/unknown_schema/reserved_block_0x90.cbor new file mode 100644 index 0000000..471f3c9 Binary files /dev/null and b/test/fixtures/n6/body/negative/unknown_schema/reserved_block_0x90.cbor differ diff --git a/test/fixtures/n6/body/negative/unknown_schema/reserved_block_0x90.expect b/test/fixtures/n6/body/negative/unknown_schema/reserved_block_0x90.expect new file mode 100644 index 0000000..b6faad5 --- /dev/null +++ b/test/fixtures/n6/body/negative/unknown_schema/reserved_block_0x90.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"unknown_schema"} diff --git a/test/fixtures/n6/body/negative/unknown_schema/trinity_block_unissued.cbor b/test/fixtures/n6/body/negative/unknown_schema/trinity_block_unissued.cbor new file mode 100644 index 0000000..dfbde04 Binary files /dev/null and b/test/fixtures/n6/body/negative/unknown_schema/trinity_block_unissued.cbor differ diff --git a/test/fixtures/n6/body/negative/unknown_schema/trinity_block_unissued.expect b/test/fixtures/n6/body/negative/unknown_schema/trinity_block_unissued.expect new file mode 100644 index 0000000..b6faad5 --- /dev/null +++ b/test/fixtures/n6/body/negative/unknown_schema/trinity_block_unissued.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"unknown_schema"} diff --git a/test/fixtures/n6/cose/sign1/carrier_extra_key.bin b/test/fixtures/n6/cose/sign1/carrier_extra_key.bin index 85e8763..5a43a21 100644 Binary files a/test/fixtures/n6/cose/sign1/carrier_extra_key.bin and b/test/fixtures/n6/cose/sign1/carrier_extra_key.bin differ diff --git a/test/fixtures/n6/cose/sign1/carrier_extra_key.ctx.json b/test/fixtures/n6/cose/sign1/carrier_extra_key.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/carrier_extra_key.ctx.json +++ b/test/fixtures/n6/cose/sign1/carrier_extra_key.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/carrier_key0_envelope.bin b/test/fixtures/n6/cose/sign1/carrier_key0_envelope.bin index 460ff69..bf7c2b8 100644 Binary files a/test/fixtures/n6/cose/sign1/carrier_key0_envelope.bin and b/test/fixtures/n6/cose/sign1/carrier_key0_envelope.bin differ diff --git a/test/fixtures/n6/cose/sign1/carrier_key0_envelope.ctx.json b/test/fixtures/n6/cose/sign1/carrier_key0_envelope.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/carrier_key0_envelope.ctx.json +++ b/test/fixtures/n6/cose/sign1/carrier_key0_envelope.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.bin b/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.bin index 10375fb..7d7fc57 100644 Binary files a/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.bin and b/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.bin differ diff --git a/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.ctx.json b/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.ctx.json +++ b/test/fixtures/n6/cose/sign1/carrier_keys_out_of_order.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/carrier_no_sign1.bin b/test/fixtures/n6/cose/sign1/carrier_no_sign1.bin index f2a65e5..b9b5e63 100644 Binary files a/test/fixtures/n6/cose/sign1/carrier_no_sign1.bin and b/test/fixtures/n6/cose/sign1/carrier_no_sign1.bin differ diff --git a/test/fixtures/n6/cose/sign1/carrier_no_sign1.ctx.json b/test/fixtures/n6/cose/sign1/carrier_no_sign1.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/carrier_no_sign1.ctx.json +++ b/test/fixtures/n6/cose/sign1/carrier_no_sign1.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/carrier_not_map.bin b/test/fixtures/n6/cose/sign1/carrier_not_map.bin index 76a282c..fed9221 100644 Binary files a/test/fixtures/n6/cose/sign1/carrier_not_map.bin and b/test/fixtures/n6/cose/sign1/carrier_not_map.bin differ diff --git a/test/fixtures/n6/cose/sign1/carrier_not_map.ctx.json b/test/fixtures/n6/cose/sign1/carrier_not_map.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/carrier_not_map.ctx.json +++ b/test/fixtures/n6/cose/sign1/carrier_not_map.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/carrier_payload_text.bin b/test/fixtures/n6/cose/sign1/carrier_payload_text.bin index 9193608..79f895c 100644 Binary files a/test/fixtures/n6/cose/sign1/carrier_payload_text.bin and b/test/fixtures/n6/cose/sign1/carrier_payload_text.bin differ diff --git a/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json b/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json +++ b/test/fixtures/n6/cose/sign1/carrier_payload_text.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.bin b/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.bin new file mode 100644 index 0000000..a1028ee Binary files /dev/null and b/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.bin differ diff --git a/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.ctx.json new file mode 100644 index 0000000..1bade25 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-50,"kid":"a44ee44a37558e272b071417936ebbd4fb5f2505a1e5d7d34bea0ad7a49855c5548d62a50f7e81fd1ec059e3443ec8dc","public":"thumbprint/rfc8032_test1_ed25519.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect b/test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.expect similarity index 100% rename from test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.expect rename to test/fixtures/n6/cose/sign1/ed25519_key_registered_as_mldsa87.expect diff --git a/test/fixtures/n6/cose/sign1/ed25519_no_issuer.bin b/test/fixtures/n6/cose/sign1/ed25519_no_issuer.bin new file mode 100644 index 0000000..63fc2fe Binary files /dev/null and b/test/fixtures/n6/cose/sign1/ed25519_no_issuer.bin differ diff --git a/test/fixtures/n6/cose/sign1/ed25519_no_issuer.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_no_issuer.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_no_issuer.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_no_issuer.expect b/test/fixtures/n6/cose/sign1/ed25519_no_issuer.expect new file mode 100644 index 0000000..b7ab10f --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_no_issuer.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"issuer_missing"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin b/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin index 5ce6d7c..7c437a2 100644 Binary files a/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin and b/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.bin differ diff --git a/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.ctx.json +++ b/test/fixtures/n6/cose/sign1/ed25519_signature_flipped.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_single.bin b/test/fixtures/n6/cose/sign1/ed25519_single.bin index 63fc2fe..a1028ee 100644 Binary files a/test/fixtures/n6/cose/sign1/ed25519_single.bin and b/test/fixtures/n6/cose/sign1/ed25519_single.bin differ diff --git a/test/fixtures/n6/cose/sign1/ed25519_single.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_single.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/ed25519_single.ctx.json +++ b/test/fixtures/n6/cose/sign1/ed25519_single.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.bin b/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.bin index 63fc2fe..a1028ee 100644 Binary files a/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.bin and b/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.bin differ diff --git a/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.ctx.json index 1c5e02f..405a7af 100644 --- a/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.ctx.json +++ b/test/fixtures/n6/cose/sign1/ed25519_single_at_nss.ctx.json @@ -1 +1 @@ -{"entry_point":"nss","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"}],"typ_table":"header/profile-table.json"} +{"entry_point":"nss","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.bin b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.bin new file mode 100644 index 0000000..29163c6 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.bin differ diff --git a/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect new file mode 100644 index 0000000..2c21ccc --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"typ_schema_mismatch"} diff --git a/test/fixtures/n6/cose/sign1/esp384_at_nss.bin b/test/fixtures/n6/cose/sign1/esp384_at_nss.bin new file mode 100644 index 0000000..eaa66f3 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_at_nss.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_at_nss.ctx.json b/test/fixtures/n6/cose/sign1/esp384_at_nss.ctx.json new file mode 100644 index 0000000..405a7af --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_at_nss.ctx.json @@ -0,0 +1 @@ +{"entry_point":"nss","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect b/test/fixtures/n6/cose/sign1/esp384_at_nss.expect similarity index 100% rename from test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.expect rename to test/fixtures/n6/cose/sign1/esp384_at_nss.expect diff --git a/test/fixtures/n6/cose/sign1/esp384_key_compressed.bin b/test/fixtures/n6/cose/sign1/esp384_key_compressed.bin new file mode 100644 index 0000000..eaa66f3 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_key_compressed.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_key_compressed.ctx.json b/test/fixtures/n6/cose/sign1/esp384_key_compressed.ctx.json new file mode 100644 index 0000000..efbc7f3 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_key_compressed.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-51,"kid":"0372f7c7c69764a4b8fe5ee15b1d34983b30492bb48875836597da6932f96f94a75019441e7689e3821460c7d6596378","public":"thumbprint/rfc6979_a26_p384_compressed.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_key_compressed.expect b/test/fixtures/n6/cose/sign1/esp384_key_compressed.expect new file mode 100644 index 0000000..651f8c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_key_compressed.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"key_invalid"} diff --git a/test/fixtures/n6/cose/sign1/esp384_key_p256.bin b/test/fixtures/n6/cose/sign1/esp384_key_p256.bin new file mode 100644 index 0000000..eaa66f3 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_key_p256.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_key_p256.ctx.json b/test/fixtures/n6/cose/sign1/esp384_key_p256.ctx.json new file mode 100644 index 0000000..0799cb8 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_key_p256.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-51,"kid":"0372f7c7c69764a4b8fe5ee15b1d34983b30492bb48875836597da6932f96f94a75019441e7689e3821460c7d6596378","public":"thumbprint/rfc6979_a25_p256.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_key_p256.expect b/test/fixtures/n6/cose/sign1/esp384_key_p256.expect new file mode 100644 index 0000000..651f8c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_key_p256.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"key_invalid"} diff --git a/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.bin b/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.bin new file mode 100644 index 0000000..eaa66f3 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.ctx.json b/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.ctx.json new file mode 100644 index 0000000..db01da1 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.ctx.json @@ -0,0 +1 @@ +{"entry_point":"commercial","keys":[{"alg":-19,"kid":"0372f7c7c69764a4b8fe5ee15b1d34983b30492bb48875836597da6932f96f94a75019441e7689e3821460c7d6596378","public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.expect b/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.expect new file mode 100644 index 0000000..30fde88 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_key_registered_as_ed25519.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"alg_not_permitted"} diff --git a/test/fixtures/n6/cose/sign1/esp384_n_minus_s.bin b/test/fixtures/n6/cose/sign1/esp384_n_minus_s.bin new file mode 100644 index 0000000..506e3a8 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_n_minus_s.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_n_minus_s.ctx.json b/test/fixtures/n6/cose/sign1/esp384_n_minus_s.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_n_minus_s.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_n_minus_s.expect b/test/fixtures/n6/cose/sign1/esp384_n_minus_s.expect new file mode 100644 index 0000000..5b5aefa --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_n_minus_s.expect @@ -0,0 +1 @@ +{"exit":0,"source":"hand","verdict_code":"claim"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.bin b/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.bin new file mode 100644 index 0000000..763ad54 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.ctx.json b/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.expect b/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.expect new file mode 100644 index 0000000..c221ece --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_95_bytes.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"signature_encoding"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.bin b/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.bin new file mode 100644 index 0000000..769ee43 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.ctx.json b/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.expect b/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.expect new file mode 100644 index 0000000..c221ece --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_97_bytes.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"signature_encoding"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_der.bin b/test/fixtures/n6/cose/sign1/esp384_signature_der.bin new file mode 100644 index 0000000..057a044 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_signature_der.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_der.ctx.json b/test/fixtures/n6/cose/sign1/esp384_signature_der.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_der.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_der.expect b/test/fixtures/n6/cose/sign1/esp384_signature_der.expect new file mode 100644 index 0000000..c221ece --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_der.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"signature_encoding"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_flipped.bin b/test/fixtures/n6/cose/sign1/esp384_signature_flipped.bin new file mode 100644 index 0000000..f4c10f4 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_signature_flipped.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_flipped.ctx.json b/test/fixtures/n6/cose/sign1/esp384_signature_flipped.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_flipped.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_signature_flipped.expect b/test/fixtures/n6/cose/sign1/esp384_signature_flipped.expect new file mode 100644 index 0000000..6ea7428 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_signature_flipped.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"signature_invalid"} diff --git a/test/fixtures/n6/cose/sign1/esp384_single.bin b/test/fixtures/n6/cose/sign1/esp384_single.bin new file mode 100644 index 0000000..eaa66f3 Binary files /dev/null and b/test/fixtures/n6/cose/sign1/esp384_single.bin differ diff --git a/test/fixtures/n6/cose/sign1/esp384_single.ctx.json b/test/fixtures/n6/cose/sign1/esp384_single.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_single.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"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/esp384_single.expect b/test/fixtures/n6/cose/sign1/esp384_single.expect new file mode 100644 index 0000000..5b5aefa --- /dev/null +++ b/test/fixtures/n6/cose/sign1/esp384_single.expect @@ -0,0 +1 @@ +{"exit":0,"source":"hand","verdict_code":"claim"} diff --git a/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.bin b/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.bin index 0b6d5ee..a60ac14 100644 Binary files a/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.bin and b/test/fixtures/n6/cose/sign1/pair_typ_in_single_carrier.bin differ 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 index e2c8018..4d289c7 100644 --- 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 @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/sign1_attached.bin b/test/fixtures/n6/cose/sign1/sign1_attached.bin index 62aaf39..324dcb4 100644 Binary files a/test/fixtures/n6/cose/sign1/sign1_attached.bin and b/test/fixtures/n6/cose/sign1/sign1_attached.bin differ diff --git a/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json b/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json +++ b/test/fixtures/n6/cose/sign1/sign1_attached.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/sign1_tagged.bin b/test/fixtures/n6/cose/sign1/sign1_tagged.bin index 09ae845..5995893 100644 Binary files a/test/fixtures/n6/cose/sign1/sign1_tagged.bin and b/test/fixtures/n6/cose/sign1/sign1_tagged.bin differ diff --git a/test/fixtures/n6/cose/sign1/sign1_tagged.ctx.json b/test/fixtures/n6/cose/sign1/sign1_tagged.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/sign1_tagged.ctx.json +++ b/test/fixtures/n6/cose/sign1/sign1_tagged.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} diff --git a/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.bin b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.bin index 725ab9b..24beb52 100644 Binary files a/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.bin and b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.bin differ diff --git a/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json index e2c8018..4d289c7 100644 --- a/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json +++ b/test/fixtures/n6/cose/sign1/sign1_unprotected_kid.ctx.json @@ -1 +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"} +{"entry_point":"commercial","keys":[{"alg":-19,"public":"thumbprint/rfc8032_test1_ed25519.pub"},{"alg":-50,"public":"thumbprint/acvp_mldsa87_tg3_tc51.pub"},{"alg":-51,"public":"thumbprint/rfc6979_a26_p384.pub"}],"typ_table":"header/profile-table.json"} 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 deleted file mode 100644 index 0e5552a..0000000 --- a/test/fixtures/n6/header/negative/alg_not_bound_to_typ/mldsa87_under_ed25519_typ.cbor +++ /dev/null @@ -1 +0,0 @@ -£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_mldsa87/es384.cbor b/test/fixtures/n6/header/negative/alg_not_mldsa87/es384.cbor new file mode 100644 index 0000000..2e856e9 --- /dev/null +++ b/test/fixtures/n6/header/negative/alg_not_mldsa87/es384.cbor @@ -0,0 +1 @@ +£8"X0r÷ÇÆ—d¤¸þ^á[4˜;0I+´ˆuƒe—Úi2ùo”§PDv‰ã‚`ÇÖYcxx+application/vnd.example.test-esp384.v1+cose \ No newline at end of file diff --git a/test/fixtures/n6/header/negative/alg_not_mldsa87/es384.expect b/test/fixtures/n6/header/negative/alg_not_mldsa87/es384.expect new file mode 100644 index 0000000..30fde88 --- /dev/null +++ b/test/fixtures/n6/header/negative/alg_not_mldsa87/es384.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/ed25519_under_mldsa87_typ.cbor b/test/fixtures/n6/header/positive/ed25519_alg_under_any_typ.cbor similarity index 100% rename from test/fixtures/n6/header/negative/alg_not_bound_to_typ/ed25519_under_mldsa87_typ.cbor rename to test/fixtures/n6/header/positive/ed25519_alg_under_any_typ.cbor diff --git a/test/fixtures/n6/header/positive/esp384_alg_kid_typ.cbor b/test/fixtures/n6/header/positive/esp384_alg_kid_typ.cbor new file mode 100644 index 0000000..f618460 --- /dev/null +++ b/test/fixtures/n6/header/positive/esp384_alg_kid_typ.cbor @@ -0,0 +1 @@ +£82X0r÷ÇÆ—d¤¸þ^á[4˜;0I+´ˆuƒe—Úi2ùo”§PDv‰ã‚`ÇÖYcxx+application/vnd.example.test-esp384.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 c2fc81c..913c481 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":[{"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"}]} +{"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"},{"carrier":"single","schema":1,"scitt":false,"signers":"single","typ":"application/vnd.example.test-ed25519.v1+cose"},{"carrier":"single","schema":1,"scitt":false,"signers":"single","typ":"application/vnd.example.test-esp384.v1+cose"},{"carrier":"envelope","scitt":false,"signers":"pair","typ":"application/vnd.example.twin-test.v1+cose"}]} diff --git a/test/fixtures/n6/thumbprint/p384_x_leading_zero.kid b/test/fixtures/n6/thumbprint/p384_x_leading_zero.kid new file mode 100644 index 0000000..4e08a7a --- /dev/null +++ b/test/fixtures/n6/thumbprint/p384_x_leading_zero.kid @@ -0,0 +1 @@ +ªÖýy—©Ô�´ûGOÌlÀø m)_-•ÍŠ*FÓàõ¼ÿ<Ë]±yÓÒÚ¿ÔŒU†á \ No newline at end of file diff --git a/test/fixtures/n6/thumbprint/p384_x_leading_zero.pub b/test/fixtures/n6/thumbprint/p384_x_leading_zero.pub new file mode 100644 index 0000000..df7b39f Binary files /dev/null and b/test/fixtures/n6/thumbprint/p384_x_leading_zero.pub differ diff --git a/test/fixtures/n6/thumbprint/rfc6979_a25_p256.pub b/test/fixtures/n6/thumbprint/rfc6979_a25_p256.pub new file mode 100644 index 0000000..50759ea --- /dev/null +++ b/test/fixtures/n6/thumbprint/rfc6979_a25_p256.pub @@ -0,0 +1 @@ +`þÔº%Z�1ÉaëtÆ5mhÀI¸’;aúlæib.`òŸ¶yþ¸¼™¤ééV(¼dòñ² -~ŸQw£Â”ÔF"™ \ No newline at end of file diff --git a/test/fixtures/n6/thumbprint/rfc6979_a26_p384.kid b/test/fixtures/n6/thumbprint/rfc6979_a26_p384.kid new file mode 100644 index 0000000..1af7b8e --- /dev/null +++ b/test/fixtures/n6/thumbprint/rfc6979_a26_p384.kid @@ -0,0 +1 @@ +r÷ÇÆ—d¤¸þ^á[4˜;0I+´ˆuƒe—Úi2ùo”§PDv‰ã‚`ÇÖYcx \ No newline at end of file diff --git a/test/fixtures/n6/thumbprint/rfc6979_a26_p384.pub b/test/fixtures/n6/thumbprint/rfc6979_a26_p384.pub new file mode 100644 index 0000000..299f8c7 --- /dev/null +++ b/test/fixtures/n6/thumbprint/rfc6979_a26_p384.pub @@ -0,0 +1 @@ +ì:NA[N¤V†ŸB¥Úš‹Ä®’à.ªå(k0 dÞøðê�U†`d¢TQT€¼€Ù·-}W$N¨ïšÀÆ!‰g¥“gùß¹õL¨K?�±(‹#:àÔþsDý%3&G \ No newline at end of file diff --git a/test/fixtures/n6/thumbprint/rfc6979_a26_p384_compressed.pub b/test/fixtures/n6/thumbprint/rfc6979_a26_p384_compressed.pub new file mode 100644 index 0000000..cc23a83 --- /dev/null +++ b/test/fixtures/n6/thumbprint/rfc6979_a26_p384_compressed.pub @@ -0,0 +1 @@ +ì:NA[N¤V†ŸB¥Úš‹Ä®’à.ªå(k0 dÞøðê�U†`d¢TQT€¼ \ No newline at end of file diff --git a/test/support/n6_vectors.ex b/test/support/n6_vectors.ex index 32888a2..393afd7 100644 --- a/test/support/n6_vectors.ex +++ b/test/support/n6_vectors.ex @@ -74,13 +74,13 @@ defmodule Trinity.TestSupport.N6Vectors do |> JSON.decode!() |> Map.fetch!("typ") |> Map.new(fn e -> - {e["typ"], - %{ - scitt: e["scitt"], - alg: e["alg"], - carrier: String.to_atom(e["carrier"]), - signers: String.to_atom(e["signers"]) - }} + entry = %{ + scitt: e["scitt"], + carrier: String.to_atom(e["carrier"]), + signers: String.to_atom(e["signers"]) + } + + {e["typ"], if(e["schema"], do: Map.put(entry, :schema, e["schema"]), else: entry)} end) end @@ -98,10 +98,12 @@ defmodule Trinity.TestSupport.N6Vectors do ctx = (base <> ".ctx.json") |> File.read!() |> JSON.decode!() + # A context that registers a key wrongly on purpose (slice 140) names the `kid` it is filed + # under, since its thumbprint under the wrong algorithm or shape is not the header's. registered = - Map.new(ctx["keys"], fn %{"alg" => alg, "public" => rel} -> + Map.new(ctx["keys"], fn %{"alg" => alg, "public" => rel} = key -> pub = File.read!(Path.join(@root, rel)) - {thumbprint(alg, pub), %{alg: alg, public: pub}} + {kid_of(key, alg, pub), %{alg: alg, public: pub}} end) opts = [ @@ -116,14 +118,24 @@ defmodule Trinity.TestSupport.N6Vectors do 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 kid_of(%{"kid" => hex}, _alg, _pub), do: Base.decode16!(hex, case: :lower) + defp kid_of(_key, alg, pub), do: thumbprint(alg, pub) + + # RFC 9679 over the AKP (RFC 9964 section 6), OKP or EC2 (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) + defp thumbprint(-51, <<4, x::binary-size(48), y::binary-size(48)>>), + do: + :crypto.hash( + :sha384, + <<0xA4, 1, 2, 0x20, 2, 0x21, 0x58, 0x30>> <> x <> <<0x22, 0x58, 0x30>> <> y + ) + @doc "RFC 8032 section 7.1, test 1: the published Ed25519 key." def rfc8032_key do %{ @@ -139,6 +151,19 @@ defmodule Trinity.TestSupport.N6Vectors do } end + @doc "RFC 6979 appendix A.2.6: the published P-384 key, as `Trinity.N6.COSE` signs with it." + def rfc6979_p384_key do + %{ + alg: -51, + public: File.read!(Path.join(@root, "thumbprint/rfc6979_a26_p384.pub")), + private: + Base.decode16!( + "6b9d3dad2e1b8c1c05b19875b6659f4de23c3b667bf297ba9aa47740787137d896d5724e4c70a825f872c9ea60d2edf5", + case: :lower + ) + } + end + @doc "NIST ACVP's ML-DSA-87 keyGen case (slice 137 pins it): seed, public key, expanded secret key." def acvp_key do k = "priv/kat/acvp/mldsa87_keygen.json" |> File.read!() |> JSON.decode!() diff --git a/test/support/receipt_body.ex b/test/support/receipt_body.ex new file mode 100644 index 0000000..df17996 --- /dev/null +++ b/test/support/receipt_body.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestSupport.ReceiptBody do + @moduledoc """ + What a receipt says, for tests that read a field of it: the body as a map with string keys, the + shape a v2 or v3 row's RFC 8785 `signed_payload` decodes to (slice 140). + + A v2 or v3 row is that decode. A v4 row's fields come from its N6 body (`seq`, `chain_scope`, + `at`, `clock`, `fingerprint`), its columns (`scheme`, `kind`, `key_id`, `prev_hash`) and the stored + bytes its two ForeignRefs name (`subject`, `decision`), decoded for reading. A test that judges + what is signed reads the body itself; this is for reading what a row says. + """ + + alias Trinity.Receipts.{Receipt, Signer, V4} + + @doc "The body of a `Receipt` row, or of an exported row, as a string-keyed map." + @spec of(Receipt.t() | map()) :: map() + def of(%Receipt{} = r), do: r |> Receipt.to_export() |> of() + + def of(%{"scheme" => scheme} = r) do + if Signer.n6?(scheme), do: v4(r), else: JSON.decode!(r["signed_payload"]) + end + + defp v4(r) do + {:ok, value} = V4.read(r["kind"], Base.decode64!(r["body_b64"])) + %{1 => wall, 2 => counter, 3 => {:text, node}} = value[19] + + %{ + "scheme" => r["scheme"], + "seq" => value[16], + "chain_scope" => text(value[17]), + "prev_hash" => r["prev_hash"], + "kind" => r["kind"], + "key_id" => r["key_id"], + "at" => value[18] |> DateTime.from_unix!(:microsecond) |> DateTime.to_iso8601(), + "clock" => %{"wall" => wall, "counter" => counter, "node" => node}, + "subject" => attachment(r["subject_b64"]), + "decision" => attachment(r["decision_b64"]), + "fingerprint" => text(value[22]) + } + end + + defp attachment(nil), do: nil + defp attachment(b64), do: b64 |> Base.decode64!() |> JSON.decode!() + + defp text({:text, t}), do: t + defp text(nil), do: nil +end diff --git a/test/support/receipts/legacy_rows.ex b/test/support/receipts/legacy_rows.ex new file mode 100644 index 0000000..b4c29d7 --- /dev/null +++ b/test/support/receipts/legacy_rows.ex @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestReceipts.LegacyRows do + @moduledoc """ + A chain that began before slice 140 (slice 140, AC5 to AC7): v3 rows written into `receipts` + exactly as the writer wrote them until then (an RFC 8785 body over DSSE's PAE, SHA-256 links, + signed through the key in custody), so a test can show the writer continuing such a chain in v4 + and every verifier reading the result. The rows go in through `BypassInserter`, the census's + planted second path, because the writer no longer writes v3. + """ + + alias Trinity.Receipts.{Envelope, KeyCustody, Receipt} + alias Trinity.TestReceipts.BypassInserter + + @doc """ + Plants `kinds` (a list of `"decision"` and `"query"`) as v3 rows at seq 1 to n of `scope`, under + the selected key's family. Returns the rows. + """ + def plant(scope, kinds) do + %{algorithm: alg, key_id: key_id} = KeyCustody.selected() + scheme = "receipt_v3_#{alg}" + + {rows, _prev} = + kinds + |> Enum.with_index(1) + |> Enum.map_reduce(nil, fn {kind, seq}, prev -> + body = %{ + "scheme" => scheme, + "seq" => seq, + "chain_scope" => scope, + "prev_hash" => prev, + "kind" => kind, + "subject" => %{"legacy" => seq}, + "decision" => if(kind == "query", do: %{"ok" => true}, else: %{"outcome" => "allow"}), + "fingerprint" => nil, + "at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "key_id" => key_id, + "clock" => %{"wall" => 1_000 + seq, "counter" => 0, "node" => "legacy-node"} + } + + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.receipt_type(scheme), payload) + hash = Envelope.hash(bytes) + + signature = + if Receipt.signed?(kind) do + {:ok, sig} = KeyCustody.sign(bytes) + sig + end + + {:ok, row} = + BypassInserter.insert(%Receipt{ + chain_scope: scope, + seq: seq, + prev_hash: prev, + receipt_hash: hash, + scheme: scheme, + kind: kind, + signed_payload: payload, + signature: signature, + key_id: key_id, + subject: body["subject"], + subject_ref: "legacy:#{scope}:#{seq}", + meta: %{}, + inserted_at: DateTime.utc_now() + }) + + {row, hash} + end) + + rows + end +end diff --git a/test/trinity/effects/boot_receipt_test.exs b/test/trinity/effects/boot_receipt_test.exs index 753a18d..6b4c605 100644 --- a/test/trinity/effects/boot_receipt_test.exs +++ b/test/trinity/effects/boot_receipt_test.exs @@ -24,7 +24,7 @@ defmodule Trinity.Effects.BootReceiptTest do assert r.subject["fips"] == Atom.to_string(:crypto.info_fips()) assert r.meta["core_policy_hash"] == CorePolicy.hash() assert r.meta["canonicalization_version"] == 1 - body = JSON.decode!(r.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(r) assert body["subject"]["authority"] == "Trinity.Authority.Local" # The policy hash is unsigned metadata, per the R21 default: not in the signed body. refute Map.has_key?(body, "core_policy_hash") @@ -47,7 +47,7 @@ defmodule Trinity.Effects.BootReceiptTest do {:error, {:mldsa87_preflight_failed, step, _}} -> "failed:#{step}" end - body = JSON.decode!(r.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(r) for {key, value} <- [{"erts", erts}, {"elixir", elixir}, {"mldsa87_preflight", want}] do assert r.subject[key] == value, key diff --git a/test/trinity/effects/membrane_test.exs b/test/trinity/effects/membrane_test.exs index b83583e..9f64fe6 100644 --- a/test/trinity/effects/membrane_test.exs +++ b/test/trinity/effects/membrane_test.exs @@ -66,7 +66,7 @@ defmodule Trinity.Effects.MembraneTest do assert admit.seq < done.seq assert admit.signature != nil and done.signature != nil assert admit.subject_ref == "effect:#{ctx.session_id}:c2" - body = JSON.decode!(done.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(done) assert body["decision"]["ok"] == true assert body["fingerprint"] == @@ -89,7 +89,7 @@ defmodule Trinity.Effects.MembraneTest do assert [{"decision", "c9", nil}] = kinds(scope) [row] = Receipts.list(scope) - assert JSON.decode!(row.signed_payload)["decision"]["outcome"] == "deny" + assert Trinity.TestSupport.ReceiptBody.of(row)["decision"]["outcome"] == "deny" end test "AC4: arguments mutated after the decision are denied at execution with a receipt (M2 re-verify)", @@ -116,7 +116,7 @@ defmodule Trinity.Effects.MembraneTest do assert derived == Permissions.fingerprint(ctx.session_id, "write_note", staged.args, nil) assert [{"effect", "c4", "denied"}] = kinds(scope) [row] = Receipts.list(scope) - assert JSON.decode!(row.signed_payload)["decision"]["reason"] =~ "fingerprint_mismatch" + assert Trinity.TestSupport.ReceiptBody.of(row)["decision"]["reason"] =~ "fingerprint_mismatch" # The same staged effect with the arguments the decision bound runs. assert {:ok, %{content: "wrote 2 bytes" <> _}} = @@ -290,7 +290,7 @@ defmodule Trinity.Effects.MembraneTest do assert [{"effect", "r1", "denied"}] = kinds(scope) [row] = Receipts.list(scope) - assert JSON.decode!(row.signed_payload)["decision"]["reason"] =~ + assert Trinity.TestSupport.ReceiptBody.of(row)["decision"]["reason"] =~ "fingerprint_mismatch_after_stage" end diff --git a/test/trinity/gateways/approvals_test.exs b/test/trinity/gateways/approvals_test.exs index 930132d..3ce0669 100644 --- a/test/trinity/gateways/approvals_test.exs +++ b/test/trinity/gateways/approvals_test.exs @@ -140,7 +140,7 @@ defmodule Trinity.Gateways.ApprovalsTest do assert refusal.subject["adapter"] == "console" assert refusal.subject["external_user_id"] == @user assert refusal.subject["risk"] == "destructive" - assert Jason.decode!(refusal.signed_payload)["decision"]["basis"] == "channel_cap" + assert Trinity.TestSupport.ReceiptBody.of(refusal)["decision"]["basis"] == "channel_cap" on_exit(fn -> Receipts.stop_writer(scope) end) end diff --git a/test/trinity/mcp/auth/resource_server_test.exs b/test/trinity/mcp/auth/resource_server_test.exs index e41cfde..0d1ce6c 100644 --- a/test/trinity/mcp/auth/resource_server_test.exs +++ b/test/trinity/mcp/auth/resource_server_test.exs @@ -122,7 +122,7 @@ defmodule Trinity.MCP.Auth.ResourceServerTest do reasons = for r <- decisions(scope), r.subject["phase"] == "auth", - do: Jason.decode!(r.signed_payload)["decision"]["reason"] + do: Trinity.TestSupport.ReceiptBody.of(r)["decision"]["reason"] assert "no_bearer" in reasons assert Enum.any?(reasons, &String.starts_with?(&1, "wrong_audience")) @@ -175,7 +175,7 @@ defmodule Trinity.MCP.Auth.ResourceServerTest do assert iss == as.issuer - assert Jason.decode!(deny.signed_payload)["decision"] == %{ + assert Trinity.TestSupport.ReceiptBody.of(deny)["decision"] == %{ "outcome" => "deny", "basis" => "scope", "reason" => "needs trinity:tools:artifact" diff --git a/test/trinity/mcp/bridge_test.exs b/test/trinity/mcp/bridge_test.exs index 2db0da6..500e686 100644 --- a/test/trinity/mcp/bridge_test.exs +++ b/test/trinity/mcp/bridge_test.exs @@ -113,7 +113,7 @@ defmodule Trinity.MCP.BridgeTest do assert [receipt] = Receipts.list(scope) assert receipt.kind == "decision" assert receipt.subject == %{"server" => "c", "tool" => "mcp:c:echo", "phase" => "load"} - payload = Jason.decode!(receipt.signed_payload) + payload = Trinity.TestSupport.ReceiptBody.of(receipt) assert payload["decision"]["outcome"] == "deny" assert payload["decision"]["reason"] =~ "catalog_is_compile_time" end diff --git a/test/trinity/mcp/server_test.exs b/test/trinity/mcp/server_test.exs index c76a6de..be0e0b7 100644 --- a/test/trinity/mcp/server_test.exs +++ b/test/trinity/mcp/server_test.exs @@ -266,7 +266,7 @@ defmodule Trinity.MCP.ServerTest do scope |> Receipts.list() |> Enum.filter(&(&1.kind == "decision")) - |> Enum.map(&Jason.decode!(&1.signed_payload)["decision"]["outcome"]) + |> Enum.map(&Trinity.TestSupport.ReceiptBody.of(&1)["decision"]["outcome"]) assert "deny" in denied end diff --git a/test/trinity/memory/prompt_tiers_test.exs b/test/trinity/memory/prompt_tiers_test.exs index 4467413..fb55a70 100644 --- a/test/trinity/memory/prompt_tiers_test.exs +++ b/test/trinity/memory/prompt_tiers_test.exs @@ -121,7 +121,7 @@ defmodule Trinity.Memory.PromptTiersTest do Receipts.list(scope, kind: "query") |> Enum.filter(&(&1.subject["prompt_tier"] == "volatile")) - assert JSON.decode!(receipt.signed_payload)["decision"]["dropped_tokens"] > 0 + assert Trinity.TestSupport.ReceiptBody.of(receipt)["decision"]["dropped_tokens"] > 0 assert receipt.subject_ref == "prompt:#{row.id}:volatile" end diff --git a/test/trinity/memory/retriever_test.exs b/test/trinity/memory/retriever_test.exs index 35e5a3b..a5aa71a 100644 --- a/test/trinity/memory/retriever_test.exs +++ b/test/trinity/memory/retriever_test.exs @@ -223,7 +223,7 @@ defmodule Trinity.Memory.RetrieverTest do [receipt] = Receipts.list(scope, kind: "query") |> Enum.filter(&(&1.subject["prompt_tier"] == "recall")) - assert JSON.decode!(receipt.signed_payload)["decision"]["dropped_tokens"] > 0 + assert Trinity.TestSupport.ReceiptBody.of(receipt)["decision"]["dropped_tokens"] > 0 assert receipt.subject_ref == "prompt:#{row.id}:recall" # One hit line survives the cap (the two tie on rank and recency; which is first is the id's), the other is cut. diff --git a/test/trinity/n6/carrier_test.exs b/test/trinity/n6/carrier_test.exs index 776bd4b..ffc8d49 100644 --- a/test/trinity/n6/carrier_test.exs +++ b/test/trinity/n6/carrier_test.exs @@ -6,9 +6,12 @@ defmodule Trinity.N6.CarrierTest do 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. + The signed samples are Ed25519 and, since slice 140, ESP384. Where the runtime refuses Ed25519 + (FIPS mode), an Ed25519 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. ESP384 is judged on every runtime here, FIPS mode included. Since slice 140 the body + rules (`typ_schema_mismatch`, `issuer_missing`) are read after the signature, so they are judged + outcomes too. """ use ExUnit.Case, async: true @@ -17,18 +20,18 @@ defmodule Trinity.N6.CarrierTest do alias Trinity.TestSupport.{Ed25519Oracle, N6Vectors} # The outcomes that need the signature checked. - @judged [:claim, :signature_invalid] + @judged [:claim, :signature_invalid, :typ_schema_mismatch, :issuer_missing] test "AC5: every sample gives its expected outcome under its context" do samples = N6Vectors.samples() - assert length(samples) >= 13 + assert length(samples) >= 26 ed25519? = Ed25519Oracle.verifies?() for {id, bytes, exit, code, opts} <- samples do answer = Carrier.verify(bytes, opts) cond do - code in @judged and not ed25519? -> + code in @judged and alg(bytes) == -19 and not ed25519? -> assert answer == {:error, 5, :alg_unavailable}, id code == :claim -> @@ -41,14 +44,21 @@ defmodule Trinity.N6.CarrierTest do end end - test "AC4: the carrier round-trips its parts, and its key 0 is the placeholder schema" do + # The header's `alg`, read from the sample's own bytes. + defp alg(bytes) do + {:ok, %{0 => _, 2 => [{:bytes, protected} | _]}} = CBOR.decode(bytes) + {:ok, %{1 => alg}} = CBOR.decode(protected) + alg + end + + test "AC4: the carrier round-trips its parts, and its key 0 is the publisher's 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} + assert Profile.schemas().single == {0xFFFF0003, :publisher} end test "AC4: the twin envelope's schema, and a body's schema, are not a single-signer carrier" do diff --git a/test/trinity/n6/census_test.exs b/test/trinity/n6/census_test.exs index 6693c33..ad652bf 100644 --- a/test/trinity/n6/census_test.exs +++ b/test/trinity/n6/census_test.exs @@ -20,9 +20,10 @@ defmodule Trinity.N6.CensusTest do * `:local_artifact` - a digest of this tree's own build output; * `:in_memory` - within one run, never written or signed; * `:test_double` - a fake used by the suite; - * `:legacy_signed` - receipts and checkpoints as they are signed today (DSSE's - pre-authentication encoding over RFC 8785 JSON, SHA-256 chains), which move to N6 with the - receipts slice and the per-box log; + * `:legacy_signed` - receipts and checkpoints as they were signed before slice 140 (DSSE's + pre-authentication encoding over RFC 8785 JSON, SHA-256 chains), which every verifier still + reads, and the signers the receipt key custody signs with, whose bytes are N6's since slice + 140 (`Trinity.Receipts.V4`); * `:reserialized` - foreign data re-encoded before it is hashed, which the rule forbids. Each is declared here with what it would take to hash the exact bytes instead; none changes in this slice. @@ -35,7 +36,8 @@ defmodule Trinity.N6.CensusTest do # path => {calls in that file, class, why} @census %{ "lib/trinity/n6/cose.ex" => - {6, :n6, "thumbprints (AKP and OKP), ML-DSA-87 and Ed25519 sign and verify"}, + {8, :n6, "thumbprints (AKP and OKP), ML-DSA-87, Ed25519 and ESP384 sign and verify"}, + "lib/trinity/n6/ec2.ex" => {1, :n6, "the EC2 (P-384) thumbprint"}, "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"}, @@ -60,8 +62,11 @@ defmodule Trinity.N6.CensusTest do "lib/trinity/receipts/envelope.ex" => {2, :legacy_signed, "RFC 8785 body and SHA-256 chain hash"}, "lib/trinity/receipts/chain_writer.ex" => - {6, :legacy_signed, "DSSE PAE for receipts and checkpoints"}, - "lib/trinity/receipts/verifier.ex" => {2, :legacy_signed, "DSSE PAE, verified"}, + {3, :legacy_signed, "the DSSE PAE of a v2 or v3 tail and checkpoint, read at rehydrate"}, + "lib/trinity/receipts/verifier.ex" => + {3, :legacy_signed, "DSSE PAE of v2 and v3 rows and checkpoints, verified, and linked"}, + "lib/trinity/receipts/v4.ex" => + {2, :n6, "a receipt's statement_id, and the link to a v2 or v3 predecessor"}, "lib/trinity/receipts/merkle.ex" => {3, :legacy_signed, "the checkpoint tree, SHA-256"}, "lib/trinity/receipts/signer.ex" => {1, :legacy_signed, "RFC 7638 key id"}, "lib/trinity/receipts/key_custody.ex" => diff --git a/test/trinity/n6/cose_signing_test.exs b/test/trinity/n6/cose_signing_test.exs index ac6fcb3..6c2e812 100644 --- a/test/trinity/n6/cose_signing_test.exs +++ b/test/trinity/n6/cose_signing_test.exs @@ -106,8 +106,17 @@ defmodule Trinity.N6.COSESigningTest do key: key, opts: opts } do - {:ok, parts} = - COSE.sign("a statement", @typ, %{alg: -50, public: key.public, private: {:seed, key.seed}}) + key = %{alg: -50, public: key.public, private: {:seed, key.seed}} + + # Since slice 140 the carrier reads the body it covers: an N6 map naming its issuer (key 1). + body = + Trinity.N6.CBOR.encode!(%{ + 0 => 1, + 1 => {:text, "trinity:test.invalid"}, + 16 => {:text, "a statement"} + }) + + {:ok, parts} = COSE.sign(body, @typ, key) carrier = Carrier.encode(parts) assert Carrier.decode(carrier) == {:ok, parts} @@ -116,5 +125,10 @@ defmodule Trinity.N6.COSESigningTest do assert {:ok, %{exit: 0, verdict_code: :claim}} = Carrier.verify(carrier, Keyword.put(opts, :entry, entry)) end + + # Slice 139's payload, bare text: its signature verifies, and the carrier refuses what it covers. + {:ok, bare} = COSE.sign("a statement", @typ, key) + assert {:ok, %{verdict_code: :claim}} = COSE.verify(bare, opts) + assert Carrier.verify(Carrier.encode(bare), opts) == {:error, 1, :trailing_bytes} end end diff --git a/test/trinity/n6/cose_test.exs b/test/trinity/n6/cose_test.exs index 6cb2292..41f1f68 100644 --- a/test/trinity/n6/cose_test.exs +++ b/test/trinity/n6/cose_test.exs @@ -18,6 +18,7 @@ defmodule Trinity.N6.COSETest do @typ "application/vnd.example.test.v1+cose" @typ_ed "application/vnd.example.test-ed25519.v1+cose" + @typ_esp "application/vnd.example.test-esp384.v1+cose" setup_all do key = N6Vectors.acvp_key() @@ -33,6 +34,7 @@ defmodule Trinity.N6.COSETest do kid: COSE.thumbprint(key.public), ed: ed, ed_kid: COSE.thumbprint(-19, ed.public), + ec_kid: COSE.thumbprint(-51, N6Vectors.rfc6979_p384_key().public), table: N6Vectors.typ_table() } end @@ -73,18 +75,29 @@ defmodule Trinity.N6.COSETest do test "every positive header decodes at commercial, and the builder makes exactly each", %{ kid: kid, ed_kid: ed_kid, + ec_kid: ec_kid, table: table } do positives = Path.wildcard(Path.join(N6Vectors.root(), "header/positive/*.cbor")) - assert length(positives) == 3 + assert length(positives) == 5 for path <- positives do assert {:ok, %{kid: k}} = COSE.decode_protected(File.read!(path), table, :commercial), path - assert k in [kid, ed_kid] + assert k in [kid, ed_kid, ec_kid] end + # Slice 140: no `typ` names an algorithm, so -19 under the ML-DSA-87 test `typ` decodes; the + # key's registration decides (the cose samples). + any = + File.read!(Path.join(N6Vectors.root(), "header/positive/ed25519_alg_under_any_typ.cbor")) + + assert COSE.protected_header(ed_kid, @typ, nil, -19) == any + + esp = File.read!(Path.join(N6Vectors.root(), "header/positive/esp384_alg_kid_typ.cbor")) + assert COSE.protected_header(ec_kid, @typ_esp, nil, -51) == esp + plain = File.read!(Path.join(N6Vectors.root(), "header/positive/alg_kid_typ.cbor")) assert COSE.protected_header(kid, @typ) == plain @@ -110,7 +123,7 @@ defmodule Trinity.N6.COSETest do } do ed = COSE.protected_header(ed_kid, @typ_ed, nil, -19) - assert {:ok, %{alg: -19, kid: ^ed_kid, entry: %{alg: -19}}} = + assert {:ok, %{alg: -19, kid: ^ed_kid, entry: %{carrier: :single}}} = COSE.decode_protected(ed, table, :commercial) assert COSE.decode_protected(ed, table, :nss) == {:error, :alg_not_permitted} @@ -226,7 +239,9 @@ defmodule Trinity.N6.COSETest do end describe "slice 139, AC3: Ed25519" do - test "a key registered under another algorithm than the header's is kid_not_thumbprint", %{ + # Slice 140: `alg_not_permitted`, the publisher's Decision of 2026-10-20 (point 1, the algorithm + # check); slice 139 answered `kid_not_thumbprint` here. + test "a key registered under another algorithm than the header's is alg_not_permitted", %{ key: key, ed: ed, ed_kid: ed_kid, @@ -241,7 +256,7 @@ defmodule Trinity.N6.COSETest do } assert COSE.verify(ed_parts, keys: mldsa(key), typ: table, entry: :commercial) == - {:error, 1, :kid_not_thumbprint} + {:error, 1, :alg_not_permitted} ml_parts = %{ protected: COSE.protected_header(kid, @typ), @@ -252,7 +267,7 @@ defmodule Trinity.N6.COSETest do 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} + {:error, 1, :alg_not_permitted} end # Found by planting (PROOF, P5): with the sizes of -50 and -19 keys different, the size check @@ -267,7 +282,7 @@ defmodule Trinity.N6.COSETest do 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} + {:error, 1, :alg_not_permitted} end test "signs the sample's literal signature and verifies; one flipped byte is invalid; " <> @@ -275,7 +290,11 @@ defmodule Trinity.N6.COSETest do %{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" + # {0: 1, 1: "trinity:test.invalid", 16: "slice 139"}, the sample's body since slice 140. + payload = + <<0xA3, 0x00, 0x01, 0x01, 0x74>> <> + "trinity:test.invalid" <> <<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] diff --git a/test/trinity/n6/esp384_test.exs b/test/trinity/n6/esp384_test.exs new file mode 100644 index 0000000..fa4e2eb --- /dev/null +++ b/test/trinity/n6/esp384_test.exs @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.ESP384Test do + @moduledoc """ + Slice 140, AC2: ESP384 (-51) under N6, the publisher's Decision of 2026-10-20, point 1. ECDSA on + P-384 with SHA-384 is on every runtime this suite runs on, FIPS mode included, so nothing here + branches; the module says which mode it ran in. + + The thumbprint is checked twice: against `Trinity.N6.EC2`, and against `sha384sum` run outside + Elixir over the 107 bytes written to a file, so the expected value is no Elixir code's opinion. + """ + + use ExUnit.Case, async: true + + alias Trinity.N6.{COSE, EC2} + alias Trinity.TestSupport.N6Vectors + + @typ "application/vnd.example.test-esp384.v1+cose" + @tmp "tmp/esp384_test" + + setup_all do + IO.puts("esp384_test: ESP384 signs and verifies with fips #{:crypto.info_fips()}") + key = N6Vectors.rfc6979_p384_key() + kid = COSE.thumbprint(-51, key.public) + keys = fn k -> if k == kid, do: {:ok, %{alg: -51, public: key.public}}, else: :error end + %{key: key, kid: kid, opts: [keys: keys, typ: N6Vectors.typ_table(), entry: :commercial]} + end + + describe "the thumbprint" do + for id <- ["rfc6979_a26_p384", "p384_x_leading_zero"] do + test "#{id}: kid is SHA-384 over exactly the 107-byte EC2 input, as sha384sum says" do + root = N6Vectors.root() + pub = File.read!(Path.join(root, "thumbprint/#{unquote(id)}.pub")) + kid = File.read!(Path.join(root, "thumbprint/#{unquote(id)}.kid")) + <<4, x::binary-size(48), y::binary-size(48)>> = pub + + input = <<0xA4, 1, 2, 0x20, 2, 0x21, 0x58, 0x30>> <> x <> <<0x22, 0x58, 0x30>> <> y + assert byte_size(input) == 107 + assert EC2.thumbprint_input(x, y) == input + assert COSE.thumbprint(-51, pub) == kid + + File.mkdir_p!(@tmp) + path = Path.join(@tmp, unquote(id) <> ".in") + File.write!(path, input) + {out, 0} = System.cmd("sha384sum", [path]) + assert out |> String.split() |> hd() == Base.encode16(kid, case: :lower) + end + end + + test "the leading-zero key keeps its zero byte" do + pub = File.read!(Path.join(N6Vectors.root(), "thumbprint/p384_x_leading_zero.pub")) + assert <<4, 0, _::binary>> = pub + + <<4, x::binary-size(48), y::binary-size(48)>> = pub + stripped = binary_part(x, 1, 47) + + # What an implementation that strips leading zeros would hash instead. + wrong = + :crypto.hash( + :sha384, + <<0xA4, 1, 2, 0x20, 2, 0x21, 0x58, 0x2F>> <> stripped <> <<0x22, 0x58, 0x30>> <> y + ) + + refute COSE.thumbprint(-51, pub) == wrong + end + end + + describe "signing and verifying" do + test "signs a 96-byte r || s that verifies, and n - s verifies too", %{key: key, opts: opts} do + assert {:ok, parts} = COSE.sign("a statement", @typ, key) + assert byte_size(parts.signature) == 96 + assert {:ok, %{exit: 0, verdict_code: :claim}} = COSE.verify(parts, opts) + + other = %{parts | signature: EC2.negate_s(parts.signature)} + refute other.signature == parts.signature + assert {:ok, %{exit: 0, verdict_code: :claim}} = COSE.verify(other, opts) + end + + test "one flipped byte in the header, the payload or the signature is invalid", %{ + key: key, + opts: opts + } do + {:ok, parts} = COSE.sign("a statement", @typ, key) + + # The last protected byte is inside `typ`; a byte inside `kid` would be an unknown key (5). + for field <- [:protected, :payload, :signature] do + bin = Map.fetch!(parts, field) + at = byte_size(bin) - 1 + <> = bin + + assert {:error, 1, _code} = + COSE.verify(%{parts | field => a <> <>}, opts), + "#{field}" + end + end + + test "DER, or 95 or 97 bytes, is signature_encoding", %{key: key, opts: opts} do + {:ok, parts} = COSE.sign("a statement", @typ, key) + der = EC2.to_der(parts.signature) + assert byte_size(der) in 101..104 + + for sig <- [der, binary_part(parts.signature, 0, 95), parts.signature <> <<0>>] do + assert COSE.verify(%{parts | signature: sig}, opts) == {:error, 1, :signature_encoding} + end + end + + test "at nss, -51 is alg_not_permitted", %{key: key, opts: opts} do + {:ok, parts} = COSE.sign("a statement", @typ, key) + + assert COSE.verify(parts, Keyword.put(opts, :entry, :nss)) == + {:error, 1, :alg_not_permitted} + end + + test "a key registered under another algorithm is alg_not_permitted", %{key: key, kid: kid} do + {:ok, parts} = COSE.sign("a statement", @typ, key) + + for alg <- [-19, -50] do + keys = fn k -> if k == kid, do: {:ok, %{alg: alg, public: key.public}}, else: :error end + opts = [keys: keys, typ: N6Vectors.typ_table(), entry: :commercial] + assert COSE.verify(parts, opts) == {:error, 1, :alg_not_permitted}, "registered #{alg}" + end + end + + test "a compressed point, a P-256 key and a point off the curve are key_invalid", %{ + key: key, + kid: kid + } do + {:ok, parts} = COSE.sign("a statement", @typ, key) + <<4, x::binary-size(48), y::binary-size(48)>> = key.public + <> = y + root = N6Vectors.root() + + for pub <- [ + <<2 + rem(yn, 2)>> <> x, + File.read!(Path.join(root, "thumbprint/rfc6979_a25_p256.pub")), + <<4>> <> x <> <> + ] do + keys = fn k -> if k == kid, do: {:ok, %{alg: -51, public: pub}}, else: :error end + opts = [keys: keys, typ: N6Vectors.typ_table(), entry: :commercial] + assert COSE.verify(parts, opts) == {:error, 1, :key_invalid} + end + end + end + + test "DER and r || s convert both ways, for signatures of every length OTP makes", %{key: key} do + for i <- 1..200 do + der = :crypto.sign(:ecdsa, :sha384, "m#{i}", [key.private, :secp384r1]) + assert {:ok, raw} = EC2.from_der(der) + assert byte_size(raw) == 96 + assert EC2.to_der(raw) == der + end + + assert EC2.from_der(<<0x30, 0x02, 0x02, 0x00>>) == :error + assert EC2.from_der(:binary.copy(<<0>>, 96)) == :error + end +end diff --git a/test/trinity/receipts/chain_writer_test.exs b/test/trinity/receipts/chain_writer_test.exs index c364440..c2da5ca 100644 --- a/test/trinity/receipts/chain_writer_test.exs +++ b/test/trinity/receipts/chain_writer_test.exs @@ -1,11 +1,16 @@ # SPDX-FileCopyrightText: Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.Receipts.ChainWriterTest do - @moduledoc "Slice 024, G1 line 3: one writer per scope, the chain, the checkpoints, the refusals." + @moduledoc """ + Slice 024, G1 line 3: one writer per scope, the chain, the checkpoints, the refusals. Since slice + 140 every row and checkpoint the writer makes is v4 (`Trinity.Receipts.V4`), so what is checked + here is its N6 body, its `statement_id` and its carrier. + """ use Trinity.DataCase, async: false + alias Trinity.N6.Carrier alias Trinity.Receipts - alias Trinity.Receipts.{Alarm, ChainWriter, Checkpoint, Envelope, KeyCustody, Receipt, Signer} + alias Trinity.Receipts.{Alarm, ChainWriter, Checkpoint, KeyCustody, Receipt, Signer, V4} alias Trinity.Repo.Receipts, as: RRepo setup do @@ -52,30 +57,40 @@ defmodule Trinity.Receipts.ChainWriterTest do |> Enum.chunk_every(2, 1, :discard) |> Enum.each(fn [a, b] -> assert b.prev_hash == a.receipt_hash end) - %{impl: impl, keys_dir: dir, key_id: key_id} = KeyCustody.selected() - {:ok, registry} = Receipts.KeyRegistry.read(dir) - - {:ok, pub} = - registry |> Receipts.KeyRegistry.lookup(key_id) |> Receipts.KeyRegistry.public_key() + %{impl: impl, n6_alg: alg, public: pub, key_id: key_id} = KeyCustody.selected() + kid = Trinity.N6.COSE.thumbprint(alg, pub) + keys = fn k -> if k == kid, do: {:ok, %{alg: alg, public: pub}}, else: :error end for r <- rows do - bytes = Envelope.pae(Envelope.receipt_type(r.scheme), r.signed_payload) - assert r.receipt_hash == Envelope.hash(bytes) + assert r.receipt_hash == V4.hex(V4.statement_id(r.kind, r.body)) assert r.scheme == impl.scheme() assert r.key_id == key_id + assert r.signed_payload == "" - if r.kind == "query", - do: assert(r.signature == nil), - else: assert(impl.verify(bytes, r.signature, pub)) - end - - # The body carries the scheme, the seq, the scope and the key id (the R21 default set). - body = JSON.decode!(hd(rows).signed_payload) + if r.kind == "query" do + assert r.carrier == nil + else + assert {:ok, %{verdict_code: :claim}} = + Carrier.verify(r.carrier, keys: keys, typ: V4.typ_table(), entry: :commercial) - assert Map.keys(body) |> Enum.sort() == - ~w(at chain_scope clock decision fingerprint key_id kind prev_hash scheme seq subject) + assert {:ok, %{payload: payload}} = Carrier.decode(r.carrier) + assert payload == r.body + end + end - assert body["scheme"] == impl.scheme() + # The body: issuer, prev (from seq 2), seq, scope, at, clock, subject, decision, fingerprint. + [first, second | _] = rows + assert {:ok, body} = V4.read("decision", first.body) + assert body |> Map.keys() |> Enum.sort() == [0, 1, 16, 17, 18, 19, 20, 21, 22] + assert body[0] == V4.schema_number("decision") + assert {:ok, %{7 => [-43, {:bytes, prev}]}} = V4.read("decision", second.body) + assert V4.hex(prev) == first.receipt_hash + + # The ForeignRefs name the stored bytes exactly. + assert Trinity.N6.Digest.matches?(body[20][0], first.subject_bytes) + assert body[20][1] == byte_size(first.subject_bytes) + assert first.subject_bytes == ~s({"n":1}) + assert first.decision_bytes == ~s({"outcome":"allow"}) end test "concurrent appenders to one scope produce one chain: no two rows share a prev_hash", %{ @@ -124,7 +139,7 @@ defmodule Trinity.Receipts.ChainWriterTest do assert Alarm.set?() assert_receive {:alarm_event, %{count: 1}, %{reason: :signer_unavailable}} assert Receipts.count(scope) == 1 - assert Receipts.list(scope) |> Enum.all?(&(&1.signature != nil)) + assert Receipts.list(scope) |> Enum.all?(&(&1.carrier != nil)) # A query row is chained without a signature and still goes in; its checkpoint waits. assert {:ok, %Receipt{kind: "query"}} = Receipts.append(scope, query(3)) @@ -146,22 +161,14 @@ defmodule Trinity.Receipts.ChainWriterTest do Receipts.checkpoints(scope) assert cp.tail_hash == Receipts.tail(scope).receipt_hash - %{impl: impl, keys_dir: dir, key_id: key_id} = KeyCustody.selected() + %{keys_dir: dir} = KeyCustody.selected() {:ok, registry} = Receipts.KeyRegistry.read(dir) + tail = Base.decode16!(cp.tail_hash, case: :lower) - {:ok, pub} = - registry |> Receipts.KeyRegistry.lookup(key_id) |> Receipts.KeyRegistry.public_key() + assert V4.verify_checkpoint(Checkpoint.to_export(cp), tail, registry: registry) == :ok - assert impl.verify( - Envelope.pae(Envelope.checkpoint_type(cp.scheme), cp.signed_payload), - cp.signature, - pub - ) - - body = JSON.decode!(cp.signed_payload) - - assert body["first_seq"] == 1 and body["last_seq"] == 3 and - body["tail_hash"] == cp.tail_hash + {:ok, body} = V4.read("checkpoint", cp.body) + assert body[16] == 1 and body[20] == 3 and body[21] == [-43, {:bytes, tail}] # A fourth opens a new window; a decision receipt does not count toward it. {:ok, _} = Receipts.append(scope, query(4)) @@ -228,8 +235,10 @@ defmodule Trinity.Receipts.ChainWriterTest do :ok = Receipts.stop_writer(scope) tail = Receipts.tail(scope) + <> = tail.body + RRepo.update_all(from(r in Receipt, where: r.id == ^tail.id), - set: [signed_payload: String.replace(tail.signed_payload, "allow", "deny!")] + set: [body: head <> <> <> rest] ) Process.flag(:trap_exit, true) @@ -253,8 +262,12 @@ defmodule Trinity.Receipts.ChainWriterTest do assert {:error, {:chain_inconsistent, ^scope, {:checkpoint_tail_not_in_chain, 1}}} = ChainWriter.start_link(scope) + {:ok, parts} = Carrier.decode(cp.carrier) + <> = parts.signature + forged = Carrier.encode(%{parts | signature: <> <> srest}) + RRepo.update_all(from(c in Checkpoint, where: c.id == ^cp.id), - set: [tail_hash: cp.tail_hash, signature: :crypto.strong_rand_bytes(64)] + set: [tail_hash: cp.tail_hash, carrier: forged] ) assert {:error, {:chain_inconsistent, ^scope, {:checkpoint_signature_invalid, 1}}} = diff --git a/test/trinity/receipts/clock_chain_test.exs b/test/trinity/receipts/clock_chain_test.exs index d563180..0e573dc 100644 --- a/test/trinity/receipts/clock_chain_test.exs +++ b/test/trinity/receipts/clock_chain_test.exs @@ -12,8 +12,10 @@ defmodule Trinity.Receipts.ClockChainTest do """ use Trinity.DataCase, async: false + alias Trinity.N6.CBOR alias Trinity.Receipts alias Trinity.Receipts.{Clock, Envelope, KeyCustody, Receipt, Signer, Verifier} + alias Trinity.TestSupport.ReceiptBody setup do scope = "test:" <> Trinity.UUID.generate() @@ -41,9 +43,8 @@ defmodule Trinity.Receipts.ClockChainTest do end end - defp clock_of(%Receipt{signed_payload: payload}) do - {:ok, body} = JSON.decode(payload) - {:ok, clock} = Clock.from_map(body["clock"]) + defp clock_of(%Receipt{} = row) do + {:ok, clock} = Clock.from_map(ReceiptBody.of(row)["clock"]) clock end @@ -52,7 +53,7 @@ defmodule Trinity.Receipts.ClockChainTest do for n <- 1..5, do: {:ok, _} = Receipts.append(scope, decision(n)) for row <- Receipts.list(scope) do - {:ok, body} = JSON.decode(row.signed_payload) + body = ReceiptBody.of(row) assert {:ok, %Clock{}} = Clock.from_map(body["clock"]), "row #{row.seq} carries no clock: #{inspect(body["clock"])}" @@ -94,7 +95,9 @@ defmodule Trinity.Receipts.ClockChainTest do end describe "the clock is signed, not decoration" do - test "editing the clock in a stored payload breaks verification", %{scope: scope} do + # Since slice 140 the clock is key 19 of the row's N6 body, which its `statement_id` and its + # signature cover. + test "editing the clock in a stored body breaks verification", %{scope: scope} do for n <- 1..3, do: {:ok, _} = Receipts.append(scope, decision(n)) :ok = Receipts.stop_writer(scope) {:ok, export} = Receipts.export(scope) @@ -104,11 +107,10 @@ defmodule Trinity.Receipts.ClockChainTest do tampered = update_in(export["receipts"], fn rows -> Enum.map(rows, fn r -> - {:ok, body} = JSON.decode(r["signed_payload"]) - - if body["seq"] == 2 do - moved = %{body | "clock" => %{body["clock"] | "wall" => 0}} - %{r | "signed_payload" => Jcs.encode(moved)} + if r["seq"] == 2 do + {:ok, body} = r["body_b64"] |> Base.decode64!() |> CBOR.decode() + moved = put_in(body, [19, 1], 0) + %{r | "body_b64" => Base.encode64(CBOR.encode!(moved))} else r end @@ -150,7 +152,7 @@ defmodule Trinity.Receipts.ClockChainTest do "a receipt was refused and nothing recorded it. A refusal that leaves no record is " <> "indistinguishable from the call never having been made." - {:ok, body} = JSON.decode(refusal.signed_payload) + body = ReceiptBody.of(refusal) assert get_in(body, ["decision", "outcome"]) == "refused" assert get_in(body, ["decision", "basis"]) == "clock" assert get_in(body, ["subject", "offered_clock", "wall"]) == behind.wall @@ -165,8 +167,7 @@ defmodule Trinity.Receipts.ClockChainTest do {:error, _} = Receipts.append(scope, Map.put(decision(99), :clock, behind)) refute Enum.any?(Receipts.list(scope), fn r -> - {:ok, body} = JSON.decode(r.signed_payload) - body["fingerprint"] == "ab99" + ReceiptBody.of(r)["fingerprint"] == "ab99" end), "the refused receipt was written anyway" @@ -200,11 +201,15 @@ defmodule Trinity.Receipts.ClockChainTest do end end - test "v2 is readable and carries no clock; v3 carries one" do + test "v2 is readable and carries no clock; v3 and v4 carry one; only v4 is N6" do refute Signer.clocked?("receipt_v2_ed25519") assert Signer.clocked?("receipt_v3_ed25519") - assert "receipt_v2_ed25519" in Signer.accepted_schemes() - assert "receipt_v3_ed25519" in Signer.accepted_schemes() + assert Signer.clocked?("receipt_v4_ed25519") + refute Signer.n6?("receipt_v3_ed25519") + assert Signer.n6?("receipt_v4_ed25519") + + for v <- ["v2", "v3", "v4"], + do: assert("receipt_#{v}_ed25519" in Signer.accepted_schemes()) end test "no signer family contains an underscore, which is what makes parsing the scheme safe" do diff --git a/test/trinity/receipts/merge_test.exs b/test/trinity/receipts/merge_test.exs index 8105b74..99fb18a 100644 --- a/test/trinity/receipts/merge_test.exs +++ b/test/trinity/receipts/merge_test.exs @@ -127,7 +127,7 @@ defmodule Trinity.Receipts.MergeTest do assert length(conflicts) == 3 for row <- conflicts do - {:ok, body} = JSON.decode(row.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(row) assert get_in(body, ["decision", "outcome"]) == "conflict" assert get_in(body, ["decision", "basis"]) == "merge" assert get_in(body, ["subject", "local_receipt_hash"]) @@ -174,7 +174,7 @@ defmodule Trinity.Receipts.MergeTest do |> Receipts.list() |> Enum.find(&(&1.subject_ref == "merge_conflict:#{scope}:4")) - {:ok, body} = JSON.decode(row.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(row) assert get_in(body, ["decision", "reason"]) =~ "the remote chain has a row", "a position the remote has and this chain does not was not described as such" @@ -191,7 +191,7 @@ defmodule Trinity.Receipts.MergeTest do |> Receipts.list() |> Enum.find(&(&1.subject_ref && String.starts_with?(&1.subject_ref, "merge_conflict:"))) - {:ok, body} = JSON.decode(row.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(row) assert get_in(body, ["subject", "local_head"]) == summary.local_head assert get_in(body, ["subject", "remote_head"]) == summary.remote_head diff --git a/test/trinity/receipts/queue_test.exs b/test/trinity/receipts/queue_test.exs index 9cccd36..9f8a6c5 100644 --- a/test/trinity/receipts/queue_test.exs +++ b/test/trinity/receipts/queue_test.exs @@ -196,7 +196,7 @@ defmodule Trinity.Receipts.QueueTest do assert refusal, "the machine refused an effect and recorded nothing" - {:ok, body} = JSON.decode(refusal.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(refusal) assert get_in(body, ["decision", "outcome"]) == "refused" assert get_in(body, ["decision", "basis"]) == "queue" assert get_in(body, ["subject", "queue_bound"]) == 2 @@ -215,7 +215,7 @@ defmodule Trinity.Receipts.QueueTest do {:error, _} = Receipts.append(scope, decision(3)) refute Enum.any?(Receipts.list(scope), fn r -> - {:ok, body} = JSON.decode(r.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(r) body["fingerprint"] == "ab3" end), "an effect was refused and written anyway" diff --git a/test/trinity/receipts/scheme_mapping_test.exs b/test/trinity/receipts/scheme_mapping_test.exs index 0d6cefc..5d40a45 100644 --- a/test/trinity/receipts/scheme_mapping_test.exs +++ b/test/trinity/receipts/scheme_mapping_test.exs @@ -9,11 +9,14 @@ defmodule Trinity.Receipts.SchemeMappingTest do code, and that agreement decays silently: the next slice to add a signed field leaves the document describing a payload that no longer exists, and nothing anywhere fails. So the document's own mapping table is the population here, checked against the payload in both directions. + + Slice 140: new rows are v4, N6 statements with integer keys (`Trinity.Receipts.V4`). The v3 table + is now checked against a v3 row as the writer signed it before slice 140 (slice 139's fixture), and + the document's v4 table against the v4 schema, both ways. """ use Trinity.DataCase, async: false - alias Trinity.Receipts - alias Trinity.Receipts.Receipt + alias Trinity.Receipts.{Receipt, V4} @doc_path Path.expand("../../../docs/receipt-scheme-mapping.md", __DIR__) @external_resource @doc_path @@ -27,19 +30,43 @@ defmodule Trinity.Receipts.SchemeMappingTest do @slice_026_added ~w(clock) @current_keys @slice_024_keys ++ @slice_026_added + # A v3 row as the writer signed it: slice 139's checked-in Ed25519 chain, its first signed row. defp payload_keys do - scope = "test:" <> Trinity.UUID.generate() - on_exit(fn -> Receipts.stop_writer(scope) end) - - {:ok, row} = - Receipts.append(scope, %{ - kind: "decision", - subject: %{"n" => 1}, - decision: %{"outcome" => "allow"}, - fingerprint: "ab01" - }) - - row.signed_payload |> JSON.decode!() |> Map.keys() |> Enum.sort() + %{"receipts" => rows} = + "test/fixtures/receipts/ed25519_signed.json" |> File.read!() |> JSON.decode!() + + row = + Enum.find( + rows, + &(&1["kind"] == "decision" and String.starts_with?(&1["scheme"], "receipt_v3_")) + ) + + row["signed_payload"] |> JSON.decode!() |> Map.keys() |> Enum.sort() + end + + # The v4 table: rows that start with a key number, then the field's name in backticks. + defp documented_v4 do + Regex.scan(~r/^\| (\d+) \| `(\w+)` \|/m, doc()) + |> Enum.map(fn [_, key, name] -> {String.to_integer(key), name} end) + |> Enum.sort() + end + + test "slice 140: the v4 table names exactly the v4 row body's keys, with their names" do + [schema | _] = Enum.filter(V4.schemas(), &(&1.kind == "trinity.receipt.decision")) + + fields = + [ + {0, "schema"} + | Enum.map(schema.fields, fn {k, {name, _, _}} -> {k, Atom.to_string(name)} end) + ] + |> Enum.sort() + + assert documented_v4() == fields, """ + the document's v4 table and Trinity.Receipts.V4's row body disagree. + + In the body, missing a row in the table: #{inspect(fields -- documented_v4())} + In the table, absent from the body: #{inspect(documented_v4() -- fields)} + """ end defp doc, do: File.read!(@doc_path) diff --git a/test/trinity/receipts/standalone_verifier_test.exs b/test/trinity/receipts/standalone_verifier_test.exs index bf63429..8f7df55 100644 --- a/test/trinity/receipts/standalone_verifier_test.exs +++ b/test/trinity/receipts/standalone_verifier_test.exs @@ -5,6 +5,10 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do Slice 024, AC7: `bin/verify_receipt.exs` runs from an empty directory, with `elixir` alone, against an exported file, and answers with the exit vocabulary. And the drift guard: on the same inputs it agrees with `Trinity.Receipts.Verifier`, outcome for outcome. + + Slice 140, AC8: the chains the writer builds are v4, which the script verifies by loading the N6 + sources and `Trinity.Receipts.V4` from the directory `--n6` names (here, this checkout's `lib/`); + a chain with no v4 row needs no `--n6` at all. """ use Trinity.DataCase, async: false @@ -12,6 +16,8 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do alias Trinity.Receipts.Verifier @script Path.expand("../../../bin/verify_receipt.exs", __DIR__) + @lib Path.expand("../../../lib", __DIR__) + @n6 ["--n6", @lib] setup do scope = "standalone:" <> Trinity.UUID.generate() @@ -47,7 +53,7 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do end # The script copied into the empty directory, run there with only elixir on the path. - defp run_script(dir, export, args \\ []) do + defp run_script(dir, export, args \\ @n6) do script = Path.join(dir, "verify_receipt.exs") File.cp!(@script, script) file = Path.join(dir, "export.json") @@ -67,7 +73,7 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do Enum.map( rows, &if(&1["seq"] == 6, - do: Map.put(&1, "signed_payload", &1["signed_payload"] <> " "), + do: Map.put(&1, "body_b64", Base.encode64(Base.decode64!(&1["body_b64"]) <> <<0>>)), else: &1 ) ) @@ -85,7 +91,11 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do assert {"compromised key: " <> _, 6} = run_script(dir, compromised) assert {"invalid: {:scheme_not_allowed, " <> _, 1} = - run_script(dir, export, ["--schemes", other_scheme()]) + run_script(dir, export, ["--schemes", other_scheme() | @n6]) + + # Copied out of the checkout with no `--n6`, a v4 chain is a usage error, never a verdict. + {out, 2} = run_script(dir, export, []) + assert out =~ "v4 rows need Trinity's N6 sources" {out, 2} = System.cmd("elixir", [Path.join(dir, "verify_receipt.exs")], @@ -131,4 +141,79 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do assert code == Verifier.exit_code(Verifier.verify(v)) end end + + test "AC8: a missing or altered attachment is a finding in both, and the verdict stands", %{ + scope: scope, + dir: dir + } do + export = export(scope, 4) + + missing = + update_in(export["receipts"], fn rows -> + Enum.map(rows, &if(&1["seq"] == 2, do: Map.delete(&1, "subject_b64"), else: &1)) + end) + + altered = + update_in(export["receipts"], fn rows -> + Enum.map( + rows, + &if(&1["seq"] == 4, + do: Map.put(&1, "decision_b64", Base.encode64(~s({"outcome":"deny"}))), + else: &1 + ) + ) + end) + + assert {:ok, %{findings: [{:foreign_bytes_missing, 2, :subject}]}} = Verifier.verify(missing) + assert {out, 0} = run_script(dir, missing) + assert out =~ "finding: {:foreign_bytes_missing, 2, :subject}" + + assert {:ok, %{findings: [{:foreign_digest_mismatch, 4, :decision}]}} = + Verifier.verify(altered) + + assert {out, 0} = run_script(dir, altered) + assert out =~ "finding: {:foreign_digest_mismatch, 4, :decision}" + end + + test "AC8: on a chain that crosses from v3 to v4, and on each tamper of it, both agree", %{ + scope: scope, + dir: dir + } do + Trinity.TestReceipts.LegacyRows.plant(scope, ["decision", "query", "decision"]) + {:ok, _} = Receipts.ensure_writer(scope) + for k <- ["query", "decision"], do: {:ok, _} = Receipts.append(scope, %{kind: k}) + :ok = Receipts.stop_writer(scope) + Process.sleep(50) + {:ok, export} = Receipts.export(scope) + + at = fn path, value -> put_in(export, path, value) end + + variants = [ + export, + at.(["receipts", Access.at(3), "prev_hash"], String.duplicate("0", 96)), + at.(["receipts", Access.at(2), "signed_payload"], "{}"), + at.(["checkpoints", Access.at(0), "tail_hash"], String.duplicate("0", 96)), + Map.put(export, "receipts", List.delete_at(export["receipts"], 3)) + ] + + codes = + for v <- variants do + {_out, code} = run_script(dir, v) + assert code == Verifier.exit_code(Verifier.verify(v)) + code + end + + assert hd(codes) == 0 + assert Enum.all?(tl(codes), &(&1 == 1)) + end + + test "AC8: a v3 chain verifies without --n6, as before slice 140, and both agree", %{dir: dir} do + # Slice 139's fixture, an Ed25519 chain written under v3. + export = "test/fixtures/receipts/ed25519_signed.json" |> File.read!() |> JSON.decode!() + assert Enum.all?(export["receipts"], &String.starts_with?(&1["scheme"], "receipt_v3_")) + + {_out, code} = run_script(dir, export, []) + assert code == Verifier.exit_code(Verifier.verify(export)) + assert code in [0, 5] + end end diff --git a/test/trinity/receipts/v4_test.exs b/test/trinity/receipts/v4_test.exs new file mode 100644 index 0000000..1ab52ef --- /dev/null +++ b/test/trinity/receipts/v4_test.exs @@ -0,0 +1,321 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.V4Test do + @moduledoc """ + Slice 140: receipts v4. AC3, the six kinds' bodies; AC4, `subject` and `decision` by ForeignRef; + AC5, linking, across the bump from v3 included; AC6, the writer and the `nss` profile; AC7, every + verifier on a chain that mixes v3 and v4 rows and checkpoints. + """ + use Trinity.DataCase, async: false + + alias Trinity.N6.{Carrier, CBOR, COSE, Digest} + alias Trinity.Receipts + alias Trinity.Receipts.{ChainWriter, Envelope, KeyCustody, Merge, Receipt, Signer, V4, Verifier} + alias Trinity.TestReceipts.LegacyRows + + setup do + scope = "v4:" <> Trinity.UUID.generate() + on_exit(fn -> Receipts.stop_writer(scope) end) + {:ok, scope: scope} + end + + defp fields(overrides \\ %{}) do + Map.merge( + %{ + issuer: "trinity:test", + seq: 2, + chain_scope: "s", + prev: :binary.copy(<<7>>, 48), + at: 1_792_500_000_000_000, + clock: {1_792_500_000_000, 3, "node-a"}, + subject: ~s({"a":1}), + decision: ~s({"outcome":"allow"}), + fingerprint: "ab01" + }, + overrides + ) + end + + defp export!(scope) do + :ok = Receipts.stop_writer(scope) + await_stopped(scope) + {:ok, export} = Receipts.export(scope) + export + end + + defp await_stopped(scope) do + if ChainWriter.whereis(scope), do: Process.sleep(10) && await_stopped(scope), else: :ok + end + + describe "AC3: the six kinds" do + test "each row kind builds and reads back to the same fields, under its own key 0 and typ" do + for {kind, n} <- V4.kinds(), kind != "checkpoint" do + assert {:ok, body} = V4.row_body(kind, fields()) + assert {:ok, v} = V4.read(kind, body) + assert v[0] == Bitwise.bsl(0x83, 24) + Bitwise.bsl(n, 8) + 1 + assert v[1] == {:text, "trinity:test"} and v[16] == 2 and v[17] == {:text, "s"} + assert v[7] == [-43, {:bytes, :binary.copy(<<7>>, 48)}] + assert v[18] == 1_792_500_000_000_000 + assert v[19] == %{1 => 1_792_500_000_000, 2 => 3, 3 => {:text, "node-a"}} + assert v[20] == Digest.foreign_ref(~s({"a":1}), "application/json") + assert v[21] == Digest.foreign_ref(~s({"outcome":"allow"}), "application/json") + assert v[22] == {:text, "ab01"} + assert V4.typ(kind) == "application/vnd.example.b83.receipt-#{kind}.v0+cose" + end + + # A row with no predecessor, no decision and no fingerprint leaves the keys out. + assert {:ok, body} = + V4.row_body("query", fields(%{prev: nil, decision: nil, fingerprint: nil})) + + assert {:ok, v} = V4.read("query", body) + assert v |> Map.keys() |> Enum.sort() == [0, 1, 16, 17, 18, 19, 20] + end + + test "a checkpoint builds and reads back" do + f = %{ + issuer: "trinity:test", + first_seq: 1, + last_seq: 9, + chain_scope: "s", + tail: :binary.copy(<<9>>, 48), + boot: "abc", + reason: "count", + at: 5 + } + + assert {:ok, body} = V4.checkpoint_body(f) + assert {:ok, v} = V4.read("checkpoint", body) + assert v[0] == V4.schema_number("checkpoint") and v[16] == 1 and v[20] == 9 + assert v[21] == [-43, {:bytes, :binary.copy(<<9>>, 48)}] and v[23] == {:text, "count"} + end + + test "a body under another kind's schema, an undeclared key, a missing key or a wrong type is refused" do + {:ok, body} = V4.row_body("decision", fields()) + {:ok, v} = CBOR.decode(body) + + assert V4.read("effect", body) == :error + + for {mutant, code} <- [ + {Map.put(v, 30, 1), :unknown_key}, + {Map.delete(v, 16), :required_key_missing}, + {Map.put(v, 16, {:text, "two"}), :wrong_type}, + {Map.put(v, 0, V4.schema_number("effect")), :unknown_schema} + ] do + assert {:error, ^code, _} = + Trinity.N6.Body.decode( + CBOR.encode!(mutant), + "trinity.receipt.decision", + V4.schemas() + ) + end + end + + test "the encoder refuses a body the decoder would refuse" do + assert {:error, :wrong_type} = V4.row_body("decision", fields(%{seq: -1})) + end + + test "the typ table has one single-signer entry per signed kind and none for query" do + table = V4.typ_table() + assert map_size(table) == 5 + refute Map.has_key?(table, V4.typ("query")) + + for {typ, entry} <- table do + assert %{carrier: :single, signers: :single, scitt: false, schema: schema} = entry + assert typ == V4.typ(Enum.find(Map.keys(V4.kinds()), &(V4.schema_number(&1) == schema))) + end + end + end + + describe "AC4: subject and decision by ForeignRef" do + test "the stored bytes match, and re-serialising them with another key order is only a finding", + %{scope: scope} do + {:ok, _} = + Receipts.append(scope, %{ + kind: "decision", + subject: %{"b" => 2, "a" => 1}, + decision: %{"outcome" => "allow", "basis" => "rule"} + }) + + export = export!(scope) + [r] = export["receipts"] + assert Base.decode64!(r["subject_b64"]) == ~s({"a":1,"b":2}) + assert {:ok, %{findings: []}} = Verifier.verify(export) + + # The same map, other key order: the same JSON value, other bytes. + reordered = Base.encode64(~s({"b":2,"a":1})) + tampered = put_in(export, ["receipts", Access.at(0), "subject_b64"], reordered) + + assert {:ok, %{findings: [{:foreign_digest_mismatch, 1, :subject}]}} = + Verifier.verify(tampered) + end + end + + describe "AC5: linking" do + test "a v4 row's link is its statement_id, the same whether or not it is signed", %{ + scope: scope + } do + for k <- ["query", "decision", "query"], do: {:ok, _} = Receipts.append(scope, %{kind: k}) + + [a, b, c] = Receipts.list(scope) + assert b.prev_hash == a.receipt_hash and c.prev_hash == b.receipt_hash + assert a.receipt_hash == V4.hex(V4.statement_id("query", a.body)) + + # The query row's body, signed: the carrier's payload is the body, so the id is unchanged. + %{n6_alg: alg, public: pub} = KeyCustody.selected() + protected = V4.protected("query", alg, pub) + {:ok, sig} = KeyCustody.sign(COSE.to_be_signed(protected, a.body)) + {:ok, carrier} = V4.carrier(protected, a.body, alg, sig) + {:ok, %{payload: payload}} = Carrier.decode(carrier) + assert V4.hex(V4.statement_id("query", payload)) == a.receipt_hash + end + + test "a chain that began in v3 continues in v4, linked by SHA-384 over the v3 row's PAE bytes", + %{scope: scope} do + [_, v3] = LegacyRows.plant(scope, ["decision", "decision"]) + {:ok, v4} = Receipts.append(scope, %{kind: "decision", decision: %{"outcome" => "allow"}}) + + assert Signer.n6?(v4.scheme) and v4.seq == 3 + pae = Envelope.pae(Envelope.receipt_type(v3.scheme), v3.signed_payload) + assert v4.prev_hash == V4.hex(:crypto.hash(:sha384, pae)) + assert {:ok, %{7 => [-43, {:bytes, link}]}} = V4.read("decision", v4.body) + assert link == :crypto.hash(:sha384, pae) + + assert {:ok, %{receipts: 3}} = Verifier.verify(export!(scope)) + end + + test "every tamper with a row, a link or a checkpoint is refused at its seq", %{scope: scope} do + for k <- ["decision", "query", "decision", "query", "decision"], + do: {:ok, _} = Receipts.append(scope, %{kind: k}) + + {:ok, _} = ChainWriter.checkpoint(scope, "manual") + export = export!(scope) + assert {:ok, %{receipts: 5}} = Verifier.verify(export) + rows = export["receipts"] + + # Reordered: rows 2 and 3 exchange their positions. + reordered = + Enum.map(rows, fn r -> + case r["seq"] do + 2 -> %{r | "seq" => 3} + 3 -> %{r | "seq" => 2} + _ -> r + end + end) + + cases = [ + {Map.put(export, "receipts", List.delete_at(rows, 2)), {:seq_gap, 2, 4}}, + {Map.put(export, "receipts", reordered), {:prev_hash_mismatch, 2}}, + {put_in(export, ["receipts", Access.at(3), "prev_hash"], String.duplicate("0", 96)), + {:prev_hash_mismatch, 4}}, + {put_in(export, ["checkpoints", Access.at(0), "tail_hash"], String.duplicate("0", 96)), + {:checkpoint_tail_mismatch, 5}} + ] + + for {variant, reason} <- cases do + assert {:error, 1, ^reason} = Verifier.verify(variant) + end + end + end + + describe "AC6: the writer and the profile" do + test "the writer writes v4 under the selected key's algorithm" do + %{algorithm: alg, n6_alg: n6, scheme: scheme} = KeyCustody.selected() + assert scheme == "receipt_v4_#{alg}" + assert n6 == %{ed25519: -19, p384: -51, mldsa87: -50}[alg] + + IO.puts("v4_test: the writer signs v4 with #{alg} (#{n6}), fips #{:crypto.info_fips()}") + end + + test "the nss profile admits ML-DSA-87 only, refused at selection naming both" do + old = Application.get_env(:trinity, :receipts, []) + on_exit(fn -> Application.put_env(:trinity, :receipts, old) end) + + for alg <- [:ed25519, :p384] do + Application.put_env( + :trinity, + :receipts, + Keyword.merge(old, profile: :nss, algorithm: alg) + ) + + assert KeyCustody.select() == {:error, {:receipts_profile_algorithm, :nss, alg}} + + assert Trinity.Profile.check_receipts(:regulated, KeyCustody.select()) == + {:error, + {:regulated_requires_receipts, {:receipts_profile_algorithm, :nss, alg}}} + end + + Application.put_env( + :trinity, + :receipts, + Keyword.merge(old, profile: :nss, algorithm: :mldsa87) + ) + + refute match?({:error, {:receipts_profile_algorithm, _, _}}, KeyCustody.select()) + assert KeyCustody.profile() == :nss + end + end + + describe "AC6: ML-DSA-87 (-50), where the runtime signs it" do + # The `fips` job's mode-off step runs this file with ML-DSA-87 required; elsewhere the tag + # excludes it. + @tag :mldsa87_runtime + test "with ML-DSA-87 configured on the nss profile, the writer signs v4 with -50 and it verifies at nss", + %{scope: scope} do + old = Application.get_env(:trinity, :receipts, []) + + dir = + Path.join(System.tmp_dir!(), "trinity-v4-mldsa87-#{System.unique_integer([:positive])}") + + on_exit(fn -> + Application.put_env(:trinity, :receipts, old) + File.rm_rf!(dir) + {:ok, _} = KeyCustody.boot!() + end) + + # The export reads the registry from the configured keys directory, so the key goes there. + Application.put_env( + :trinity, + :receipts, + Keyword.merge(old, algorithm: :mldsa87, profile: :nss, keys_dir: dir) + ) + + assert {:ok, %{algorithm: :mldsa87, n6_alg: -50, scheme: "receipt_v4_mldsa87"}} = + KeyCustody.boot!() + + for k <- ["decision", "query", "effect"], do: {:ok, _} = Receipts.append(scope, %{kind: k}) + + for r <- Receipts.list(scope), r.carrier do + {:ok, parts} = Carrier.decode(r.carrier) + assert {:ok, %{1 => -50}} = CBOR.decode(parts.protected) + assert byte_size(parts.signature) == 4627 + end + + export = export!(scope) + assert {:ok, %{receipts: 3}} = Verifier.verify(export, entry: :nss) + IO.puts("v4_test: ML-DSA-87 (-50) v4 rows written and verified at nss") + end + end + + describe "AC7: every verifier on a chain that mixes v3 and v4" do + test "the verifier, the merge and the writer's rehydrate all accept it", %{scope: scope} do + LegacyRows.plant(scope, ["decision", "query", "decision"]) + + # The writer starts over the v3 tail: its rehydrate checkpoint covers the v3 query row in v4. + {:ok, _} = Receipts.ensure_writer(scope) + for k <- ["query", "decision", "query"], do: {:ok, _} = Receipts.append(scope, %{kind: k}) + + export = export!(scope) + schemes = Enum.map(export["receipts"], &elem(Signer.parse_scheme(&1["scheme"]), 1)) + assert schemes == ["v3", "v3", "v3", "v4", "v4", "v4"] + assert Enum.any?(export["checkpoints"], &(&1["first_seq"] == 2 and &1["body_b64"])) + + assert {:ok, %{receipts: 6, findings: []}} = Verifier.verify(export) + assert {:ok, :identical, %{conflicts: []}} = Merge.merge(scope, export) + + # A restart rehydrates over the v4 tail and its v4 checkpoint, and the chain continues. + {:ok, _} = Receipts.ensure_writer(scope) + assert {:ok, %Receipt{seq: 7}} = Receipts.append(scope, %{kind: "decision"}) + assert {:ok, %{receipts: 7}} = Verifier.verify(export!(scope)) + end + end +end diff --git a/test/trinity/receipts/verifier_test.exs b/test/trinity/receipts/verifier_test.exs index 3b77b15..5e16554 100644 --- a/test/trinity/receipts/verifier_test.exs +++ b/test/trinity/receipts/verifier_test.exs @@ -3,12 +3,16 @@ defmodule Trinity.Receipts.VerifierTest do @moduledoc """ Slice 024, AC3 and AC8: a chain of 1,000 mixed receipts verifies; one byte in any - `signed_payload` gives 1; an unknown key id 5; a compromised key 6; a body claiming + signed body gives 1; an unknown key id 5; a compromised key 6; a body claiming another algorithm verifies against the registry's, not the body's; a foreign family is refused at the scheme string before any signature is checked. + + Since slice 140 the chain the writer builds is v4, so the bytes tampered with are each row's N6 + body and carrier; the hand-built foreign row is v3, which every verifier still reads. """ use Trinity.DataCase, async: false + alias Trinity.N6.Carrier alias Trinity.Receipts alias Trinity.Receipts.{Envelope, Signer, Verifier} @@ -70,27 +74,27 @@ defmodule Trinity.Receipts.VerifierTest do <> end - test "AC3: 1,000 mixed receipts verify with their checkpoints; one byte in any signed_payload is 1", + defp flip_b64(b64, at), do: b64 |> Base.decode64!() |> flip_byte(at) |> Base.encode64() + + test "AC3: 1,000 mixed receipts verify with their checkpoints; one byte in any signed body is 1", %{scope: scope} do export = build(scope, 1_000) assert length(export["receipts"]) == 1_000 assert length(export["checkpoints"]) >= 10 assert {:ok, %{receipts: 1_000}} = Verifier.verify(export) - # A byte inside the JSON text of a random row, not on a structural character: still 1, - # because the hash no longer matches, before any signature is looked at. - for seq <- [1, 7, 500, 1_000] do + # A byte inside the body of a random row: still 1, because its `statement_id`, which is its + # `receipt_hash`, no longer matches, before any signature is looked at. + for seq <- [1, 7, 500, 1_000], at <- [2, 9] do tampered = update_in(export["receipts"], fn rows -> Enum.map(rows, fn r -> - if r["seq"] == seq, do: Map.update!(r, "signed_payload", &flip_byte(&1, 2)), else: r + if r["seq"] == seq, do: Map.update!(r, "body_b64", &flip_b64(&1, at)), else: r end) end) assert {:error, 1, reason} = Verifier.verify(tampered) - - assert elem(reason, 0) in [:hash_mismatch, :body_column_mismatch, :body_not_json], - inspect(reason) + assert reason == {:hash_mismatch, seq}, inspect(reason) assert Verifier.exit_code({:error, 1, reason}) == 1 end @@ -131,13 +135,25 @@ defmodule Trinity.Receipts.VerifierTest do export, "receipts", Enum.map(rows, fn r -> - if r["kind"] == "decision", - do: Map.put(r, "signature_b64", Base.encode64(:crypto.strong_rand_bytes(64))), - else: r + if r["kind"] == "decision" do + {:ok, parts} = r["carrier_b64"] |> Base.decode64!() |> Carrier.decode() + forged = %{parts | signature: flip_byte(parts.signature, 5)} + Map.put(r, "carrier_b64", Base.encode64(Carrier.encode(forged))) + else + r + end end) ) assert {:error, 1, {:signature_invalid, _}} = Verifier.verify(forged) + + # A signed kind with its carrier removed is not an unsigned row: it is refused. + stripped = + update_in(export["receipts"], fn rows -> + Enum.map(rows, &if(&1["kind"] == "decision", do: Map.delete(&1, "carrier_b64"), else: &1)) + end) + + assert {:error, 1, {:signature_missing, 4}} = Verifier.verify(stripped) end test "AC8: the algorithm comes from the registry, not the body; a foreign family is refused at the scheme string", @@ -154,6 +170,8 @@ defmodule Trinity.Receipts.VerifierTest do foreign_impl = Signer.impl(foreign) foreign_scheme = foreign_impl.scheme() here_scheme = Signer.impl(here).scheme() + # The hand-built foreign row below is v3, a scheme every verifier reads. + foreign_v3 = "receipt_v3_#{foreign}" # Mutant 1 (the registry lookup dropped): a receipt whose scheme names the foreign family # while its key's registry row says the chain's must be refused for the family mismatch, @@ -170,30 +188,9 @@ defmodule Trinity.Receipts.VerifierTest do assert {:error, 1, {:scheme_family_mismatch, 1, ^foreign_scheme, ^here_s}} = Verifier.verify(claimed) - # The same with the body and the hash rewritten to agree with the column (an attacker who - # controls the row controls all three): the registry row's family still refuses the scheme. - rebuilt = - update_in(export["receipts"], fn rows -> - Enum.map(rows, fn r -> - if r["seq"] == 1 do - body = r["signed_payload"] |> JSON.decode!() |> Map.put("scheme", foreign_scheme) - payload = Envelope.canonical(body) - bytes = Envelope.pae(Envelope.receipt_type(foreign_scheme), payload) - - %{ - r - | "scheme" => foreign_scheme, - "signed_payload" => payload, - "receipt_hash" => Envelope.hash(bytes) - } - else - r - end - end) - end) - - assert {:error, 1, {:scheme_family_mismatch, 1, ^foreign_scheme, ^here_s}} = - Verifier.verify(rebuilt) + # Before slice 140 the body named the scheme too, and a second mutant rewrote body, hash and + # column to agree. A v4 body names no family (the key's registration does, D-140-2), so the + # column is the only place a family can be claimed, and the case above is the whole of it. # Mutant 2 (the scheme check dropped): a receipt of the foreign family with its own key in # the registry, presented to a verifier told to accept only the chain's family, is refused @@ -218,7 +215,7 @@ defmodule Trinity.Receipts.VerifierTest do foreign_row = %{ "key_id" => foreign_id, "algorithm" => Atom.to_string(foreign), - "scheme" => foreign_scheme, + "scheme" => foreign_v3, "jwk" => jwk, "public_key_b64" => Base.encode64(pub), "status" => "active", @@ -227,7 +224,7 @@ defmodule Trinity.Receipts.VerifierTest do } body = %{ - "scheme" => foreign_scheme, + "scheme" => foreign_v3, "seq" => 1, "chain_scope" => "other", "prev_hash" => nil, @@ -240,7 +237,7 @@ defmodule Trinity.Receipts.VerifierTest do } payload = Envelope.canonical(body) - bytes = Envelope.pae(Envelope.receipt_type(foreign_scheme), payload) + bytes = Envelope.pae(Envelope.receipt_type(foreign_v3), payload) foreign_export = %{ "receipts" => [ @@ -249,7 +246,7 @@ defmodule Trinity.Receipts.VerifierTest do "seq" => 1, "prev_hash" => nil, "receipt_hash" => Envelope.hash(bytes), - "scheme" => foreign_scheme, + "scheme" => foreign_v3, "kind" => "decision", "signed_payload" => payload, "signature_b64" => Base.encode64(sig_fun.(bytes)), @@ -264,7 +261,7 @@ defmodule Trinity.Receipts.VerifierTest do if foreign_impl.available?(), do: assert({:ok, %{receipts: 1}} = Verifier.verify(foreign_export)) - assert {:error, 1, {:scheme_not_allowed, 1, ^foreign_scheme}} = + assert {:error, 1, {:scheme_not_allowed, 1, ^foreign_v3}} = Verifier.verify(foreign_export, schemes: [here_scheme]) end diff --git a/test/trinity/secrets_boot_node_test.exs b/test/trinity/secrets_boot_node_test.exs index 72430fa..611f63c 100644 --- a/test/trinity/secrets_boot_node_test.exs +++ b/test/trinity/secrets_boot_node_test.exs @@ -180,7 +180,8 @@ defmodule Trinity.SecretsBootNodeTest do Enum.find_value(1..50, fn _ -> Trinity.Receipts.boot_receipt() || (Process.sleep(100) && nil) end) - IO.puts("MOVE " <> JSON.encode!(JSON.decode!(boot.signed_payload)["subject"]["secrets_migration"])) + # Slice 140: the subject the v4 body's ForeignRef names, as stored. + IO.puts("MOVE " <> JSON.encode!(JSON.decode!(boot.subject_bytes)["secrets_migration"])) {:ok, export} = Trinity.Receipts.export("boot") IO.puts("COUNT " <> Integer.to_string(length(export["receipts"]))) IO.puts("VERIFY " <> inspect(Trinity.Receipts.Verifier.verify(export |> JSON.encode!() |> JSON.decode!()))) diff --git a/test/trinity/sessions/fs_turn_receipt_test.exs b/test/trinity/sessions/fs_turn_receipt_test.exs index 191b943..c82576d 100644 --- a/test/trinity/sessions/fs_turn_receipt_test.exs +++ b/test/trinity/sessions/fs_turn_receipt_test.exs @@ -58,7 +58,7 @@ defmodule Trinity.Sessions.FsTurnReceiptTest do |> Receipts.list(kind: "decision") |> Enum.filter(&(&1.subject["tool"] == "fs_read")) - [fs] = JSON.decode!(decision.signed_payload)["decision"]["fs"] + [fs] = Trinity.TestSupport.ReceiptBody.of(decision)["decision"]["fs"] assert fs["decision"] == "allow" assert fs["canonical_path"] == Path.join(root, "a.txt") assert fs["root"] == root diff --git a/test/trinity/tools/fs/secrets_reach_test.exs b/test/trinity/tools/fs/secrets_reach_test.exs index 5944b82..8332e50 100644 --- a/test/trinity/tools/fs/secrets_reach_test.exs +++ b/test/trinity/tools/fs/secrets_reach_test.exs @@ -166,7 +166,7 @@ defmodule Trinity.Tools.FS.SecretsReachTest do fs = scope |> Receipts.list(kind: "decision") - |> Enum.flat_map(&(JSON.decode!(&1.signed_payload)["decision"]["fs"] || [])) + |> Enum.flat_map(&(Trinity.TestSupport.ReceiptBody.of(&1)["decision"]["fs"] || [])) allowed = Enum.find(fs, &(&1["decision"] == "allow")) denied = Enum.find(fs, &(&1["decision"] == "deny")) @@ -200,7 +200,7 @@ defmodule Trinity.Tools.FS.SecretsReachTest do assert {:ok, _, _} = run(ctx, "fs_grep", %{"pattern" => "PRIVATE"}) [query] = Receipts.list(scope, kind: "query") - fs = JSON.decode!(query.signed_payload)["decision"]["fs"] + fs = Trinity.TestSupport.ReceiptBody.of(query)["decision"]["fs"] # The key is inventoried, so the inode check names it before the link count would. assert [%{"deny_rule" => "protected_inode", "nlink" => 2, "decision" => "deny"} = entry] = fs diff --git a/test/trinity/tools/memory_test.exs b/test/trinity/tools/memory_test.exs index 4e5d729..dbc0700 100644 --- a/test/trinity/tools/memory_test.exs +++ b/test/trinity/tools/memory_test.exs @@ -50,7 +50,7 @@ defmodule Trinity.Tools.MemoryTest do [decision | effects] = Receipts.list(scope) assert decision.kind == "decision" - assert JSON.decode!(decision.signed_payload)["decision"] == %{ + assert Trinity.TestSupport.ReceiptBody.of(decision)["decision"] == %{ "outcome" => "allow", "basis" => "persona", "reason" => nil diff --git a/test/trinity_web/auth/access_receipts_test.exs b/test/trinity_web/auth/access_receipts_test.exs index 1a5e90d..0174662 100644 --- a/test/trinity_web/auth/access_receipts_test.exs +++ b/test/trinity_web/auth/access_receipts_test.exs @@ -36,7 +36,7 @@ defmodule TrinityWeb.Auth.AccessReceiptsTest do defp as(roles, sub), do: build_conn() |> log_in_web(roles, sub: sub, iss: @iss) |> elem(0) defp bodies(scope) do - for r <- Receipts.list(scope), do: {r, JSON.decode!(r.signed_payload)} + for r <- Receipts.list(scope), do: {r, Trinity.TestSupport.ReceiptBody.of(r)} end test "an approval decided on the page names its decider, on the row and on the signed receipt", diff --git a/test/trinity_web/auth/local_token_test.exs b/test/trinity_web/auth/local_token_test.exs index a93cde7..40c6aca 100644 --- a/test/trinity_web/auth/local_token_test.exs +++ b/test/trinity_web/auth/local_token_test.exs @@ -112,7 +112,7 @@ defmodule TrinityWeb.Auth.LocalTokenTest do assert conn |> recycle() |> get("/settings/export.tar.gz") |> Map.fetch!(:status) == 200 [receipt] = Trinity.Receipts.list(scope) - principal = JSON.decode!(receipt.signed_payload)["subject"]["principal"] + principal = Trinity.TestSupport.ReceiptBody.of(receipt)["subject"]["principal"] assert %{"sub" => "local_token", "iss" => "local_token", "mode" => "local_token"} = principal end diff --git a/test/trinity_web/live/approval_resume_test.exs b/test/trinity_web/live/approval_resume_test.exs index 985424d..7a4966e 100644 --- a/test/trinity_web/live/approval_resume_test.exs +++ b/test/trinity_web/live/approval_resume_test.exs @@ -133,7 +133,7 @@ defmodule TrinityWeb.ApprovalResumeTest do decisions = Enum.filter(rows, &(&1.kind == "decision")) outcomes = - for r <- decisions, {:ok, body} <- [JSON.decode(r.signed_payload)] do + for r <- decisions, body <- [Trinity.TestSupport.ReceiptBody.of(r)] do get_in(body, ["decision", "outcome"]) end @@ -196,7 +196,7 @@ defmodule TrinityWeb.ApprovalResumeTest do |> Enum.find(&(&1.subject_ref == "approval_dropped:#{id}:#{approval.id}")) assert row, "the drop was not receipted" - assert {:ok, body} = JSON.decode(row.signed_payload) + body = Trinity.TestSupport.ReceiptBody.of(row) assert get_in(body, ["decision", "outcome"]) == "dropped" assert get_in(body, ["decision", "reason"]) =~ "no held call" end