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
2 changes: 1 addition & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,4 @@ jobs:
TRINITY_REQUIRE_MLDSA87_RUNTIME: "1"
run: |
rm -rf tmp trinity_test.db* trinity_test_receipts.db*
mix test test/trinity/crypto/preflight_test.exs test/trinity/toolchain_node_test.exs test/trinity/receipts/signer_test.exs test/trinity/n6/cose_signing_test.exs test/trinity/n6/cose_test.exs test/trinity/receipts/foreign_algorithm_test.exs
mix test test/trinity/crypto/preflight_test.exs test/trinity/toolchain_node_test.exs test/trinity/receipts/signer_test.exs test/trinity/n6/cose_signing_test.exs test/trinity/n6/cose_test.exs test/trinity/receipts/foreign_algorithm_test.exs test/trinity/receipts/v4_test.exs test/trinity/n6/esp384_test.exs
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ the runtime's `OTP_VERSION` file; a real boot on OTP 28.5.0.7, refused as headle
as desktop; a runtime with 28.5.0.8's erts and an older ssl refused, naming ssl; and the decoder probe catching the defect on OTP 28.1.1 and
28.5.0.3.

### `slice/140`: receipts are signed statements in one byte form

New receipts and checkpoints are N6 statements: deterministic CBOR inside COSE_Sign1, in the
single-signer carrier, signed with ML-DSA-87, Ed25519, or, on a node in FIPS mode, ECDSA P-384 as
COSE's ESP384. Each row is linked to the one before it by a SHA-384 identity that is the same
whether or not the row is signed, and a receipt's subject and decision are referenced by digest over
the exact bytes stored beside it, so a verifier reads what was signed and never a re-encoded copy.
Receipts written before stay as they were signed and still verify, and a chain that began earlier
continues into the new form. The verifier inside Trinity and the standalone script give the same
answer on every chain, the script loading the same N6 code from Trinity's sources when it meets a
new row. A `typ` no longer names an algorithm: the signing key's registration does, as the
profile's publisher has since decided.

Verified by the test suite: ESP384 signing and verifying in the gate and in FIPS mode, its key id
checked against `sha384sum`, and its malformed signatures and keys refused; every receipt kind
built and read back; a chain crossing from the earlier form, and every tampering with a row, a link
or a checkpoint refused; attachments missing or altered reported without changing the verdict; and
the two verifiers agreeing on each case.

## 2026-10-08

### `slice/136`: the web pages ask who is there
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ enforces it.
model's window.
- **Acts.** Filesystem, web and shell tools behind a permission gate with fingerprint-bound
approvals; every effect passes one membrane and leaves a signed, hash-chained receipt you can
verify offline (`mix trinity.receipts.verify`). A FIPS build leg in CI proves the receipt scheme
under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any
verify offline (`mix trinity.receipts.verify`). Since slice 140 a receipt is deterministic CBOR in
COSE_Sign1, signed with ML-DSA-87, Ed25519 or, in FIPS mode, ECDSA P-384, and older receipts still
verify. A FIPS build leg in CI proves the receipt scheme under OpenSSL's FIPS provider. No tool can read or write Trinity's own keys or databases, by any
path: the secrets live in their own directory outside the data directory, a filesystem root that
overlaps either refuses the boot, and every path is checked for links, hard links, `/proc` and
protected inodes before the file is opened and again on the open file (slice 135).
Expand Down Expand Up @@ -379,8 +380,9 @@ two: a *Signed Statement* is issued by whoever made the statement, and a *Receip
Transparency Service and must carry proof of inclusion in a verifiable log. Trinity runs no
transparency service, so in that vocabulary Trinity issues Signed Statements and never Receipts. The
house name is not changing, because "receipt" is the right word for what it is to the person who owns
the machine, but nobody should infer SCITT conformance from it. The field by field mapping, including
the three fields that have no equivalent, is in
the machine, but nobody should infer SCITT conformance from it. Since slice 140 its envelope is
COSE_Sign1, the one SCITT uses, still without the CWT issuer and subject claims a SCITT statement
carries. The field by field mapping, including the fields that have no equivalent, is in
[`docs/receipt-scheme-mapping.md`](docs/receipt-scheme-mapping.md).

