From 7a1efd004e639e0510d5773e0a9c35a8bb79c246 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 21 Oct 2026 19:24:22 -0400 Subject: [PATCH 1/3] feat(s144): complete slice 144 (N6 v0.1 verdicts: exit 7 with one finding for a defect in verified bytes, the Ed25519 rule of r7 section 5.3, the v3-to-v4 crossing reported, no legacy row after an N6 row) Signed-off-by: Ayla Croft --- CHANGELOG.md | 15 ++ bin/verify_receipt.exs | 18 +- docs/07-security-model.md | 24 +++ docs/regulated/receipts-verify.md | 27 ++- lib/mix/tasks/trinity.receipts.verify.ex | 33 ++-- lib/trinity/n6/carrier.ex | 58 ++++-- lib/trinity/n6/cose.ex | 13 +- lib/trinity/n6/ed25519.ex | 79 ++++++++- lib/trinity/n6/profile.ex | 12 +- lib/trinity/receipts/v4.ex | 27 ++- lib/trinity/receipts/verifier.ex | 35 +++- scripts/n6_vectors.exs | 38 +++- test/fixtures/n6/README.md | 7 +- test/fixtures/n6/SHA384SUMS | 16 +- .../sign1/ed25519_body_format_control.bin | Bin 0 -> 219 bytes .../ed25519_body_format_control.ctx.json | 1 + .../sign1/ed25519_body_format_control.expect | 1 + .../n6/cose/sign1/ed25519_key_identity.bin | Bin 0 -> 219 bytes .../cose/sign1/ed25519_key_identity.ctx.json | 1 + .../n6/cose/sign1/ed25519_key_identity.expect | 1 + .../n6/cose/sign1/ed25519_no_issuer.expect | 2 +- .../n6/cose/sign1/ed25519_r_identity.bin | Bin 0 -> 219 bytes .../n6/cose/sign1/ed25519_r_identity.ctx.json | 1 + .../n6/cose/sign1/ed25519_r_identity.expect | 1 + .../sign1/ed25519_typ_schema_mismatch.expect | 2 +- .../n6/thumbprint/ed25519_identity.pub | Bin 0 -> 32 bytes test/support/ed25519_points.ex | 71 ++++++++ test/support/n6_vectors.ex | 9 +- test/support/receipts/legacy_rows.ex | 13 +- test/trinity/n6/carrier_test.exs | 15 +- test/trinity/n6/r7_alignment_test.exs | 9 +- test/trinity/n6/verdicts_test.exs | 149 ++++++++++++++++ test/trinity/receipts/v4_test.exs | 3 +- test/trinity/receipts/verdicts_test.exs | 165 ++++++++++++++++++ 34 files changed, 771 insertions(+), 75 deletions(-) create mode 100644 test/fixtures/n6/cose/sign1/ed25519_body_format_control.bin create mode 100644 test/fixtures/n6/cose/sign1/ed25519_body_format_control.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/ed25519_body_format_control.expect create mode 100644 test/fixtures/n6/cose/sign1/ed25519_key_identity.bin create mode 100644 test/fixtures/n6/cose/sign1/ed25519_key_identity.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/ed25519_key_identity.expect create mode 100644 test/fixtures/n6/cose/sign1/ed25519_r_identity.bin create mode 100644 test/fixtures/n6/cose/sign1/ed25519_r_identity.ctx.json create mode 100644 test/fixtures/n6/cose/sign1/ed25519_r_identity.expect create mode 100644 test/fixtures/n6/thumbprint/ed25519_identity.pub create mode 100644 test/support/ed25519_points.ex create mode 100644 test/trinity/n6/verdicts_test.exs create mode 100644 test/trinity/receipts/verdicts_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 28aba40..35ae987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,21 @@ both profiles and refusing on the national security profile by mode or by build pass on that profile shown by the decision's own tests, since no CI runtime carries the validated provider. +### `slice/144`: a verified signature makes its signer answer for what it signed + +When a signature verifies and the bytes it covers break the profile's rules, Trinity now says the +key's holder signed them (exit 7, "accused") instead of calling the record damaged (exit 1), and it +names the one defect it found. This applies to single signed statements and to receipt rows, in the +in-app verifier and in the standalone script. Ed25519 keys and signatures are held to the profile +publisher's rule, so a weak key, or a signature built to verify for any message, is refused; OpenSSL +alone accepts both. A receipt chain that moved from the older format to the new one reports the move, +and an older-format row after a new one is refused. + +Verified by the test suite, each case first shown failing on the previous tree: the accusation and +its single finding at the carrier and in both receipt verifiers; every small-order, mixed-order and +malformed key and signature the publisher lists, refused, with 1,000 generated keys accepted; and the +chain's one crossing reported and a later older-format row refused. + ### `slice/142`: signed statements follow the profile's v0.1 draft The checks Trinity runs on its signed-statement format now follow the profile publisher's v0.1 diff --git a/bin/verify_receipt.exs b/bin/verify_receipt.exs index 5ad770a..a4cac8c 100755 --- a/bin/verify_receipt.exs +++ b/bin/verify_receipt.exs @@ -121,6 +121,14 @@ defmodule VerifyReceipt do Enum.each(findings, &IO.puts("finding: #{inspect(&1)}")) System.halt(0) + # Slice 144: the issuer's verified key signed nonconforming bytes. The finding names the + # defect and the last line on stderr is the verdict code (N6 draft v0.1-r7 section 9.3). + {:error, 7, {:accuse, at, verdict, findings}} -> + IO.puts(:stderr, "#{label(7)}: #{inspect(at)}") + for {:nonconforming, code} <- findings, do: IO.puts(:stderr, "finding: nonconforming #{code}") + IO.puts(:stderr, "verdict_code: #{verdict}") + System.halt(7) + {:error, code, reason} -> IO.puts(:stderr, "#{label(code)}: #{inspect(reason)}") System.halt(code) @@ -132,6 +140,7 @@ defmodule VerifyReceipt do defp label(1), do: "invalid" defp label(5), do: "trust not established" defp label(6), do: "compromised key" + defp label(7), do: "accused" defp walk([], _reg, _schemes, _seq, _prev, _n6, findings), do: {:ok, findings} @@ -139,13 +148,20 @@ defmodule VerifyReceipt do defp walk([%{"scheme" => "receipt_v4_" <> _} = r | rest], reg, schemes, prev_seq, prev, n6, findings) do seq = r["seq"] + # Slice 144: the first v4 row after a v2 or v3 row is the chain's one crossing, reported. + crossing = if prev != nil and not n6?(prev), do: [{:crossing, seq}], else: [] + with :ok <- expect(seq == prev_seq + 1, 1, {:seq_gap, prev_seq, 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) + walk(rest, reg, schemes, seq, r, n6, findings ++ crossing ++ info.findings) end end + # Slice 144: a chain crosses once, so a v2 or v3 row after a v4 row is refused. + defp walk([r | _rest], _reg, _schemes, _prev_seq, %{"scheme" => "receipt_v4_" <> _}, _n6, _f), + do: {:error, 1, {:legacy_after_n6, r["seq"]}} + defp walk([r | rest], reg, schemes, prev_seq, prev, n6, findings) do seq = r["seq"] diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 9591e92..909ff5e 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -730,6 +730,30 @@ and the publisher's further Ed25519 rule on small-order points, which are the ne code Trinity answers, `alg_unavailable` (the runtime cannot run the header's algorithm), is not in the draft's list; it is kept and has been put to the publisher. +### What a verified signature makes the signer answer for (Slice 144, as built) + +The profile's rule, applied at the carrier and in both receipt verifiers: a defect in bytes that a +signature verifies over is the act of whoever holds the key, because nobody else could have produced +it, so it is answered as an accusation (exit 7) and not as a damaged copy (exit 1). + +- **Signature first, then the body.** A carrier's signature is verified before its body is read, + and a receipt row's before its columns are compared. The first defect met in the verified body is + named in one finding, `finding: nonconforming `, and judging stops there. The verdict is + `accuse_single_signed_noncanonical` when the encoding itself is broken and + `accuse_single_signed_nonconforming` when a body rule is. A body under a signature that does not + verify is never read. +- **Ed25519 is held to one rule.** RFC 8032 permits two verification equations and says nothing about + small-order points, so two conforming verifiers can disagree, and under a small-order key one + signature verifies for many messages: measured, OpenSSL 3.0.13 and 3.5.9 accept the identity key + with a signature of R the identity and S = 0 for every message, and accept R the identity from the + key's holder under an ordinary key. Trinity now refuses a key that does not decode strictly, is the + identity or has a small-order part (`key_invalid`), and a signature whose R does not decode + strictly or is of small order, or whose S is not below the group order (`signature_invalid`), + before OpenSSL's equation runs. That makes an accusation under an Ed25519 key well founded. +- **A chain crosses once.** The first v4 receipt after a v2 or v3 receipt is reported as the + crossing, and an older-format receipt after a v4 one is refused, so the format history of a chain + cannot be interleaved. + ## 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/regulated/receipts-verify.md b/docs/regulated/receipts-verify.md index 59a8d7f..ba72495 100644 --- a/docs/regulated/receipts-verify.md +++ b/docs/regulated/receipts-verify.md @@ -36,7 +36,8 @@ From `lib/mix/tasks/trinity.receipts.verify.ex`: > mix trinity.receipts.verify --file receipts.json > > Exit codes, the same vocabulary as `bin/verify_receipt.exs`: 0 verified, 1 invalid, -> 2 usage, 5 trust not established (a key id the registry does not know), 6 compromised key. +> 2 usage, 5 trust not established (a key id the registry does not know), 6 compromised key, +> 7 accused (a row's signature verifies over bytes that break the profile's rules). ## Exporting a scope first @@ -61,12 +62,25 @@ Both entry points use the same codes. Treat them as the finding, not the console | 2 | Usage | | 5 | Trust not established: a key id the registry does not know | | 6 | Compromised key | +| 7 | Accused: a v4 row's signature verifies under its registered key over bytes that break the profile's rules | Codes 5 and 6 are the interesting ones for an assessor. **5 is not a failure of the chain**; it says the chain refers to a key the reader cannot place, which is a question about key distribution rather than about the records. **6 is a refusal**: the registry marks that key compromised and the verifier will not vouch for rows signed under it. +**7 is about the signer, not the copy** (slice 144). A v4 row's signature is checked before its body +is read. If the signature verifies and the body breaks a rule, the defect is in bytes the key's holder +signed, which nobody else could have produced: both entry points print the defect on one line and the +verdict code last, for example + + finding: nonconforming issuer_missing + verdict_code: accuse_single_signed_nonconforming + +`accuse_single_signed_noncanonical` means the defect is in the encoding itself; `..._nonconforming` +that the encoding is sound and a rule of the body is broken. The same bytes under a signature that +does not verify are exit 1, because anyone handling the row could have produced them. + ## What the verifier actually checks `lib/trinity/receipts/verifier.ex`, per row, in order: @@ -79,6 +93,17 @@ will not vouch for rows signed under it. 5. The stored columns agree with the signed body, so a row edited after the fact is caught. 6. For a signed kind, the signature verifies under the registry's public key. +A v4 row is checked in a different order (slice 144): its signature first, then its body, then the +columns against the body. An Ed25519 key or signature is held to the profile's rule, which `:crypto` +alone does not apply: the key must decode strictly, must not be the identity and must have no +small-order part, and the signature's R must decode strictly and not be of small order, with S below +the group order. Without that rule, one signature under a small-order key verifies for every +message. + +A chain that began before v4 crosses into it once. The first v4 row after a v2 or v3 row is reported +as `finding: {:crossing, }`, and a v2 or v3 row after a v4 row is refused +(`{:legacy_after_n6, }`, exit 1), however good its link and signature. + Checkpoints are verified as well, so query receipts that are covered rather than individually signed are still accounted for. diff --git a/lib/mix/tasks/trinity.receipts.verify.ex b/lib/mix/tasks/trinity.receipts.verify.ex index 21e5462..17d9b52 100644 --- a/lib/mix/tasks/trinity.receipts.verify.ex +++ b/lib/mix/tasks/trinity.receipts.verify.ex @@ -11,7 +11,8 @@ defmodule Mix.Tasks.Trinity.Receipts.Verify do mix trinity.receipts.verify --file receipts.json Exit codes, the same vocabulary as `bin/verify_receipt.exs`: 0 verified, 1 invalid, - 2 usage, 5 trust not established (a key id the registry does not know), 6 compromised key. + 2 usage, 5 trust not established (a key id the registry does not know), 6 compromised key, + 7 accused (a row's signature verifies over bytes that break the profile's rules). """ use Boundary, classify_to: Trinity use Mix.Task @@ -40,23 +41,31 @@ defmodule Mix.Tasks.Trinity.Receipts.Verify do outcome = Verifier.verify(export) code = Verifier.exit_code(outcome) + report(outcome, code) + if code != 0, do: exit({:shutdown, code}) + end - case outcome do - {: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)}") + defp report({:ok, %{receipts: n, checkpoints: c} = ok}, code) do + Mix.shell().info("verified: #{n} receipts, #{c} checkpoints, exit #{code}") - {:error, ^code, reason} -> - Mix.shell().error("#{label(code)}: #{inspect(reason)}, exit #{code}") - end + # 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)}") + end - if code != 0, do: exit({:shutdown, code}) + # Slice 144: the issuer's verified key signed nonconforming bytes; the finding names the defect + # and the verdict code comes last. + defp report({:error, 7, {:accuse, at, verdict, findings}}, 7) do + Mix.shell().error("#{label(7)}: #{inspect(at)}, exit 7") + for {:nonconforming, c} <- findings, do: Mix.shell().error("finding: nonconforming #{c}") + Mix.shell().error("verdict_code: #{verdict}") end + defp report({:error, code, reason}, code), + do: Mix.shell().error("#{label(code)}: #{inspect(reason)}, exit #{code}") + defp label(1), do: "invalid" defp label(5), do: "trust not established" defp label(6), do: "compromised key" + defp label(7), do: "accused" end diff --git a/lib/trinity/n6/carrier.ex b/lib/trinity/n6/carrier.ex index c1c569f..203b069 100644 --- a/lib/trinity/n6/carrier.ex +++ b/lib/trinity/n6/carrier.ex @@ -28,16 +28,21 @@ defmodule Trinity.N6.Carrier do 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`. + Once the signature verifies, the body it covers is judged, and the first defect met in it is the + issuer's act, not anyone's (draft v0.1-r7 sections 9.2 and 9.3; slice 144): exit 7, ACCUSE, with + one finding `{:nonconforming, code}`. The verdict is `accuse_single_signed_noncanonical` when the + code comes from the codec (steps D0 to D18) and `accuse_single_signed_nonconforming` otherwise. + Judging stops at that first defect, so one carrier gives at most one finding. The same bytes under + a signature that does not verify are exit 1, `signature_invalid`, and the body is never read. + + With `schemas:` the body is checked by `Trinity.N6.Body` in full (B1 to B8, with B7 against the + `typ`). Without it, by the rules a single-signer entry point always holds: a map + (`body_not_map`); key 0 the schema number the `typ`'s entry names (`typ_schema_mismatch`, B7); + key 1, `issuer`, present (`issuer_missing`, B8), so two signers over identical bytes cannot pass + a copy across issuers as the same statement. """ - alias Trinity.N6.{CBOR, COSE, Profile} + alias Trinity.N6.{Body, CBOR, COSE, Profile} @doc "The carrier bytes for a Sign1's parts." @spec encode(COSE.parts()) :: binary() @@ -67,6 +72,7 @@ defmodule Trinity.N6.Carrier do @spec verify(binary(), keyword()) :: {:ok, %{exit: 0, verdict_code: :claim, kid: binary(), typ: String.t()}} | {:error, 1 | 5, Profile.code()} + | {:error, 7, {Profile.code(), [{:nonconforming, Profile.code()}]}} def verify(bytes, opts) when is_binary(bytes) do table = Keyword.fetch!(opts, :typ) entry_point = Keyword.get(opts, :entry, :nss) @@ -74,20 +80,42 @@ defmodule Trinity.N6.Carrier do with {:ok, parts} <- decode(bytes), {:ok, %{entry: entry}} <- COSE.decode_protected(parts.protected, table, entry_point), :ok <- single(entry), - {:ok, verdict} <- COSE.verify(parts, opts), - :ok <- body(parts.payload, entry) do - {:ok, verdict} + {:ok, verdict} <- COSE.verify(parts, opts) do + case body(parts.payload, entry, verdict.typ, opts) do + :ok -> {:ok, verdict} + {:error, step, code} -> {:error, 7, {accusation(step), [{:nonconforming, code}]}} + end else {:error, exit, code} -> {:error, exit, code} {:error, code} -> {:error, Profile.exit_of(code), code} end end - defp body(payload, entry) do + defp accusation(:codec), do: :accuse_single_signed_noncanonical + defp accusation(:body), do: :accuse_single_signed_nonconforming + + # The body, after the signature: the codec's code first (`:codec`), then the body rules (`:body`). + defp body(payload, entry, typ, opts) do case CBOR.decode(payload) do - {:ok, %{} = body} -> body_rules(body, entry) - {:ok, _other} -> {:error, :body_not_map} - {:error, code, _offset} -> {:error, code} + {:ok, value} -> tag(:body, body_rules(value, entry, typ, opts)) + {:error, code, _offset} -> {:error, :codec, code} + end + end + + defp tag(_step, :ok), do: :ok + defp tag(step, {:error, code}), do: {:error, step, code} + + defp body_rules(value, entry, typ, opts) do + case Keyword.fetch(opts, :schemas) do + {:ok, schemas} -> + check = Body.check(value, :any, schemas, typ: typ, test_mode: opts[:test_mode] == true) + if match?({:ok, _}, check), do: :ok, else: check + + :error when is_map(value) -> + body_rules(value, entry) + + :error -> + {:error, :body_not_map} end end diff --git a/lib/trinity/n6/cose.ex b/lib/trinity/n6/cose.ex index 34f88a1..72b568a 100644 --- a/lib/trinity/n6/cose.ex +++ b/lib/trinity/n6/cose.ex @@ -434,8 +434,10 @@ defmodule Trinity.N6.COSE do # K2. defp key_valid(@esp384, pub), do: if(EC2.valid_key?(pub), do: :ok, else: {:error, :key_invalid}) + # K2 with the publisher's key rule (r7 section 5.3): decoded strictly, not the identity, [L]A the + # identity. defp key_valid(@ed25519, pub), - do: if(match?({:ok, _}, Ed25519.decode(pub)), do: :ok, else: {:error, :key_invalid}) + do: if(Ed25519.valid_key?(pub), do: :ok, else: {:error, :key_invalid}) defp key_valid(@mldsa87, pub), do: if(byte_size(pub) == 2592, do: :ok, else: {:error, :key_invalid}) @@ -449,12 +451,19 @@ defmodule Trinity.N6.COSE do if byte_size(sig) == @sizes[alg].signature, do: :ok, else: {:error, :signature_encoding} end + # For Ed25519 the R and S checks of r7 section 5.3 run first; one code covers all four + # conditions, because a verify call returns one result. defp signature_valid(alg, tbs, sig, pub) do - if crypto_verify(alg, tbs, sig, pub), do: :ok, else: {:error, :signature_invalid} + if r_and_s(alg, sig) and crypto_verify(alg, tbs, sig, pub), + do: :ok, + else: {:error, :signature_invalid} rescue e in ErlangError -> unavailable(e) end + defp r_and_s(@ed25519, sig), do: Ed25519.valid_r_and_s?(sig) + defp r_and_s(_alg, _sig), do: true + defp crypto_verify(@mldsa87, tbs, sig, pub), do: :crypto.verify(:mldsa87, :none, tbs, sig, pub) defp crypto_verify(@ed25519, tbs, sig, pub), diff --git a/lib/trinity/n6/ed25519.ex b/lib/trinity/n6/ed25519.ex index 9426213..d3c5e7e 100644 --- a/lib/trinity/n6/ed25519.ex +++ b/lib/trinity/n6/ed25519.ex @@ -2,9 +2,17 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.N6.Ed25519 do @moduledoc """ - Ed25519 points as the N6 profile reads them (draft v0.1-r7 section 5.2, K2; slice 142): the - strict decoding of RFC 8032 section 5.1.3, which `:crypto` does not apply (measured on OpenSSL - 3.0.13 and 3.5.9: a key whose y is not below p is accepted). + Ed25519 points as the N6 profile reads them: the strict decoding of RFC 8032 section 5.1.3 + (draft v0.1-r7 section 5.2, K2; slice 142), and the publisher's verification rule (r7 section 5.3, + decided 2026-10-21; slice 144), which `:crypto` does not apply. Measured on OpenSSL 3.0.13 and + 3.5.9: the identity key with R the identity and S = 0 verifies for every message; a key whose y + is not below p is accepted; R the identity, made by the key's holder, verifies. + + * A key is valid when it decodes strictly, is not the identity, and [L]A is the identity: full + public key validation (NIST SP 800-186 D.1.3.2). It refuses every small-order and mixed-order + point and never a key RFC 8032 key generation made. + * A signature's R must decode strictly and not be of small order ([8]R not the identity), and + its S must be below L. The equation itself, cofactorless, stays `:crypto`'s. A 32-byte string decodes when y, its low 255 bits, is below p; x, recovered from y, exists; and x is not 0 with the sign bit set. Only public values pass through here, so the arithmetic needs no @@ -19,9 +27,51 @@ defmodule Trinity.N6.Ed25519 do # sqrt(-1) mod p, 2^((p - 1) / 4). @sqrt_m1 19_681_161_376_707_505_956_807_079_304_988_542_015_446_066_515_923_890_162_744_021_073_123_829_784_752 + # L, the order of the base point (RFC 8032 section 5.1). + @l (1 <<< 252) + 27_742_317_777_372_353_535_851_937_790_883_648_493 + @typedoc "A point in affine coordinates." @type point :: {non_neg_integer(), non_neg_integer()} + @doc """ + Whether 32 bytes are a public key the profile accepts (r7 section 5.3, the key rule). The [L]A + check costs about half a millisecond, and a receipt chain verifies every row under one key, so + a key found valid is remembered in the calling process; one found invalid is checked again. + """ + @spec valid_key?(binary()) :: boolean() + def valid_key?(bytes) when is_binary(bytes) do + memo = {__MODULE__, :valid_key, bytes} + + if Process.get(memo) do + true + else + valid = validate_key(bytes) + if valid, do: Process.put(memo, true) + valid + end + end + + defp validate_key(bytes) do + case decode(bytes) do + {:ok, a} -> not identity?(extended(a)) and identity?(multiply(@l, extended(a))) + :error -> false + end + end + + @doc """ + Whether a 64-byte signature's R and S pass the profile's checks before the equation: R decodes + strictly and is not of small order, and S is below L. + """ + @spec valid_r_and_s?(binary()) :: boolean() + def valid_r_and_s?(<>) when s < @l do + case decode(r) do + {:ok, point} -> not identity?(multiply(8, extended(point))) + :error -> false + end + end + + def valid_r_and_s?(_signature), do: false + @doc "Strictly decodes a 32-byte encoding of a point (RFC 8032 section 5.1.3)." @spec decode(binary()) :: {:ok, point()} | :error def decode(<<_::binary-32>> = bytes) do @@ -54,6 +104,29 @@ defmodule Trinity.N6.Ed25519 do end end + # Extended coordinates (X, Y, Z, T), x = X/Z, y = Y/Z, xy = T/Z; the addition of RFC 8032 + # section 5.1.4, which also doubles. + defp extended({x, y}), do: {x, y, 1, mod(x * y)} + + defp add({x1, y1, z1, t1}, {x2, y2, z2, t2}) do + a = mod((y1 - x1) * (y2 - x2)) + b = mod((y1 + x1) * (y2 + x2)) + c = mod(2 * @d * t1 * t2) + d = mod(2 * z1 * z2) + {e, f, g, h} = {b - a, d - c, d + c, b + a} + {mod(e * f), mod(g * h), mod(f * g), mod(e * h)} + end + + defp multiply(k, point), do: multiply(k, point, {0, 1, 1, 0}) + defp multiply(0, _point, acc), do: acc + + defp multiply(k, point, acc) do + acc = if (k &&& 1) == 1, do: add(acc, point), else: acc + multiply(k >>> 1, add(point, point), acc) + end + + defp identity?({x, y, z, _t}), do: mod(x) == 0 and mod(y - z) == 0 + defp mod(n), do: Integer.mod(n, @p) defp pow(b, e), do: :crypto.mod_pow(b, e, @p) |> :binary.decode_unsigned() end diff --git a/lib/trinity/n6/profile.ex b/lib/trinity/n6/profile.ex index 4143e30..b89823c 100644 --- a/lib/trinity/n6/profile.ex +++ b/lib/trinity/n6/profile.ex @@ -28,7 +28,8 @@ defmodule Trinity.N6.Profile do Every refusal in `Trinity.N6` is one of the atoms in `codes/0`, which is r7's closed registry (its section 9.4) restricted to what this tree can emit, plus the codes marked `:trinity`. Each - carries its exit class (0 verified, 1 invalid, 2 usage, 5 no trust) and the steps that emit it. + carries its exit class (0 verified, 1 invalid, 2 usage, 5 no trust, 7 accuse) and the steps that + emit it. ## Limits @@ -139,6 +140,11 @@ defmodule Trinity.N6.Profile do foreign_digest_mismatch: {1, :draft, [:attachment]}, # Section 9. claim: {0, :draft, [:verdict]}, + # Section 9.2: a defect in bytes a verified signature covers is the issuer's act. `` is + # `single` for a single-signer statement; `_noncanonical` when the defect's code came from the + # codec (D0 to D18), `_nonconforming` otherwise (G-479). Slice 144. + accuse_single_signed_noncanonical: {7, :draft, [:attribution]}, + accuse_single_signed_nonconforming: {7, :draft, [:attribution]}, usage: {2, :draft, [:invocation]}, policy_stale_or_unknown_key: {5, :draft, [:key_resolution]}, # The runtime cannot run the header's algorithm (ML-DSA-87, Ed25519 or ESP384; FIPS mode refuses @@ -167,7 +173,7 @@ defmodule Trinity.N6.Profile do Every code, with its exit class, whether the draft defines it (`:draft`) or this tree does (`:trinity`), and the steps that emit it. """ - @spec codes() :: %{code() => {0 | 1 | 2 | 5, :draft | :trinity, [atom()]}} + @spec codes() :: %{code() => {0 | 1 | 2 | 5 | 7, :draft | :trinity, [atom()]}} def codes, do: @codes @doc """ @@ -199,6 +205,6 @@ defmodule Trinity.N6.Profile do 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 + @spec exit_of(code()) :: 0 | 1 | 2 | 5 | 7 def exit_of(code), do: @codes |> Map.fetch!(code) |> elem(0) end diff --git a/lib/trinity/receipts/v4.ex b/lib/trinity/receipts/v4.ex index 68ef6d5..15da072 100644 --- a/lib/trinity/receipts/v4.ex +++ b/lib/trinity/receipts/v4.ex @@ -129,7 +129,7 @@ defmodule Trinity.Receipts.V4 do 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} + %{kind: body_kind(kind), version: schema_number(kind), typ: typ(kind), fields: fields} end end @@ -245,19 +245,22 @@ defmodule Trinity.Receipts.V4 do """ @spec verify_row(map(), binary() | nil, keyword()) :: {:ok, %{link: binary(), at: integer(), node: String.t(), findings: [tuple()]}} - | {:error, 1 | 5, term()} + | {:error, 1 | 5 | 7, term()} def verify_row(r, prev_link, opts) do seq = r["seq"] kind = r["kind"] + # A signed kind's carrier is verified before its body is read (slice 144): a defect in bytes the + # signature covers is then the issuer's act, exit 7, and a body under a signature that does not + # verify is never judged. 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 <- signature(kind, r, body, opts, 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 <- issuer(value, opts, seq) do {:ok, %{ link: link, @@ -316,9 +319,15 @@ defmodule Trinity.Receipts.V4 do {: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 + case Carrier.verify(carrier, + keys: keys(row), + typ: typ_table(), + entry: entry(opts), + schemas: schemas() + ) do {:ok, _} -> :ok {:error, 5, :alg_unavailable} -> {:error, 5, {:alg_unavailable, at, row["algorithm"]}} + {:error, 7, {verdict, findings}} -> {:error, 7, {:accuse, at, verdict, findings}} {:error, exit, code} -> {:error, exit, {:n6, at, code}} end end @@ -384,12 +393,13 @@ defmodule Trinity.Receipts.V4 do 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()} + @spec verify_checkpoint(map(), binary(), keyword()) :: :ok | {:error, 1 | 5 | 7, 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 <- signature("checkpoint", cp, body, opts, at), {:ok, value} <- decode_body(body, "checkpoint", at), :ok <- expect( @@ -398,9 +408,8 @@ defmodule Trinity.Receipts.V4 do cp["tail_hash"] == hex(tail_link), 1, {:checkpoint_body_mismatch, last} - ), - :ok <- issuer(value, opts, at) do - signature("checkpoint", cp, body, opts, at) + ) do + issuer(value, opts, at) end end diff --git a/lib/trinity/receipts/verifier.ex b/lib/trinity/receipts/verifier.ex index 6869197..27ec110 100644 --- a/lib/trinity/receipts/verifier.ex +++ b/lib/trinity/receipts/verifier.ex @@ -34,7 +34,14 @@ defmodule Trinity.Receipts.Verifier do 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. + a registered key it cannot use), `6` a key the registry marks compromised, and, since slice 144, + `7` accuse: a v4 row or checkpoint whose signature verifies under its registered key over bytes + that break the profile's rules (`{:accuse, at, verdict_code, [{:nonconforming, code}]}`). + + Since slice 144 the chain's crossing from the earlier formats is reported: the first v4 row after + a v2 or v3 row adds the finding `{:crossing, seq}`, and a v2 or v3 row after a v4 row is refused + (`{:legacy_after_n6, seq}`, exit 1), because a chain crosses once (N6 draft v0.1-r7 section 3.9). + Both names are this tree's until the profile names them (its VB3). """ alias Trinity.Receipts.{Envelope, Signer, V4} @@ -42,12 +49,12 @@ defmodule Trinity.Receipts.Verifier do @type outcome :: {:ok, %{receipts: non_neg_integer(), checkpoints: non_neg_integer(), findings: [tuple()]}} - | {:error, code :: 1 | 5 | 6, reason :: term()} + | {:error, code :: 1 | 5 | 6 | 7, reason :: term()} @signed_kinds ~w(decision effect boot cap) @doc "Exit code for an outcome." - @spec exit_code(outcome()) :: 0 | 1 | 5 | 6 + @spec exit_code(outcome()) :: 0 | 1 | 5 | 6 | 7 def exit_code({:ok, _}), do: 0 def exit_code({:error, code, _}), do: code @@ -106,14 +113,26 @@ defmodule Trinity.Receipts.Verifier do # 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, []} + cond do + n6?(r) -> + with {:ok, found} <- check_v4(r, prev_seq, prev, registry, schemes, n6), + do: {:ok, crossing(r, prev) ++ found} + + prev != nil and n6?(prev) -> + {:error, 1, {:legacy_after_n6, r["seq"]}} + + true -> + with :ok <- check_receipt(r, prev_seq, prev && prev["receipt_hash"], registry, schemes), + do: {:ok, []} end end + defp n6?(r), do: Signer.n6?(r["scheme"] || "") + + # The one link from a v2 or v3 row to the first v4 row after it. + defp crossing(_r, nil), do: [] + defp crossing(r, prev), do: if(n6?(prev), do: [], else: [{:crossing, r["seq"]}]) + defp check_v4(r, prev_seq, prev, registry, schemes, n6) do seq = r["seq"] diff --git a/scripts/n6_vectors.exs b/scripts/n6_vectors.exs index 2bace07..182e0d0 100644 --- a/scripts/n6_vectors.exs +++ b/scripts/n6_vectors.exs @@ -45,6 +45,15 @@ defmodule N6Vectors do @ed_sig "44f793ebf76a054b798344893478ce4d19985eb9b8e813e6a99fa2920cc45f955c8fa32ce1307b6673c5418419cb81b0e35c7018966622bcf179ecd623740703" @ed_sig_issuer "ff0ca9413ae325a29a74f71ff62f8cc8d3197d2aa715ac1e43aa74c112c0d51b5325be34e6c097d5e120f9646fdaf7f0c40739e3750a5b49275f2d04bcc07d00" @ed_sig_schema_2 "23b8e030b47818e0d28cad5805c155bed332b4fcb9169001acc38f3868278fb5ad4600d75f1ff50f2f693668860ef507ce1ee81c7a8fe9ae46eb83d083649904" + # Slice 144. `@ed_sig_cf` is over the body with key 1 whose text is "slic", U+200B, "44" (nine + # bytes, a format control). `@ed_sig_r_identity` is a signature the key's holder can make over the + # good sample with R the identity and S = k * a: the equation holds, `:crypto` accepts it, and the + # profile's rule refuses it (N6 draft v0.1-r7 section 5.3). `@ed_identity_sig` is R the identity and + # S = 0, which verifies under the identity key for every message. + @ed_sig_cf "833ba6de729c7004d69ea496d6a0b13108546aadf3349142dc299f5c1618c0c76c2ac213c59b625f5e0bc56620f6b18ffc48f20768efcfd1b626ba1f4384ee0d" + @ed_sig_r_identity "01000000000000000000000000000000000000000000000000000000000000003f768ff3c0dbfe1fcbc42afeb83d20d7e0f865042c63984e9ef3b5467688d905" + @ed_identity "0100000000000000000000000000000000000000000000000000000000000000" + @ed_identity_sig "01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" # 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 @@ -116,6 +125,8 @@ defmodule N6Vectors do <<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)) + # Slice 144: the Ed25519 identity point, registered as a key on purpose. + write(root, "thumbprint/ed25519_identity.pub", h(@ed_identity)) for {id, bytes, exit, code, entry, keys} <- sign1_samples(kid, ed_kid, ec_kid), do: sample(root, id, bytes, exit, code, entry, keys) @@ -508,6 +519,11 @@ defmodule N6Vectors do # 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") + cf_body = h("01 58 24") <> body(h("01"), "slic\u200b44") + + identity_kid = :crypto.hash(:sha384, h("a3 01 01 20 06 21 58 20") <> h(@ed_identity)) + identity_protected = h("a3 01 32 04 58 30") <> identity_kid <> h("10 78 2c") <> @typ_ed + identity_sign1 = h("84 58 65") <> identity_protected <> h("a0 f6 58 40") <> h(@ed_identity_sig) 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 @@ -522,8 +538,15 @@ defmodule N6Vectors do {"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}, + # Slice 144: a defect in bytes a verified signature covers is the issuer's act, exit 7, with + # the defect named in one finding (N6 draft v0.1-r7 sections 9.2 and 9.3). + {"ed25519_no_issuer", carrier.(no_issuer, ed_sign1.(h(@ed_sig))), 7, "accuse_single_signed_nonconforming", "commercial", nil}, + {"ed25519_typ_schema_mismatch", carrier.(schema_2, ed_sign1.(h(@ed_sig_schema_2))), 7, "accuse_single_signed_nonconforming", "commercial", nil}, + {"ed25519_body_format_control", carrier.(cf_body, ed_sign1.(h(@ed_sig_cf))), 7, "accuse_single_signed_noncanonical", "commercial", nil}, + # Slice 144, the Ed25519 rule (r7 section 5.3): R the identity, and the identity key. + {"ed25519_r_identity", carrier.(payload_field, ed_sign1.(h(@ed_sig_r_identity))), 1, "signature_invalid", "commercial", nil}, + {"ed25519_key_identity", carrier.(payload_field, identity_sign1), 1, "key_invalid", "commercial", + [%{"alg" => -19, "public" => "thumbprint/ed25519_identity.pub"}]}, {"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}, @@ -563,6 +586,13 @@ defmodule N6Vectors do 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) + # Slice 144: the one finding each exit-7 sample names. + @findings %{ + "ed25519_no_issuer" => "issuer_missing", + "ed25519_typ_schema_mismatch" => "typ_schema_mismatch", + "ed25519_body_format_control" => "text_format_control" + } + @default_keys [ %{"alg" => -19, "public" => "thumbprint/rfc8032_test1_ed25519.pub"}, %{"alg" => -50, "public" => "thumbprint/acvp_mldsa87_tg3_tc51.pub"}, @@ -571,7 +601,9 @@ defmodule N6Vectors do 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"})) + expect = %{"exit" => exit, "verdict_code" => code, "source" => "hand"} + expect = if exit == 7, do: Map.put(expect, "findings", ["nonconforming " <> Map.fetch!(@findings, id)]), else: expect + write(root, "cose/sign1/#{id}.expect", json(expect)) ctx = %{ "entry_point" => entry, diff --git a/test/fixtures/n6/README.md b/test/fixtures/n6/README.md index ef63655..16d331f 100644 --- a/test/fixtures/n6/README.md +++ b/test/fixtures/n6/README.md @@ -113,8 +113,11 @@ 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_no_issuer` | `commercial` | 7 | `accuse_single_signed_nonconforming` | +| `ed25519_typ_schema_mismatch` | `commercial` | 7 | `accuse_single_signed_nonconforming` | +| `ed25519_body_format_control` | `commercial` | 7 | `accuse_single_signed_noncanonical` | +| `ed25519_r_identity` | `commercial` | 1 | `signature_invalid` | +| `ed25519_key_identity` | `commercial` | 1 | `key_invalid` | | `ed25519_key_registered_as_mldsa87` | `commercial` | 1 | `alg_not_permitted` | | `carrier_key0_envelope` | `commercial` | 1 | `carrier_malformed` | | `carrier_extra_key` | `commercial` | 1 | `carrier_malformed` | diff --git a/test/fixtures/n6/SHA384SUMS b/test/fixtures/n6/SHA384SUMS index 12a2753..395eb92 100644 --- a/test/fixtures/n6/SHA384SUMS +++ b/test/fixtures/n6/SHA384SUMS @@ -1,4 +1,4 @@ -209663b006912666cb71b51c3964d091a2ca6815846887153fcaa77203c59426c5568b5a463f8f450f2e38f0267a5289 README.md +e019f789175f83405efe56274c9fb43f4b9aec769e56cd2565e4784eeac722fbf66b8ff9904c57eac22cd18f7f587cc8 README.md 12541e60a919c5350898baade01540aa3ded55897b594afc54501031c55571b9172cae3bff8eb40f5d20e7b2f6c47e9b VERSION e80ceee3df61c92398d9f44ea69efc5ca52602afd424d63ec799735a3466f836ff7208cc45a21918b4ea314e8beb77a8 body/negative/bool_schema_version/true.cbor c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d1380539e54ba616ca2 body/negative/bool_schema_version/true.expect @@ -60,12 +60,21 @@ be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa01 dc1dd699b12fc164247068dd734880a71e6de8ba86cc81598906c639a9f0a9bde75aaf799a9b1ddc18da338ac95e75b3 cose/sign1/carrier_payload_text.bin be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/carrier_payload_text.ctx.json 6952a6d06aabc1ad97dc2bf970c0df73095a9776ac2f36b106e87d1a8c23cba55efe0d1e833874f001dc2c4f2d9a2016 cose/sign1/carrier_payload_text.expect +569c84c41829a01be6e8a85c26af327b26c32402423e69eacc800e17e67c1efc3e52cf815e260b64805304131543059c cose/sign1/ed25519_body_format_control.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/ed25519_body_format_control.ctx.json +05ab5f6c57d3c60d542011d718da6f10cfd90c8fd5c323b41ccd1570f45eccb274e44f7b1566bf70a409287a9cf17651 cose/sign1/ed25519_body_format_control.expect +a539ed35efbc975e46187f8f77bed3c966f148afa84be0b2cbb0d137fd4120400347e57c932e34c876f38b659ac49ad5 cose/sign1/ed25519_key_identity.bin +66a4283ffd5e73b3dd894d7fc28e6c64cb68092bf9e52f6fc777a65f0813a5122ef47834317b60dc8756371f273d0642 cose/sign1/ed25519_key_identity.ctx.json +559e883df29a99d9aede9dc2242dd4b0ac030f5eff7b1e21ecaec46c7c953279b3f5a69f3ba41273bf4be7a63a4d2332 cose/sign1/ed25519_key_identity.expect 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 +7ef411a7b79720873f023ea549629f18ac85949c9f0beb7d770235b6e05b46f82f7a5c551a7f16fc2c299d31c91a4fbb cose/sign1/ed25519_no_issuer.expect +f6fda951c7c06017458c27ae1f3bbdd19033a2419d3ef9e91c5c78b75e35a672c0fa41c0aa4f733e8f05622a7d13b29e cose/sign1/ed25519_r_identity.bin +be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/ed25519_r_identity.ctx.json +d9dac7ae9073b03b97b273d7bdd1102b2bb52c35ff68efd68dfef3d980038b66924993403b5be93d134ab49fbaa819f6 cose/sign1/ed25519_r_identity.expect f0008ea3e37ce5d1d75d2de4de24baeb04a62fd27dab336f0b7c3755121f15c8d0fbc19a0d8b36386563a70e1a0300c8 cose/sign1/ed25519_signature_flipped.bin be8a1922fab91a7110ed5f74aad78634c7307bae41df1a3564226d132049d25d1900dc87ddbbaa012e583f4ebc470b7f cose/sign1/ed25519_signature_flipped.ctx.json d9dac7ae9073b03b97b273d7bdd1102b2bb52c35ff68efd68dfef3d980038b66924993403b5be93d134ab49fbaa819f6 cose/sign1/ed25519_signature_flipped.expect @@ -77,7 +86,7 @@ bbda60353f6402c61af91794a157904f0308469ccc4d7991d48802a8293860ebc83817246c748b26 2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff cose/sign1/ed25519_single_at_nss.expect 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 +519ae0b08ebccba38fa94c5a62444fda6e961dbbd3d4515e2cbd1f3a98a800238faed6e22ee1566fd33cffa23049b96f 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 @@ -424,6 +433,7 @@ a9b5e6706fec09c6f59343ef85022f91767b3939965b27a3053852638968d916b7b9142c315f241d abe17bb2fb3ef487fe33624be45a67742d153d0d046896919b0927517cf0c1a16cdd1f485addbcf4713c738d9696db4b header/profile-table.json aab9ecd6c7320915435631cae66cf8bc25a4c33d782c8fe5f81a55eb40e3df2d81450d101f068d52bed4f37a2ec0492e thumbprint/acvp_mldsa87_tg3_tc51.kid 954bc653b6b8f323b050607cb2db6560e0a6634ec020083892bfb7c2cd4370b9b3b44161739b9cc9a6a4d56ac17d3bc2 thumbprint/acvp_mldsa87_tg3_tc51.pub +dd606b49d7658a5eae905d593271c280819f92eb1a9a4986057aedc0a5f2eaea99052904718f6d83f16ad209d793f253 thumbprint/ed25519_identity.pub 266701110a216f563c0162bfdc90ca8518428bc7dd1e7dc4223edbd8dcf7f0deaaad28f3fe951d00931c44446350909d thumbprint/p384_x_leading_zero.kid 4a3d8f8b26132da377242ef893e609a63a8cdc1a7798f9842f67465001570ae444d14a6918526677fc320b22b4e39835 thumbprint/p384_x_leading_zero.pub 4bdeebaec52cef342a3959fc433690358b70349e97146fd132d0e0d97c533d82fb6293a47520656b16c2ebd7eebee862 thumbprint/rfc6979_a25_p256.pub diff --git a/test/fixtures/n6/cose/sign1/ed25519_body_format_control.bin b/test/fixtures/n6/cose/sign1/ed25519_body_format_control.bin new file mode 100644 index 0000000000000000000000000000000000000000..fffea964f8cd0c22d1b28c5197e3fcef4a0bfd3d GIT binary patch literal 219 zcmV<103`pT02=@Q00RM7B%=TU0d#b7X>MtBc{+4ub963gZgyd8X=D&-b8Km2;(&`Z zGy;TJWupNy1XwVnPUK2ARgNbs2NV~RZoAa`UnK>h<=4|o>I&DSm{rA8jbfz_eu4cS zz**x&KFHh~-3on^Q7sKYCV^Wh(_-IrYDHphK~Ab3$S&Q~6eCkpLrc9fL+#}J;?xD- sA{-bQ@B{m@{?7+)|C2v`MC;!UTZQWnex$PKB+u}h_j#*ZS;tLQ080K&PXGV_ literal 0 HcmV?d00001 diff --git a/test/fixtures/n6/cose/sign1/ed25519_r_identity.ctx.json b/test/fixtures/n6/cose/sign1/ed25519_r_identity.ctx.json new file mode 100644 index 0000000..4d289c7 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_r_identity.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_r_identity.expect b/test/fixtures/n6/cose/sign1/ed25519_r_identity.expect new file mode 100644 index 0000000..6ea7428 --- /dev/null +++ b/test/fixtures/n6/cose/sign1/ed25519_r_identity.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"signature_invalid"} diff --git a/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect index 2c21ccc..20c4796 100644 --- a/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect +++ b/test/fixtures/n6/cose/sign1/ed25519_typ_schema_mismatch.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"typ_schema_mismatch"} +{"exit":7,"findings":["nonconforming typ_schema_mismatch"],"source":"hand","verdict_code":"accuse_single_signed_nonconforming"} diff --git a/test/fixtures/n6/thumbprint/ed25519_identity.pub b/test/fixtures/n6/thumbprint/ed25519_identity.pub new file mode 100644 index 0000000000000000000000000000000000000000..9870828e3938f3276a5cd4a38d05b7281f0cd502 GIT binary patch literal 32 KcmZQ%zz+ZbKmY;& literal 0 HcmV?d00001 diff --git a/test/support/ed25519_points.ex b/test/support/ed25519_points.ex new file mode 100644 index 0000000..29abcaf --- /dev/null +++ b/test/support/ed25519_points.ex @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestSupport.Ed25519Points do + @moduledoc """ + Edwards25519 arithmetic for building the Ed25519 vectors of N6 draft v0.1-r7 section 12.1 item 18 + (slice 144): points of small and mixed order, and signatures their holder can make. Written + apart from `Trinity.N6.Ed25519`, in affine coordinates from RFC 8032 section 5.1.4's formulas, + so the tests do not check that module against itself. + """ + + import Bitwise + + @p (1 <<< 255) - 19 + @d Integer.mod(-121_665 * :binary.decode_unsigned(:crypto.mod_pow(121_666, @p - 2, @p)), @p) + @l (1 <<< 252) + 27_742_317_777_372_353_535_851_937_790_883_648_493 + # The base point, and one point of order 8 (RFC 8032; the small-order list of the literature). + @b "5866666666666666666666666666666666666666666666666666666666666666" + @t8 "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a" + + def l, do: @l + def base, do: decode!(Base.decode16!(@b, case: :lower)) + def order8, do: decode!(Base.decode16!(@t8, case: :lower)) + + def add({x1, y1}, {x2, y2}) do + k = mod(@d * x1 * x2 * y1 * y2) + {mod((x1 * y2 + x2 * y1) * inv(1 + k)), mod((y1 * y2 + x1 * x2) * inv(1 - k))} + end + + def multiply(0, _point), do: {0, 1} + + def multiply(n, point) do + half = multiply(n >>> 1, point) + double = add(half, half) + if (n &&& 1) == 1, do: add(double, point), else: double + end + + def encode({x, y}), do: <> + + # RFC 8032 section 5.1.3, for points the tests build from known-good encodings only. + def decode!(<>) do + y = n &&& (1 <<< 255) - 1 + u = mod(y * y - 1) + v = mod(@d * y * y + 1) + x = pow(mod(u * inv(v)), div(@p + 3, 8)) + + x = + if mod(v * x * x - u) == 0, + do: x, + else: mod(x * pow(2, div(@p - 1, 4))) + + 0 = mod(v * x * x - u) + if (x &&& 1) == n >>> 255, do: {x, y}, else: {mod(-x), y} + end + + @doc "The clamped secret scalar of an RFC 8032 seed (section 5.1.5)." + def scalar(seed) do + <> = :crypto.hash(:sha512, seed) + <> = h + bor(a &&& (1 <<< 254) - 8, 1 <<< 254) + end + + @doc "k = SHA-512(R || A || M) mod L, over the bytes as given." + def challenge(r, a, message) do + <> = :crypto.hash(:sha512, r <> a <> message) + rem(k, @l) + end + + defp mod(n), do: Integer.mod(n, @p) + defp inv(n), do: pow(n, @p - 2) + defp pow(b, e), do: :crypto.mod_pow(mod(b), e, @p) |> :binary.decode_unsigned() +end diff --git a/test/support/n6_vectors.ex b/test/support/n6_vectors.ex index 393afd7..b073a6c 100644 --- a/test/support/n6_vectors.ex +++ b/test/support/n6_vectors.ex @@ -94,7 +94,12 @@ defmodule Trinity.TestSupport.N6Vectors do base = Path.rootname(bin) %{"exit" => exit, "verdict_code" => code} = - (base <> ".expect") |> File.read!() |> JSON.decode!() + expect = (base <> ".expect") |> File.read!() |> JSON.decode!() + + # Slice 144: an exit-7 sample names its one finding, `nonconforming `. + findings = + for "nonconforming " <> c <- Map.get(expect, "findings", []), + do: {:nonconforming, String.to_atom(c)} ctx = (base <> ".ctx.json") |> File.read!() |> JSON.decode!() @@ -114,7 +119,7 @@ defmodule Trinity.TestSupport.N6Vectors do # `to_atom`, not `to_existing_atom`: which atoms exist depends on which modules a test run has # loaded first, and these strings come from the checked-in fixtures, not from input. - {id, File.read!(bin), exit, String.to_atom(code), opts} + {id, File.read!(bin), exit, String.to_atom(code), findings, opts} end end diff --git a/test/support/receipts/legacy_rows.ex b/test/support/receipts/legacy_rows.ex index b4c29d7..09f8fd6 100644 --- a/test/support/receipts/legacy_rows.ex +++ b/test/support/receipts/legacy_rows.ex @@ -16,14 +16,21 @@ defmodule Trinity.TestReceipts.LegacyRows do 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 + def plant(scope, kinds), do: plant(scope, kinds, 1, nil) + + @doc """ + Plants `kinds` as v3 rows from `first_seq`, the first linked to `prev` (a receipt hash, or nil). + Slice 144 uses it to put a v3 row after a v4 row, whose link and signature are both good, which + a chain must still refuse. + """ + def plant(scope, kinds, first_seq, prev) 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 -> + |> Enum.with_index(first_seq) + |> Enum.map_reduce(prev, fn {kind, seq}, prev -> body = %{ "scheme" => scheme, "seq" => seq, diff --git a/test/trinity/n6/carrier_test.exs b/test/trinity/n6/carrier_test.exs index 9b482d9..717a2a0 100644 --- a/test/trinity/n6/carrier_test.exs +++ b/test/trinity/n6/carrier_test.exs @@ -20,14 +20,19 @@ defmodule Trinity.N6.CarrierTest do alias Trinity.TestSupport.{Ed25519Oracle, N6Vectors} # The outcomes that need the signature checked. - @judged [:claim, :signature_invalid, :typ_schema_mismatch, :issuer_missing] + @judged [ + :claim, + :signature_invalid, + :accuse_single_signed_noncanonical, + :accuse_single_signed_nonconforming + ] test "AC5: every sample gives its expected outcome under its context" do samples = N6Vectors.samples() assert length(samples) >= 26 ed25519? = Ed25519Oracle.verifies?() - for {id, bytes, exit, code, opts} <- samples do + for {id, bytes, exit, code, findings, opts} <- samples do answer = Carrier.verify(bytes, opts) cond do @@ -37,6 +42,10 @@ defmodule Trinity.N6.CarrierTest do code == :claim -> assert {:ok, %{exit: 0, verdict_code: :claim}} = answer, id + exit == 7 -> + assert answer == {:error, 7, {code, findings}}, id + assert [_one] = findings, id + true -> assert answer == {:error, exit, code}, id assert Profile.exit_of(code) == exit @@ -74,7 +83,7 @@ defmodule Trinity.N6.CarrierTest do end test "AC4: a pair typ is refused before the signature, whatever the signature" do - [{_, bytes, _, :wrong_carrier_for_typ, opts}] = + [{_, bytes, _, :wrong_carrier_for_typ, [], opts}] = Enum.filter(N6Vectors.samples(), &(elem(&1, 3) == :wrong_carrier_for_typ)) # The sample's signature is 4,627 zero bytes: refused for its typ, never judged. diff --git a/test/trinity/n6/r7_alignment_test.exs b/test/trinity/n6/r7_alignment_test.exs index 7087945..ad0d80c 100644 --- a/test/trinity/n6/r7_alignment_test.exs +++ b/test/trinity/n6/r7_alignment_test.exs @@ -107,12 +107,17 @@ defmodule Trinity.N6.R7AlignmentTest do policy_stale_or_unknown_key unknown_origin descriptor_unverified foreign_bytes_missing degraded_kv_only degraded_kh_only fork_kv fork_kh fork_pair epoch_regression key_revoked_compromised_kv key_revoked_compromised_kh issuer_mismatch_kh - accuse_kv_issuer_mismatch accuse_foreign_origin_signed_kv accuse_logged_nonconforming)a + accuse_kv_issuer_mismatch accuse_foreign_origin_signed_kv accuse_logged_nonconforming + accuse_single_signed_noncanonical accuse_single_signed_nonconforming)a test "every draft code is in r7's registry, with r7's exit class; only two are this tree's" do for {code, {exit, :draft, _steps}} <- Profile.codes() do assert code in @r7, "#{code} is marked :draft but is not in r7 section 9.4" - assert exit in [0, 1, 2, 5], "#{code}" + + expected = + if String.starts_with?(Atom.to_string(code), "accuse_"), do: [7], else: [0, 1, 2, 5] + + assert exit in expected, "#{code}" end ours = for {code, {_exit, :trinity, _steps}} <- Profile.codes(), do: code diff --git a/test/trinity/n6/verdicts_test.exs b/test/trinity/n6/verdicts_test.exs new file mode 100644 index 0000000..2d5f551 --- /dev/null +++ b/test/trinity/n6/verdicts_test.exs @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.VerdictsTest do + # Slice 144: exit 7 for a defect in bytes a verified signature covers, and the Ed25519 rule of + # N6 draft v0.1-r7 section 5.3, at the carrier. + use ExUnit.Case, async: true + + alias Trinity.N6.{Carrier, CBOR, COSE, Ed25519} + alias Trinity.TestSupport.{Ed25519Oracle, Ed25519Points, N6Vectors} + + @typ "application/vnd.example.test-ed25519.v1+cose" + @key N6Vectors.rfc8032_key() + + defp carrier(body, signature_fun, pub \\ @key.public) do + protected = COSE.protected_header(COSE.thumbprint(-19, pub), @typ, nil, -19) + sig = signature_fun.(COSE.to_be_signed(protected, body)) + Carrier.encode(%{protected: protected, payload: body, signature: sig}) + end + + defp signed(tbs), do: :crypto.sign(:eddsa, :none, tbs, [@key.private, :ed25519]) + defp verify(bytes, pub \\ @key.public), do: Carrier.verify(bytes, opts(pub)) + + defp opts(pub), + do: [ + keys: fn _kid -> {:ok, %{alg: -19, public: pub}} end, + typ: N6Vectors.typ_table(), + entry: :commercial + ] + + # A signature the key's holder can make with R chosen: S = r + k * a. + defp chosen_r(r_point, r_scalar, tbs) do + r = Ed25519Points.encode(r_point) + k = Ed25519Points.challenge(r, @key.public, tbs) + s = rem(r_scalar + k * Ed25519Points.scalar(@key.private), Ed25519Points.l()) + r <> <> + end + + describe "AC1: ACCUSE in the single-signer carrier" do + test "a verified carrier's body defect is exit 7 with one finding; unverified, it is never read" do + if Ed25519Oracle.verifies?() do + no_issuer = CBOR.encode!(%{0 => 1, 16 => {:text, "slice 144"}}) + + assert verify(carrier(no_issuer, &signed/1)) == + {:error, 7, + {:accuse_single_signed_nonconforming, [nonconforming: :issuer_missing]}} + + # Under the wrong `typ` (key 0 of another schema) and without key 1: B7 runs first and + # stops the judging, so the one finding is B7's. + both = CBOR.encode!(%{0 => 2, 16 => {:text, "slice 144"}}) + + assert verify(carrier(both, &signed/1)) == + {:error, 7, + {:accuse_single_signed_nonconforming, [nonconforming: :typ_schema_mismatch]}} + + # A codec defect (D16) in signed bytes is `_noncanonical`. + good = CBOR.encode!(%{0 => 1, 1 => {:text, "trinity:test.invalid"}, 16 => {:text, "abc"}}) + cf = :binary.replace(good, "abc", "​") + assert byte_size(cf) == byte_size(good) + + assert verify(carrier(cf, &signed/1)) == + {:error, 7, + {:accuse_single_signed_noncanonical, [nonconforming: :text_format_control]}} + + # The same bodies under a signature that does not verify: exit 1, the body unread. + for body <- [no_issuer, both, cf] do + bad = fn tbs -> signed(tbs <> "x") end + assert verify(carrier(body, bad)) == {:error, 1, :signature_invalid} + end + else + IO.puts("verdicts_test: Ed25519 refused on this runtime; AC1's signed cases not run") + end + end + end + + describe "AC3: the Ed25519 key rule" do + test "every key the rule names is refused, and generated keys are not" do + identity = Ed25519Points.encode({0, 1}) + t8 = Ed25519Points.order8() + a = Ed25519Points.multiply(Ed25519Points.scalar(@key.private), Ed25519Points.base()) + + refused = %{ + "identity" => identity, + "(0, -1)" => <<0xEC>> <> :binary.copy(<<0xFF>>, 30) <> <<0x7F>>, + "order 4" => :binary.copy(<<0>>, 32), + "order 8" => Ed25519Points.encode(t8), + "mixed order" => Ed25519Points.encode(Ed25519Points.add(a, t8)), + "x = 0, sign bit set" => <<1>> <> :binary.copy(<<0>>, 30) <> <<0x80>>, + "no point" => <<2>> <> :binary.copy(<<0>>, 31), + "y = p + 1" => <<0xEE>> <> :binary.copy(<<0xFF>>, 30) <> <<0x7F>> + } + + for {name, key} <- refused, do: refute(Ed25519.valid_key?(key), name) + assert Ed25519Points.encode(a) == @key.public + + for _ <- 1..1000 do + {pub, _} = :crypto.generate_key(:eddsa, :ed25519) + assert Ed25519.valid_key?(pub) + end + end + + test "the identity key with R the identity and S = 0 is key_invalid, though the equation holds" do + identity = Ed25519Points.encode({0, 1}) + body = CBOR.encode!(%{0 => 1, 1 => {:text, "trinity:test.invalid"}}) + bytes = carrier(body, fn _ -> identity <> :binary.copy(<<0>>, 32) end, identity) + assert verify(bytes, identity) == {:error, 1, :key_invalid} + end + end + + describe "AC4: the Ed25519 signature rule" do + test "R and S the rule refuses, each signature_invalid, made by the key's holder" do + body = CBOR.encode!(%{0 => 1, 1 => {:text, "trinity:test.invalid"}}) + t8 = Ed25519Points.order8() + l = Ed25519Points.l() + r = 0x1234_5678_9ABC + r_point = Ed25519Points.multiply(r, Ed25519Points.base()) + + cases = %{ + "R the identity, S = k * a" => fn tbs -> chosen_r({0, 1}, 0, tbs) end, + "R of order 8, S = k * a" => fn tbs -> chosen_r(t8, 0, tbs) end, + "R mixed order, S = r + k * a" => fn tbs -> + chosen_r(Ed25519Points.add(r_point, t8), r, tbs) + end, + "R the identity as y = p + 1" => fn tbs -> + r = <<0xEE>> <> :binary.copy(<<0xFF>>, 30) <> <<0x7F>> + k = Ed25519Points.challenge(r, @key.public, tbs) + r <> <> + end, + "R with x = 0 and the sign bit set" => fn tbs -> + <<_::binary-32, s::binary-32>> = chosen_r(r_point, r, tbs) + <<1>> <> :binary.copy(<<0>>, 30) <> <<0x80>> <> s + end, + "S + L" => fn tbs -> + <> = chosen_r(r_point, r, tbs) + rb <> <> + end + } + + for {name, sig} <- cases, + do: assert(verify(carrier(body, sig)) == {:error, 1, :signature_invalid}, name) + + # The control: R chosen honestly verifies, where the runtime does Ed25519. + honest = verify(carrier(body, &chosen_r(r_point, r, &1))) + + if Ed25519Oracle.verifies?(), + do: assert({:ok, %{verdict_code: :claim}} = honest), + else: assert(honest == {:error, 5, :alg_unavailable}) + end + end +end diff --git a/test/trinity/receipts/v4_test.exs b/test/trinity/receipts/v4_test.exs index af0d42a..90806a7 100644 --- a/test/trinity/receipts/v4_test.exs +++ b/test/trinity/receipts/v4_test.exs @@ -309,7 +309,8 @@ defmodule Trinity.Receipts.V4Test do 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) + # Slice 144: the one crossing, at the first v4 row after the v3 rows. + assert {:ok, %{receipts: 6, findings: [crossing: 4]}} = 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. diff --git a/test/trinity/receipts/verdicts_test.exs b/test/trinity/receipts/verdicts_test.exs new file mode 100644 index 0000000..99e1041 --- /dev/null +++ b/test/trinity/receipts/verdicts_test.exs @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.VerdictsTest do + # Slice 144: the receipt verifiers' exit 7, the Ed25519 key rule on a chain, the crossing and a + # legacy row after an N6 row, each in the in-app verifier and in `bin/verify_receipt.exs`. + use Trinity.DataCase, async: false + + alias Trinity.N6.{CBOR, COSE} + alias Trinity.Receipts + alias Trinity.Receipts.{KeyCustody, V4, Verifier} + alias Trinity.TestReceipts.LegacyRows + + @script Path.expand("../../../bin/verify_receipt.exs", __DIR__) + @lib Path.expand("../../../lib", __DIR__) + @n6_alg %{"ed25519" => -19, "p384" => -51, "mldsa87" => -50} + + setup do + scope = "verdicts:" <> Trinity.UUID.generate() + dir = Path.join(System.tmp_dir!(), "trinity-verdicts-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + + on_exit(fn -> + Receipts.stop_writer(scope) + File.rm_rf!(dir) + end) + + {:ok, scope: scope, dir: dir} + end + + defp export(scope, kinds) do + for k <- kinds, do: {:ok, _} = Receipts.append(scope, %{kind: k, subject: %{"k" => k}}) + :ok = Receipts.stop_writer(scope) + Process.sleep(50) + {:ok, export} = Receipts.export(scope) + export + end + + defp run_script(dir, export) do + script = Path.join(dir, "verify_receipt.exs") + File.cp!(@script, script) + file = Path.join(dir, "export.json") + File.write!(file, JSON.encode!(export)) + + {out, code} = + System.cmd("elixir", [script, file, "--n6", @lib], cd: dir, stderr_to_stdout: true) + + {String.trim(out), code} + end + + # The row at `seq` rebuilt with `edit` applied to its body and signed again by the key in custody, + # so the signature verifies over bytes the profile refuses. + defp resign(export, seq, edit) do + update_in(export["receipts"], fn rows -> + Enum.map(rows, fn + %{"seq" => ^seq, "kind" => kind} = r -> + {:ok, value} = CBOR.decode(Base.decode64!(r["body_b64"])) + body = CBOR.encode!(edit.(value)) + [reg] = Enum.filter(export["registry"], &(&1["key_id"] == r["key_id"])) + alg = Map.fetch!(@n6_alg, reg["algorithm"]) + protected = V4.protected(kind, alg, Base.decode64!(reg["public_key_b64"])) + {:ok, sig} = KeyCustody.sign(COSE.to_be_signed(protected, body)) + {:ok, carrier} = V4.carrier(protected, body, alg, sig) + + %{ + r + | "body_b64" => Base.encode64(body), + "carrier_b64" => Base.encode64(carrier), + "receipt_hash" => V4.hex(V4.statement_id(kind, body)) + } + + r -> + r + end) + end) + end + + describe "AC2: the verifiers answer exit 7" do + test "a signed row whose verified body lacks key 1: one finding, verdict_code last, in both", + %{scope: scope, dir: dir} do + export = export(scope, ["query", "decision"]) |> resign(2, &Map.delete(&1, 1)) + + assert Verifier.verify(export, require_coverage: false) == + {:error, 7, + {:accuse, 2, :accuse_single_signed_nonconforming, + [nonconforming: :issuer_missing]}} + + {out, code} = run_script(dir, export) + assert code == 7 + + assert out |> String.split("\n") |> Enum.take(-2) == [ + "finding: nonconforming issuer_missing", + "verdict_code: accuse_single_signed_nonconforming" + ] + end + end + + describe "AC3: the Ed25519 key rule on a chain" do + test "rows signed under the identity key verify under OpenSSL's equation and are key_invalid", + %{scope: scope, dir: dir} do + identity = <<1>> <> :binary.copy(<<0>>, 31) + sig = identity <> :binary.copy(<<0>>, 32) + export = export(scope, ["decision"]) + + export = + export + |> update_in(["registry"], fn rows -> + Enum.map( + rows, + &%{&1 | "algorithm" => "ed25519", "public_key_b64" => Base.encode64(identity)} + ) + end) + |> update_in(["receipts"], fn rows -> + Enum.map(rows, fn r -> + body = Base.decode64!(r["body_b64"]) + protected = V4.protected(r["kind"], -19, identity) + {:ok, carrier} = V4.carrier(protected, body, -19, sig) + %{r | "scheme" => "receipt_v4_ed25519", "carrier_b64" => Base.encode64(carrier)} + end) + end) + + assert Verifier.verify(export, require_coverage: false) == + {:error, 1, {:n6, 1, :key_invalid}} + + assert {out, 1} = run_script(dir, export) + assert out =~ "{:n6, 1, :key_invalid}" + end + end + + describe "AC5: the crossing" do + test "a chain crossing from v3 to v4 has one crossing finding, in both; one that never crosses has none", + %{scope: scope, dir: dir} do + LegacyRows.plant(scope, ["decision", "query", "decision"]) + {:ok, _} = Receipts.ensure_writer(scope) + crossed = export(scope, ["query", "decision"]) + + assert {:ok, %{findings: [crossing: 4]}} = Verifier.verify(crossed) + assert {out, 0} = run_script(dir, crossed) + assert out =~ "finding: {:crossing, 4}" + + plain = export("verdicts:" <> Trinity.UUID.generate(), ["decision", "query"]) + assert {:ok, %{findings: []}} = Verifier.verify(plain, require_coverage: false) + end + end + + describe "AC6: no legacy row after an N6 row" do + test "a v3 row after a v4 row is refused, though its link and signature are good", + %{scope: scope, dir: dir} do + export = export(scope, ["decision"]) + [last] = export["receipts"] + + # Back into the database, so the export carries the planted row exactly as a writer would. + LegacyRows.plant(scope, ["decision"], 2, last["receipt_hash"]) + {:ok, export} = Receipts.export(scope) + + assert Enum.map(export["receipts"], & &1["scheme"]) |> Enum.map(&String.slice(&1, 0, 10)) == + ["receipt_v4", "receipt_v3"] + + assert Verifier.verify(export, require_coverage: false) == + {:error, 1, {:legacy_after_n6, 2}} + + assert {out, 1} = run_script(dir, export) + assert out =~ "{:legacy_after_n6, 2}" + end + end +end From 643a4cfc02c16456037fd74e763f8d93a53fd197 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 21 Oct 2026 19:36:36 -0400 Subject: [PATCH 2/3] fix(s144): the FIPS leg: the Ed25519 R and S checks decide on every runtime, and the tests no longer assume the runtime does Ed25519 Signed-off-by: Ayla Croft --- lib/trinity/n6/cose.ex | 7 +++++-- test/trinity/n6/carrier_test.exs | 9 +++++++-- test/trinity/n6/verdicts_test.exs | 26 ++++++++++++++++++++------ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/lib/trinity/n6/cose.ex b/lib/trinity/n6/cose.ex index 72b568a..1c4cb1b 100644 --- a/lib/trinity/n6/cose.ex +++ b/lib/trinity/n6/cose.ex @@ -86,9 +86,12 @@ defmodule Trinity.N6.COSE do 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 + header's `alg` (`alg_not_permitted`); for ESP384 a valid P-384 point, for Ed25519 the key rule + of r7 section 5.3 (`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 + `signature_encoding`; the others: `carrier_malformed`); and the signature valid. For Ed25519 the + R and S checks of draft v0.1-r7 section 5.3 come before the runtime is asked, so a signature they + refuse is `signature_invalid` on every runtime, FIPS mode included. The attribution classes 6 and 7 need the profile's key descriptors and are not decided here. ## Keys diff --git a/test/trinity/n6/carrier_test.exs b/test/trinity/n6/carrier_test.exs index 717a2a0..f911b3a 100644 --- a/test/trinity/n6/carrier_test.exs +++ b/test/trinity/n6/carrier_test.exs @@ -9,7 +9,8 @@ defmodule Trinity.N6.CarrierTest do 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 + is looked at. Since slice 144 that includes a signature the Ed25519 R and S checks refuse, which + are run before the runtime is asked. 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. """ @@ -27,6 +28,10 @@ defmodule Trinity.N6.CarrierTest do :accuse_single_signed_nonconforming ] + # Ed25519 samples whose signature r7 section 5.3's R and S checks refuse (slice 144). Those checks + # are the profile's, decided before the runtime is asked, so these keep their code in FIPS mode. + @refused_by_rule ["ed25519_r_identity"] + test "AC5: every sample gives its expected outcome under its context" do samples = N6Vectors.samples() assert length(samples) >= 26 @@ -36,7 +41,7 @@ defmodule Trinity.N6.CarrierTest do answer = Carrier.verify(bytes, opts) cond do - code in @judged and alg(bytes) == -19 and not ed25519? -> + code in @judged and alg(bytes) == -19 and not ed25519? and id not in @refused_by_rule -> assert answer == {:error, 5, :alg_unavailable}, id code == :claim -> diff --git a/test/trinity/n6/verdicts_test.exs b/test/trinity/n6/verdicts_test.exs index 2d5f551..54802b8 100644 --- a/test/trinity/n6/verdicts_test.exs +++ b/test/trinity/n6/verdicts_test.exs @@ -92,8 +92,15 @@ defmodule Trinity.N6.VerdictsTest do for {name, key} <- refused, do: refute(Ed25519.valid_key?(key), name) assert Ed25519Points.encode(a) == @key.public - for _ <- 1..1000 do - {pub, _} = :crypto.generate_key(:eddsa, :ed25519) + # Keys from random seeds by the oracle, not `:crypto`, which refuses Ed25519 in FIPS mode. + for _ <- 1..200 do + seed = :crypto.strong_rand_bytes(32) + + pub = + Ed25519Points.encode( + Ed25519Points.multiply(Ed25519Points.scalar(seed), Ed25519Points.base()) + ) + assert Ed25519.valid_key?(pub) end end @@ -107,19 +114,26 @@ defmodule Trinity.N6.VerdictsTest do end describe "AC4: the Ed25519 signature rule" do + # The R and S checks are the profile's, decided before the runtime is asked, so a signature + # they refuse is signature_invalid on every runtime, FIPS mode included. A mixed-order R passes + # them (it is not of small order) and is refused by the equation, which needs the runtime. test "R and S the rule refuses, each signature_invalid, made by the key's holder" do body = CBOR.encode!(%{0 => 1, 1 => {:text, "trinity:test.invalid"}}) t8 = Ed25519Points.order8() l = Ed25519Points.l() r = 0x1234_5678_9ABC r_point = Ed25519Points.multiply(r, Ed25519Points.base()) + ed25519? = Ed25519Oracle.verifies?() + + mixed = verify(carrier(body, &chosen_r(Ed25519Points.add(r_point, t8), r, &1))) + + if ed25519?, + do: assert(mixed == {:error, 1, :signature_invalid}), + else: assert(mixed == {:error, 5, :alg_unavailable}) cases = %{ "R the identity, S = k * a" => fn tbs -> chosen_r({0, 1}, 0, tbs) end, "R of order 8, S = k * a" => fn tbs -> chosen_r(t8, 0, tbs) end, - "R mixed order, S = r + k * a" => fn tbs -> - chosen_r(Ed25519Points.add(r_point, t8), r, tbs) - end, "R the identity as y = p + 1" => fn tbs -> r = <<0xEE>> <> :binary.copy(<<0xFF>>, 30) <> <<0x7F>> k = Ed25519Points.challenge(r, @key.public, tbs) @@ -141,7 +155,7 @@ defmodule Trinity.N6.VerdictsTest do # The control: R chosen honestly verifies, where the runtime does Ed25519. honest = verify(carrier(body, &chosen_r(r_point, r, &1))) - if Ed25519Oracle.verifies?(), + if ed25519?, do: assert({:ok, %{verdict_code: :claim}} = honest), else: assert(honest == {:error, 5, :alg_unavailable}) end From 33be3384d9f1db046700e080c3e87b02c26978e5 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 21 Oct 2026 20:46:54 -0400 Subject: [PATCH 3/3] test(s144): a codec defect in bytes a verified ML-DSA-87 signature covers is exit 7, noncanonical Signed-off-by: Ayla Croft --- test/trinity/n6/cose_signing_test.exs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/trinity/n6/cose_signing_test.exs b/test/trinity/n6/cose_signing_test.exs index 6c2e812..eea3315 100644 --- a/test/trinity/n6/cose_signing_test.exs +++ b/test/trinity/n6/cose_signing_test.exs @@ -127,8 +127,11 @@ defmodule Trinity.N6.COSESigningTest do end # Slice 139's payload, bare text: its signature verifies, and the carrier refuses what it covers. + # Since slice 144 a codec defect in verified bytes is the signer's act: exit 7, `_noncanonical`. {: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} + + assert Carrier.verify(Carrier.encode(bare), opts) == + {:error, 7, {:accuse_single_signed_noncanonical, [nonconforming: :trailing_bytes]}} end end