Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
95b3660
docs(s024): G1 plan with the signing and insert costs measured, and t…
HackTuah Sep 20, 2026
9f646ca
docs(s024): the design checked against DSSE, RFC 8725, RFC 7638, RFC …
HackTuah Sep 20, 2026
b34cd8f
feat(s024): the receipts repo and file, the signer seam, key custody …
HackTuah Sep 20, 2026
6dd6a86
feat(s024): the chain writer, checkpoints, the authority behaviour an…
HackTuah Sep 20, 2026
689542a
feat(s024): the verifier's tests, the export and verify tasks, the st…
HackTuah Sep 20, 2026
4b356af
test(s024): the authority selection: named refusals, the child spec's…
HackTuah Sep 20, 2026
85c0cdb
fix(s021): a decision that arrives while the tools still run is postp…
HackTuah Sep 20, 2026
69cb4e4
feat(s024): the membrane, its runner as the seam in force, the execut…
HackTuah Sep 20, 2026
e1461d2
test(s024): the membrane: the execute/2 census with a planted bypass,…
HackTuah Sep 20, 2026
a44d820
feat(s024): CorePolicy covers the modules that decide; the boot recei…
HackTuah Sep 20, 2026
3d6fc93
feat(s024): the receipts pages: a session's chain and the boot receip…
HackTuah Sep 20, 2026
7170545
feat(s024): the FIPS half of AC8 for the leg; the policy hash over st…
HackTuah Sep 20, 2026
c583ef3
docs(s024): 01, 05, 07 and ADR-0013 as built
HackTuah Sep 20, 2026
a0ef284
refactor(s024): credo's seven findings and sobelow's nine
HackTuah Sep 20, 2026
120ff73
test(s024): mode-aware expectations for the fips leg; the peer assert…
HackTuah Sep 20, 2026
d6a2054
test(s024): the receipts tests derive the chain's family and the fore…
HackTuah Sep 20, 2026
f977b84
test(s024): the outbound-connection assertion is scoped to this appli…
HackTuah Sep 20, 2026
2ab8d28
feat(s024): complete slice 024 (effect catalog, authority selection, …
HackTuah Sep 20, 2026
e1ffbbb
docs(s024): PROOF.md names the closing commit's sha
HackTuah Sep 20, 2026
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 ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ standards register names the rows that ask for them.
| 021 | Permission gate + approval UI (M2 fingerprint-bound, M7) | 2 Tools | M | 020, 013 | approved |
| 022 | Core tools: filesystem, web fetch/search, shell (MuonTrap) | 2 Tools | L | 021 | approved |
| 023 | Context compaction + session lineage | 2 Tools | M | 012 | approved |
| 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | planned |
| 024 | Effect catalog, authority selection (`TRINITY_AUTHORITY`), local receipts | 2 Tools | L | 021, 022 | done |
| 025 | Encryption at rest, and the key-custody seam | 2 Tools | M | 010, 024 | planned |
| 026 | Store-and-forward receipts for disconnected operation | 2 Tools | L | 024 | planned |
| 030 | Persona (SOUL) + always-on memory tier | 3 Memory | M | 012 | planned |
Expand Down
183 changes: 183 additions & 0 deletions bin/verify_receipt.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env elixir
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# Verifies an exported Trinity receipt chain with nothing but Elixir and OTP's :crypto
# (slice 024, AC7). Run it from any directory against a file `mix trinity.receipts.export`
# wrote; the registry travels inside the file.
#
# elixir verify_receipt.exs receipts.json [--schemes receipt_v2_ed25519,receipt_v2_p384]
#
# Exit codes, the vocabulary an operator learns once: 0 verified, 1 invalid, 2 usage,
# 5 trust not established (a key id the registry does not know), 6 compromised key.
#
# This is a copy of Trinity.Receipts.Verifier's rules, kept small so a stranger can read it:
# the seq is the previous plus one; prev_hash is the previous receipt_hash; the scheme is
# allowed; the key id resolves in the registry and the row's algorithm is the scheme's family,
# else refused before any signature check; the row is not compromised; the hash recomputes
# over DSSE's PAE of "trinity/receipt/<scheme>" and the stored body; decision, effect, boot
# and cap receipts carry a signature that verifies under the registry's algorithm; every
# 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.

defmodule VerifyReceipt do
@families %{
"receipt_v2_ed25519" => {"ed25519", :eddsa, :none, :ed25519},
"receipt_v2_p384" => {"p384", :ecdsa, :sha384, :secp384r1},
"receipt_v2_mldsa87" => {"mldsa87", :mldsa87, :none, nil}
}
@signed_kinds ~w(decision effect boot cap)

def main(argv) do
{opts, args, _} = OptionParser.parse(argv, strict: [schemes: :string])

case args do
[path] ->
schemes =
case opts[:schemes] do
nil -> Map.keys(@families)
s -> String.split(s, ",", trim: true)
end

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

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

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

defp run({:ok, %{"receipts" => rs, "checkpoints" => cps, "registry" => reg}}, schemes) do
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),
:ok <- coverage(rs, cps) do
{:ok, length(rs), length(cps)}
end