## Connecting Trinity to the platform
Expand Down
141 changes: 113 additions & 28 deletions bin/verify_receipt.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# wrote; the registry travels inside the file.
#
# elixir verify_receipt.exs receipts.json [--schemes receipt_v3_ed25519,receipt_v2_ed25519]
# [--n6 <Trinity's lib directory>]
#
# Exit codes, the vocabulary an operator learns once: 0 verified, 1 invalid, 2 usage,
# 5 trust not established (a key id the registry does not know, or a signature this runtime
Expand All @@ -21,6 +22,15 @@
# query receipt is covered by a checkpoint whose tail is in the chain and whose signature
# verifies the same way. test/trinity/receipts/standalone_verifier_test.exs asserts this
# file and the in-app verifier agree on the same inputs.
#
# Slice 140: a v4 row or checkpoint is an N6 statement, and checking one needs a strict N6 decoder,
# far more than this file should hold. So for those this script loads, from source, the N6 files
# and Trinity.Receipts.V4, which depend on nothing but each other and :crypto, and runs the same
# rules the in-app verifier runs (V4.verify_row/3, V4.verify_checkpoint/3) after the same registry
# resolution as above. They are read from ../lib beside this file in a checkout, or from the
# directory --n6 names. A chain with no v4 row needs neither, and the script loads nothing. A
# finding about a v4 row's stored attachments (its subject or decision bytes missing or not what its
# signed body names) is printed after the verdict and does not change it.

defmodule VerifyReceipt do
# Every scheme version this script can read. `v2` is slice 024's; `v3` is slice 026's and carries
Expand All @@ -33,12 +43,25 @@ defmodule VerifyReceipt do
"receipt_v2_mldsa87" => {"mldsa87", :mldsa87, :none, nil},
"receipt_v3_ed25519" => {"ed25519", :eddsa, :none, :ed25519},
"receipt_v3_p384" => {"p384", :ecdsa, :sha384, :secp384r1},
"receipt_v3_mldsa87" => {"mldsa87", :mldsa87, :none, nil}
"receipt_v3_mldsa87" => {"mldsa87", :mldsa87, :none, nil},
"receipt_v4_ed25519" => {"ed25519", :eddsa, :none, :ed25519},
"receipt_v4_p384" => {"p384", :ecdsa, :sha384, :secp384r1},
"receipt_v4_mldsa87" => {"mldsa87", :mldsa87, :none, nil}
}
@signed_kinds ~w(decision effect boot cap)

# The sources a v4 row needs, relative to Trinity's lib directory, in the order they are loaded.
@n6_sources ~w(n6/profile.ex n6/cbor.ex n6/digest.ex n6/body.ex n6/ec2.ex n6/cose.ex n6/carrier.ex receipts/v4.ex)

# Named at run time, so compiling this file before the sources are loaded warns about nothing.
defp v4, do: Module.concat(["Trinity", "Receipts", "V4"])

def main(argv) do
{opts, args, _} = OptionParser.parse(argv, strict: [schemes: :string])
# OTP 28.5.0.8 warns on stdout when :crypto loads before its application ("Module 'crypto'
# loaded without application 'crypto' being loaded"), which a plain `elixir` run does; loading
# the application first keeps the verdict the only line (slice 140, measured on that runtime).
_ = Application.load(:crypto)
{opts, args, _} = OptionParser.parse(argv, strict: [schemes: :string, n6: :string])

case args do
[path] ->
Expand All @@ -49,31 +72,53 @@ defmodule VerifyReceipt do
end

