Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion bin/verify_receipt.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -132,20 +140,28 @@ 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}

# 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"]

# 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"]

Expand Down
24 changes: 24 additions & 0 deletions docs/07-security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>`, 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
Expand Down
27 changes: 26 additions & 1 deletion docs/regulated/receipts-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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, <seq>}`, and a v2 or v3 row after a v4 row is refused
(`{:legacy_after_n6, <seq>}`, 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.

Expand Down
33 changes: 21 additions & 12 deletions lib/mix/tasks/trinity.receipts.verify.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
58 changes: 43 additions & 15 deletions lib/trinity/n6/carrier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -67,27 +72,50 @@ 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)

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

Expand Down
20 changes: 16 additions & 4 deletions lib/trinity/n6/cose.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -434,8 +437,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})
Expand All @@ -449,12 +454,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),
Expand Down
Loading
Loading