case result do
{:ok, n, c} ->
IO.puts("verified: #{n} receipts, #{c} checkpoints")
System.halt(0)

{:error, code, reason} ->
IO.puts(:stderr, "#{label(code)}: #{inspect(reason)}")
System.halt(code)
end
end

defp run(_, _), do: usage("not a receipts export")

defp label(1), do: "invalid"
defp label(5), do: "trust not established"
defp label(6), do: "compromised key"

defp walk([], _reg, _schemes, _seq, _hash), do: :ok

defp walk([r | rest], reg, schemes, prev_seq, prev_hash) 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),
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"])
end
end

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}),
{:ok, pub} <- b64(row["public_key_b64"], 5, {:key_undecodable, r["key_id"]}) do
expect(verify(fam, bytes, sig, pub), 1, {:signature_invalid, seq})
end
else
:ok
end
end

defp body_matches(r, seq) do
case JSON.decode(r["signed_payload"]) do
{:ok, b} ->
expect(
b["seq"] == r["seq"] and b["prev_hash"] == r["prev_hash"] and b["scheme"] == r["scheme"] and
b["kind"] == r["kind"] and b["key_id"] == r["key_id"] and b["chain_scope"] == r["chain_scope"],
1,
{:body_column_mismatch, seq}
)

_ ->
{:error, 1, {:body_not_json, seq}}
end
end

defp resolve(scheme, key_id, reg, schemes, seq) do
with :ok <- expect(scheme in schemes, 1, {:scheme_not_allowed, seq, scheme}),
{:ok, fam} <- Map.fetch(@families, scheme) |> or_error({:error, 1, {:unknown_scheme, seq, scheme}}),
%{} = row <- lookup(reg, key_id) || {:error, 5, {:unknown_key_id, seq, key_id}},
:ok <- expect(row["status"] != "compromised", 6, {:key_compromised, seq, key_id}),
:ok <- expect(row["algorithm"] == elem(fam, 0), 1, {:scheme_family_mismatch, seq, scheme, row["algorithm"]}) do
{:ok, fam, row}
end
end

defp checkpoints(cps, by_seq, reg, schemes) 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
expect(verify(fam, pae("trinity/checkpoint/" <> cp["scheme"], cp["signed_payload"]), sig, pub), 1, {:checkpoint_signature_invalid, last})
end

if r == :ok, do: {:cont, :ok}, else: {:halt, r}
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"])
expect(uncovered == [], 1, {:query_receipts_uncovered, uncovered})
end

defp verify({_, :eddsa, _, curve}, bytes, sig, pub), do: :crypto.verify(:eddsa, :none, bytes, sig, [pub, curve])
defp verify({_, :ecdsa, digest, curve}, bytes, sig, pub), do: :crypto.verify(:ecdsa, digest, bytes, sig, [pub, curve])
defp verify({_, :mldsa87, _, _}, bytes, sig, pub), do: :crypto.verify(:mldsa87, :none, bytes, sig, pub)

defp pae(type, body),
do: "DSSEv1 " <> Integer.to_string(byte_size(type)) <> " " <> type <> " " <> Integer.to_string(byte_size(body)) <> " " <> body

defp hash(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)

defp lookup(reg, key_id) when is_list(reg) and is_binary(key_id), do: reg |> Enum.filter(&(&1["key_id"] == key_id)) |> List.last()
defp lookup(_, _), do: nil

defp b64(nil, code, reason), do: {:error, code, reason}
defp b64(s, code, reason), do: Base.decode64(s) |> or_error({:error, code, reason})