case File.read(path) do
{:ok, bin} -> run(JSON.decode(bin), schemes)
{:ok, bin} -> bin |> JSON.decode() |> load_n6(opts[:n6]) |> run(schemes)
{:error, reason} -> usage("cannot read #{path}: #{inspect(reason)}")
end

_ ->
usage("usage: elixir verify_receipt.exs <export.json> [--schemes a,b]")
usage("usage: elixir verify_receipt.exs <export.json> [--schemes a,b] [--n6 <lib dir>]")
end
end

defp load_n6({:ok, %{"receipts" => rs, "checkpoints" => cps}} = export, dir) do
if Enum.any?(rs ++ cps, &n6?/1) do
root = dir || Path.expand("../lib", __DIR__)
files = Enum.map(@n6_sources, &Path.join([root, "trinity", &1]))

case Enum.reject(files, &File.regular?/1) do
[] -> Enum.each(files, &Code.require_file/1)
_ -> usage("v4 rows need Trinity's N6 sources: --n6 <Trinity's lib directory> (#{root} has none)")
end
end

export
end

defp load_n6(other, _dir), do: other

defp n6?(%{"scheme" => s}) when is_binary(s), do: String.starts_with?(s, "receipt_v4_")
defp n6?(_), do: false

defp usage(msg) do
IO.puts(:stderr, msg)
System.halt(2)
end

defp run({:ok, %{"receipts" => rs, "checkpoints" => cps, "registry" => reg}}, schemes) do
defp run({:ok, %{"receipts" => rs, "checkpoints" => cps, "registry" => reg} = export}, schemes) do
n6 = [registry: reg, entry: :commercial, issuer: export["issuer"]]

result =
with :ok <- walk(Enum.sort_by(rs, & &1["seq"]), reg, schemes, 0, nil),
:ok <- checkpoints(cps, Map.new(rs, &{&1["seq"], &1}), reg, schemes),
with {:ok, findings} <- walk(Enum.sort_by(rs, & &1["seq"]), reg, schemes, 0, nil, n6, []),
:ok <- checkpoints(cps, Map.new(rs, &{&1["seq"], &1}), reg, schemes, n6),
:ok <- coverage(rs, cps) do
{:ok, length(rs), length(cps)}
{:ok, length(rs), length(cps), findings}
end

case result do
{:ok, n, c} ->
{:ok, n, c, findings} ->
IO.puts("verified: #{n} receipts, #{c} checkpoints")
Enum.each(findings, &IO.puts("finding: #{inspect(&1)}"))
System.halt(0)

{:error, code, reason} ->
Expand All @@ -88,22 +133,45 @@ defmodule VerifyReceipt do
defp label(5), do: "trust not established"
defp label(6), do: "compromised key"

defp walk([], _reg, _schemes, _seq, _hash), do: :ok
defp walk([], _reg, _schemes, _seq, _prev, _n6, findings), do: {:ok, findings}

defp walk([r | rest], reg, schemes, prev_seq, prev_hash) do
# A v4 row: the registry resolution here, then V4's rules, then the next row.
defp walk([%{"scheme" => "receipt_v4_" <> _} = r | rest], reg, schemes, prev_seq, prev, n6, findings) do
seq = r["seq"]

with :ok <- expect(seq == prev_seq + 1, 1, {:seq_gap, prev_seq, seq}),
:ok <- expect(r["prev_hash"] == prev_hash, 1, {:prev_hash_mismatch, seq}),
{:ok, _fam, _row} <- resolve(r["scheme"], r["key_id"], reg, schemes, seq),
{:ok, info} <- v4_row(r, prev, n6) do
walk(rest, reg, schemes, seq, r, n6, findings ++ info.findings)
end
end

defp walk([r | rest], reg, schemes, prev_seq, prev, n6, findings) do
seq = r["seq"]

with :ok <- expect(seq == prev_seq + 1, 1, {:seq_gap, prev_seq, seq}),
:ok <- expect(r["prev_hash"] == (prev && prev["receipt_hash"]), 1, {:prev_hash_mismatch, seq}),
{:ok, fam, row} <- resolve(r["scheme"], r["key_id"], reg, schemes, seq),
bytes = pae("trinity/receipt/" <> r["scheme"], r["signed_payload"]),
:ok <- expect(hash(bytes) == r["receipt_hash"], 1, {:hash_mismatch, seq}),
:ok <- body_matches(r, seq),
:ok <- signature(r, bytes, fam, row, seq) do
walk(rest, reg, schemes, seq, r["receipt_hash"])
walk(rest, reg, schemes, seq, r, n6, findings)
end
end

defp v4_row(r, prev, n6) do
case apply(v4(), :verify_row, [r, prev && link(prev), n6]) do
{:error, 1, {:n6, seq, :signature_invalid}} -> {:error, 1, {:signature_invalid, seq}}
other -> other
end
end

# A row's link id, as the next v4 row names it: a v4 row's statement_id (its receipt_hash), or
# SHA-384 over a v2 or v3 row's PAE bytes.
defp link(%{"scheme" => "receipt_v4_" <> _, "receipt_hash" => h}), do: Base.decode16!(h, case: :lower)
defp link(r), do: :crypto.hash(:sha384, pae("trinity/receipt/" <> r["scheme"], r["signed_payload"]))

defp signature(r, bytes, fam, row, seq) do
if r["kind"] in @signed_kinds do
with {:ok, sig} <- b64(r["signature_b64"], 1, {:signature_undecodable, seq}),
Expand Down Expand Up @@ -140,26 +208,43 @@ defmodule VerifyReceipt do
end
end

defp checkpoints(cps, by_seq, reg, schemes) do
defp checkpoints(cps, by_seq, reg, schemes, n6) do
Enum.reduce_while(cps, :ok, fn cp, :ok ->
last = cp["last_seq"]

r =
with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}},
:ok <- expect(tail["receipt_hash"] == cp["tail_hash"], 1, {:checkpoint_tail_mismatch, last}),
:ok <- expect(is_integer(cp["first_seq"]) and cp["first_seq"] <= last, 1, {:checkpoint_range, last}),
{:ok, fam, row} <- resolve(cp["scheme"], cp["key_id"], reg, schemes, {:checkpoint, last}),
{:ok, body} <- JSON.decode(cp["signed_payload"]) |> or_error({:error, 1, {:checkpoint_body_not_json, last}}),
:ok <- expect(body["last_seq"] == last and body["first_seq"] == cp["first_seq"] and body["tail_hash"] == cp["tail_hash"], 1, {:checkpoint_body_mismatch, last}),
{:ok, sig} <- b64(cp["signature_b64"], 1, {:signature_undecodable, {:checkpoint, last}}),
{:ok, pub} <- b64(row["public_key_b64"], 5, {:key_undecodable, cp["key_id"]}) do
check(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub, {:checkpoint, last}, cp["key_id"], {:checkpoint_signature_invalid, last})
end

r = checkpoint(cp, by_seq, reg, schemes, n6)
if r == :ok, do: {:cont, :ok}, else: {:halt, r}
end)
end

defp checkpoint(%{"body_b64" => _} = cp, by_seq, reg, schemes, n6) do
last = cp["last_seq"]

with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}},
:ok <- expect(is_integer(cp["first_seq"]) and cp["first_seq"] <= last, 1, {:checkpoint_range, last}),
{:ok, _fam, _row} <- resolve(cp["scheme"], cp["key_id"], reg, schemes, {:checkpoint, last}) do
case apply(v4(), :verify_checkpoint, [cp, link(tail), n6]) do
:ok -> :ok
{:error, 1, {:checkpoint_body_mismatch, _}} -> {:error, 1, {:checkpoint_tail_mismatch, last}}
{:error, 1, {:n6, _at, :signature_invalid}} -> {:error, 1, {:checkpoint_signature_invalid, last}}
error -> error
end
end
end