defp expect(true, _, _), do: :ok
defp expect(false, code, reason), do: {:error, code, reason}

defp or_error({:ok, v}, _), do: {:ok, v}
defp or_error(_, e), do: e
end

VerifyReceipt.main(System.argv())
23 changes: 22 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,17 @@ import Config
config :ex_tauri, app_name: "Trinity", host: "localhost", port: 4000, version: "2.5.1"

config :trinity,
ecto_repos: [Trinity.Repo],
# Slice 024: the receipts chain has its own Repo and file (docs/adr/0013, `Trinity.Repo.Receipts`);
# the migrator and the ecto tasks run over both.
ecto_repos: [Trinity.Repo, Trinity.Repo.Receipts],
generators: [timestamp_type: :utc_datetime]

# Slice 024: the receipts Repo keeps its migrations apart from the primary's, and on Postgres,
# where both Repos share one database, its own schema_migrations table.
config :trinity, Trinity.Repo.Receipts,
priv: "priv/repo_receipts",
migration_source: "receipts_schema_migrations"

# Slice 010. The database adapter is chosen at compile time: SQLite is primary and the default,
# Postgres is the CI-tested alternative behind TRINITY_DB=postgres (docs/adr/0002). An Ecto
# adapter is fixed in `use Ecto.Repo`, so switching means recompiling, and this file says so
Expand Down Expand Up @@ -96,6 +104,19 @@ if System.get_env("TRINITY_DB", "sqlite") == "sqlite" do
busy_timeout: 5_000,
cache_size: -64_000,
wal_auto_check_point: 1_000

# Slice 024: the receipts file runs `synchronous: :full`, one fsync per committed receipt,
# so the last signed receipt is durable across power loss. Measured at 024 G1 on this
# machine: 274 µs per row at one row per transaction under FULL against 20 µs under NORMAL,
# far under any effect rate; the primary keeps NORMAL.
config :trinity, Trinity.Repo.Receipts,
pool_size: 1,
journal_mode: :wal,
synchronous: :full,
foreign_keys: :on,
busy_timeout: 5_000,
cache_size: -16_000,
wal_auto_check_point: 1_000
end

# Configure the endpoint
Expand Down
5 changes: 5 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ config :trinity, Trinity.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true

# Slice 024: the receipts chain's own file.
config :trinity, Trinity.Repo.Receipts,
database: Path.expand("../trinity_dev_receipts.db", __DIR__),
stacktrace: true

# For development, we disable any cache and enable
# debugging and code reloading.
#
Expand Down
8 changes: 8 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,18 @@ if config_env() == :prod do
# capacity. A Postgres build sets its own size below.
config :trinity, Trinity.Repo, database: database_path

# Slice 024: the receipts file beside it, the same way.
config :trinity, Trinity.Repo.Receipts,
database: System.get_env("RECEIPTS_DATABASE_PATH") || Trinity.Paths.receipts_database_path()

if System.get_env("TRINITY_DB") == "postgres" do
config :trinity, Trinity.Repo,
url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

config :trinity, Trinity.Repo.Receipts,
url: System.get_env("DATABASE_URL"),
pool_size: 2
end

# The secret key base is used to sign/encrypt cookies and other secrets.
Expand Down
14 changes: 14 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,20 @@ if System.get_env("TRINITY_DB") == "postgres" do
url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"),
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox

# Slice 024: the receipts Repo shares the Postgres database (its own migrations table).
config :trinity, Trinity.Repo.Receipts,
url: System.get_env("DATABASE_URL"),
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox
else
config :trinity, Trinity.Repo,
database: Path.expand("../trinity_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox

config :trinity, Trinity.Repo.Receipts,
database: Path.expand("../trinity_test_receipts.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox
end

# We don't run a server during test. If one is required,
Expand Down Expand Up @@ -130,3 +140,7 @@ config :phoenix_live_view,
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true

# Slice 024: the receipt signing key and registry for the suite live under the project's
# ignored tmp/, never in the data directory of the machine running the tests.
config :trinity, :receipts, keys_dir: Path.expand("../tmp/test_keys", __DIR__)
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ slice_id percent sha date
022 74.85 1fb1372 2026-09-20
023 75.39 5989b73 2026-09-20
003 75.39 39518c2 2026-09-20
024 76.55 f977b84 2026-09-20
Loading
Loading