defp checkpoint(cp, by_seq, reg, schemes, _n6) do
last = cp["last_seq"]

with %{} = tail <- by_seq[last] || {:error, 1, {:checkpoint_tail_missing, last}},
:ok <- expect(tail["receipt_hash"] == cp["tail_hash"], 1, {:checkpoint_tail_mismatch, last}),
:ok <- expect(is_integer(cp["first_seq"]) and cp["first_seq"] <= last, 1, {:checkpoint_range, last}),
{:ok, fam, row} <- resolve(cp["scheme"], cp["key_id"], reg, schemes, {:checkpoint, last}),
{:ok, body} <- JSON.decode(cp["signed_payload"]) |> or_error({:error, 1, {:checkpoint_body_not_json, last}}),
:ok <- expect(body["last_seq"] == last and body["first_seq"] == cp["first_seq"] and body["tail_hash"] == cp["tail_hash"], 1, {:checkpoint_body_mismatch, last}),
{:ok, sig} <- b64(cp["signature_b64"], 1, {:signature_undecodable, {:checkpoint, last}}),
{:ok, pub} <- b64(row["public_key_b64"], 5, {:key_undecodable, cp["key_id"]}) do
check(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub, {:checkpoint, last}, cp["key_id"], {:checkpoint_signature_invalid, last})
end
end

defp coverage(rs, cps) do
covered = cps |> Enum.flat_map(fn cp -> Enum.to_list(cp["first_seq"]..cp["last_seq"]//1) end) |> MapSet.new()
uncovered = rs |> Enum.filter(&(&1["kind"] == "query" and not MapSet.member?(covered, &1["seq"]))) |> Enum.map(& &1["seq"])
Expand Down
11 changes: 11 additions & 0 deletions docs/05-data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,15 @@ keys are `scheme, seq, chain_scope, prev_hash, kind, subject, decision, fingerpr
`canonicalization_version`, `authority`, `tool_definition_digest`), `inserted_at`. Unique `(chain_scope, seq)`
and `receipt_hash`. Chain scopes: `session:<id>` and `boot`.

As built at slice 140 (receipts v4, `Trinity.Receipts.V4`): new rows are N6 statements, deterministic CBOR in
the single-signer COSE_Sign1 carrier, under `receipt_v4_<family>`. Four nullable binary columns are added and v2
and v3 rows are never rewritten: `body` (the N6 body: `issuer`, `prev`, `seq`, `chain_scope`, `at` in microseconds,
`clock`, and `subject` and `decision` as references by SHA-384 digest, length and media type), `carrier` (for a
signed kind), `subject_bytes` and `decision_bytes` (the exact RFC 8785 bytes the references name, verified as
stored). A v4 row's `receipt_hash` is its `statement_id` (SHA-384, hex), its `prev_hash` its predecessor's, or for
the first v4 row after a v3 one, SHA-384 over that row's PAE bytes; `signed_payload` is the empty string, since the
column is NOT NULL. `subject` (map) is kept for the receipts page and is never what a verifier reads.

### receipt_checkpoints (Slice 024)
| column | type | notes |
|---|---|---|
Expand All @@ -268,6 +277,8 @@ and `receipt_hash`. Chain scopes: `session:<id>` and `boot`.
| scheme, signed_payload, signature, key_id | | signed like a receipt, over the PAE of `trinity/checkpoint/<scheme>` and the canonical body |
| reason | string | "count" \| "time" \| "shutdown" \| "rehydrate" \| "manual" |
A row, never a write onto a receipt: `receipts` stays append-only and a checkpoint states its own coverage.
Since slice 140 a checkpoint is a v4 statement too: `body` and `carrier` (nullable binary columns), `tail_hash` the
tail's link id, `signed_payload` empty, and `signature` the carrier's signature.

### mcp_servers (Slice 060, as built)
One row per server the client connects to; the row's `name` is the namespace segment of every tool it contributes.
Expand Down
Loading
Loading