diff --git a/ROADMAP.md b/ROADMAP.md index 9f171cf..d408bb4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/bin/verify_receipt.exs b/bin/verify_receipt.exs new file mode 100755 index 0000000..8e272df --- /dev/null +++ b/bin/verify_receipt.exs @@ -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/" 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 [--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()) diff --git a/config/config.exs b/config/config.exs index a79a658..6938876 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 @@ -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 diff --git a/config/dev.exs b/config/dev.exs index 4062e19..90d86a3 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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. # diff --git a/config/runtime.exs b/config/runtime.exs index fbc90bd..8f1c15f 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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. diff --git a/config/test.exs b/config/test.exs index 2765cf5..22d94ba 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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, @@ -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__) diff --git a/coverage.tsv b/coverage.tsv index d0d7629..52998d3 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -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 diff --git a/docs/01-architecture.md b/docs/01-architecture.md index f84c903..7fa9961 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -28,12 +28,20 @@ Trinity.Application │ │ # (GenServer over ETS). 022 adds the stateful runtimes beside them ├── Trinity.Permissions.Gate # Slice 021, as built: approval requests (rows, then broadcasts), │ # decisions, expiries; pending rows reloaded with their timers -├── Trinity.Receipts.Supervisor # Slice 024 -│ └── Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry; ADR-0013) -│ # serialises append per scope. prev_hash -> receipt_hash is a read-then-write, so -│ # concurrent sessions would otherwise race: SQLite's single writer serialises the -│ # INSERT but does not guarantee each row read the correct predecessor. -├── Trinity.Authority # the selected implementation, resolved once at boot. Slice 024 +├── Trinity.Authority.Selection # Slice 024, as built: a transient Task right after the data +│ # directory lock; reads TRINITY_AUTHORITY once, refuses the boot +│ # by name (ADR-0010). Placed early in the list, before the Repo. +├── Trinity.Repo.Receipts # Slice 024, as built: the receipts chain's own SQLite file, +│ # synchronous full (ADR-0013), its own migrations +├── Trinity.Receipts.Supervisor # Slice 024, as built: KeyCustody.boot!/1 in its init (the +│ │ # signer and its key, once), then +│ └── Trinity.Receipts.WriterSupervisor (DynamicSupervisor) +│ └── Trinity.Receipts.ChainWriter (one per chain_scope, :unique in Trinity.Registry, temporary; ADR-0013) +│ # serialises append per scope. prev_hash -> receipt_hash is a read-then-write, so +│ # concurrent sessions would otherwise race: SQLite's single writer serialises the +│ # INSERT but does not guarantee each row read the correct predecessor. +├── Trinity.Effects.Boot # Slice 024, as built: a transient Task writing the boot receipt +│ # once the signer and the authority are known ├── Trinity.Memory.Supervisor # Nx.Serving for embeddings, retrieval. Slice 032 ├── Trinity.Skills.Registry # hot-loaded skills index. Slice 040 ├── Oban # cron + durable jobs. Slice 050 @@ -46,8 +54,9 @@ Trinity.Application ``` `Trinity.Effects` is a module rather than a process: it is the membrane every effectful call passes through, and -holds no state of its own. `Trinity.Authority` appears in the tree because the selection is resolved once at boot -and must not be re-resolvable afterwards. +holds no state of its own. `Trinity.Authority` is a behaviour with the selection resolved once at boot by the +`Selection` child and must not be re-resolvable afterwards; as built at slice 024 the selected module is read +from `:persistent_term` by `Trinity.Authority.impl/0`. Restart strategies: `Trinity.Sessions.Supervisor` is `:one_for_one` with `max_restarts: 10, max_seconds: 60` per session; a Session that crashes rehydrates from the DB (`Trinity.Sessions.rehydrate/1`) and re-enters `idle`. @@ -65,11 +74,11 @@ without anything failing. |---|---|---| | `Trinity.Sessions` | Session process, turn loop, message log | LLM, Tools, **Effects**, Permissions, Memory, Skills, Repo, PubSub | | `Trinity.LLM` | Provider behaviour, req_llm adapter, model registry, streaming, usage | Repo (usage), Telemetry | -| `Trinity.Tools` | Tool behaviour, registry, execution runtime, core tools | Permissions, Sandbox, Repo | +| `Trinity.Tools` | Tool behaviour, registry, execution runtime, core tools, and (as built at 024) the compile-time effect catalog `Trinity.Tools.Catalog`, because the registry reads it and Effects depends on Tools | Permissions, Sandbox, Repo | | `Trinity.Permissions` | Policy, tier/1 (name-only), fingerprint-bound approvals, override adjudication | Repo, PubSub | -| `Trinity.Effects` | The membrane; compile-time effect catalog; query receipts for reads | **Tools**, Permissions, Authority, Receipts, Repo | -| `Trinity.Authority` | Behaviour; `Local` implementation; selection at boot; adapter responses | Receipts, Repo | -| `Trinity.Receipts` | Local chain (one supervised writer per scope, ADR-0013), Ed25519 signer, key registry | Repo | +| `Trinity.Effects` | The membrane; the runner in force (`Effects.Runner`, the executor `Tools.Runner` takes as a function); decision and query receipts; the boot receipt | **Tools**, Permissions, Authority, Receipts, Repo | +| `Trinity.Authority` | Behaviour; `Local` implementation (the one caller of `execute/2` for effectful tools); selection at boot; `Staged` | Receipts, Repo | +| `Trinity.Receipts` | Local chain (one supervised writer per scope, ADR-0013), the signer seam (Ed25519, P-384, ML-DSA-87), key custody and the registry, checkpoints, the verifier, the alarm | Repo (`Repo.Receipts`) | | `Trinity.Memory` | Always-on tier, episodic FTS, semantic store, retrieval, compaction | LLM (summaries/embeddings), Repo | | `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner | Repo, Permissions, **Effects**, **Receipts**, Sandbox | | `Trinity.Scheduler` | Oban workers for agent tasks, delivery | Sessions, Gateways, **Repo** | @@ -136,7 +145,7 @@ session with `parent_id`, the compaction first, the user's message second, the c closed with a row naming the child and `{:forked, child_id}` broadcast. Memory depends on LLM and the core, never on Sessions. -**Effect path (Slice 024):** `Session → Permissions.decide → Effects.execute → Authority → tool.execute/2 (local) or a proposal (external adapter) → Receipts.append`. `Effects` is the only caller of `execute/2` for effectful tools; a census test enforces it. Reads emit query receipts. +**Effect path (Slice 024, as built):** `Session → ToolRunner seam → Effects.Runner (executor) → Tools.Runner.decide (the gate, once) → decision receipt → Effects.execute (the membrane: decision, effect class and catalog, fingerprint re-derived, idempotency by session and call id) → Authority.stage → decide → admission receipt → Authority.Local.execute → tool.execute/2 → outcome receipt`. `Authority.Local` is the only caller of `execute/2` for effectful tools; `Tools.Runner.call_tool/3` runs `effect: :none` tools directly and refuses the rest by name; a census over `git ls-files` with a planted bypass holds both. Reads emit query receipts, chained unsigned and checkpointed. A decision that cannot be receipted (no signer) refuses the call, reads included. **The page (Slice 013):** `TrinityWeb.SessionLive.Show` subscribes to `session:` on mount, calls `Trinity.Sessions.ensure_started/1`, loads the history from the database into a LiveView stream and the turn in diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 578cb0e..af5405b 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -143,7 +143,7 @@ call is timed at the one place every call passes. | receipt_hash | binary | over the canonical signed bytes | | signed_payload | map | RFC 8785 canonical JSON. Field set is a legal-review question before Slice 024 | | signature | binary | through the signer seam: Ed25519 by default, ECDSA P-384 in FIPS mode, ML-DSA-87 opt-in (slice 024 amendments 1 to 6) | -| key_id | string | inside the signed bytes; resolves in `priv/keys/registry.json`, whose row names the algorithm; the verifier reads the algorithm from there and nowhere else | +| key_id | string | inside the signed bytes; resolves in the key registry (as built: `/keys/registry.json`, not `priv/`), whose row names the algorithm; the verifier reads the algorithm from there and nowhere else | | kind | string | "decision" \| "effect" \| "query" \| "boot" \| "cap" | | subject | map | refs to the session, tool call, approval or effect this receipts | Append-only. Never updated, never deleted. Signing unavailable means the effect is denied, not that an unsigned @@ -151,6 +151,25 @@ row is written. The signed bytes carry a scheme string naming the family (`recei `receipt_v2_mldsa87`); a chain never mixes families. Effect, decision, boot and cap receipts are signed one by one; query receipts are hash-chained and checkpointed (the tail is signed every N rows, every T seconds, and on shutdown). +As built at slice 024, in its own database (`Trinity.Repo.Receipts`, `receipts.db`, `synchronous: :full`): `id`, +`chain_scope`, `seq`, `prev_hash` (hex), `receipt_hash` (hex: SHA-256 over the DSSE PAE of +`trinity/receipt/` and the body), `scheme`, `kind`, `signed_payload` (**text**: the RFC 8785 body, whose +keys are `scheme, seq, chain_scope, prev_hash, kind, subject, decision, fingerprint, at, key_id`), `signature` +(binary; null for query rows), `key_id`, `subject` (map), `subject_ref` (string: `effect::`, +`decision:…`, `query:…`, `boot:`; the idempotency lookup), `meta` (map, unsigned: `core_policy_hash`, +`canonicalization_version`, `authority`, `tool_definition_digest`), `inserted_at`. Unique `(chain_scope, seq)` +and `receipt_hash`. Chain scopes: `session:` and `boot`. + +### receipt_checkpoints (Slice 024) +| column | type | notes | +|---|---|---| +| chain_scope, first_seq, last_seq | string, integer, integer | the query rows this checkpoint covers; unique `(chain_scope, last_seq)` | +| boot_receipt_hash | string | which boot wrote it (RFC 5848's reboot session id, by role) | +| tail_hash | string | the `receipt_hash` at `last_seq` | +| scheme, signed_payload, signature, key_id | | signed like a receipt, over the PAE of `trinity/checkpoint/` and the canonical body | +| reason | string | "count" \| "time" \| "shutdown" \| "rehydrate" \| "manual" | +A row, never a write onto a receipt: `receipts` stays append-only and a checkpoint states its own coverage. + ### mcp_servers (Slice 060) `name`, `transport`, `command_or_url`, `env_refs`, `enabled`, `effect_default ∈ {none, artifact}`, per-tool effect and risk overrides. A server config claiming `:catalog` is refused at load and receipted. diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 6c3f75b..12b30d5 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -128,6 +128,34 @@ is not public (loopback, private, link-local) to `:ask`. configurable and the default is the safe one. A capped request is not silently dropped: the requester is told the decision must be made on the desktop, and the refusal is receipted like any other. +## Effects and receipts (Slice 024, as built) + +The runner in force is `Trinity.Effects.Runner`. For every validated call it asks the gate once and writes a +decision receipt before anything runs; a decision that cannot be receipted (no approved signer) refuses the call, +reads included, and the alarm `:trinity_receipts_signer` sounds with a telemetry event. An `effect: :none` call +then runs directly and leaves a query receipt (chained, unsigned, checkpointed every 100 rows, 5 s after the +first uncovered row, on shutdown and on rehydrate); every other call becomes a `Trinity.Authority.Staged` and +crosses `Trinity.Effects.execute/2`, which denies with a receipt, in this order, when: the decision is not +`:allow`; the effect class is not admitted, or a `:catalog` tool is absent from `Trinity.Tools.Catalog`; the +fingerprint re-derived over the arguments it holds is not the one the decision bound (M2); an effect receipt +already names this session and call id (the idempotency key, read from the chain); the authority in force refuses. +Then the admission receipt is signed and written, `Trinity.Authority.Local.execute/3` runs the tool's `execute/2` +(the only such caller for effectful tools; a census over `git ls-files` with a planted bypass holds it), and the +outcome receipt follows with the result's digest. + +The signature is over DSSE's PAE of a payload type and the canonical body, the type carrying the scheme +(`trinity/receipt/receipt_v2_ed25519`), so a signature made for a receipt cannot be presented as anything else the +same key signs. `key_id` is the RFC 7638 thumbprint of the public key and sits inside the signed body; the +registry row binds one algorithm to it and the verifier takes the algorithm from there, refusing a scheme whose +family is not the row's before any signature check, and refusing schemes the caller did not allow. The key is a +0600 file under the data directory's `keys/`, read on every sign and never cached, so a key removed mid-run is a +signer unavailable at the next receipt. What a file-backed key establishes: that the chain was not altered after +the fact by anything lacking read access to that file, and nothing more. Selection is once, at boot: P-384 when +`crypto:info_fips/0` is `enabled` (proven on the `fips` leg), Ed25519 otherwise, ML-DSA-87 by configuration +where the runtime carries it; the boot receipt names the choice and the authority in force, with the core policy +hash in unsigned metadata (the R21 default). `bin/verify_receipt.exs` verifies an export with `elixir` alone, +from an empty directory, with the exit vocabulary 0, 1, 2, 5, 6. + ## Data at rest - SQLite file under the OS data dir with 0600 perms. Optional at-rest encryption is a later slice (SQLCipher via exqlite build flag), noted rather than planned. diff --git a/docs/adr/0013-receipt-chain-owner.md b/docs/adr/0013-receipt-chain-owner.md index 6daaf92..9c37431 100644 --- a/docs/adr/0013-receipt-chain-owner.md +++ b/docs/adr/0013-receipt-chain-owner.md @@ -39,3 +39,8 @@ guarantee is "exactly one path", and only a test over the tree can hold that. - Verification is unchanged and stays offline: a verifier walks a scope by `seq` and recomputes the hashes. - The receipt path costs one process hop per catalogued effect. That is the price of an unforked chain, and it is paid on effects, not on reads. +- As built at slice 024: the writer is a temporary child of `Trinity.Receipts.WriterSupervisor`, started on demand + and started again by the next append after a crash; a tail whose hash does not recompute, or a checkpoint whose + tail is not in the chain or whose signature fails, stops it with the reason instead of letting it write. Query + receipts are covered by rows in `receipt_checkpoints` (never by a write onto a receipt), so `receipts` stays + append-only; the chain lives in its own database, `Trinity.Repo.Receipts`, with `synchronous: :full`. diff --git a/lib/mix/tasks/trinity.receipts.export.ex b/lib/mix/tasks/trinity.receipts.export.ex new file mode 100644 index 0000000..628e324 --- /dev/null +++ b/lib/mix/tasks/trinity.receipts.export.ex @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Receipts.Export do + @shortdoc "Writes one receipt chain scope, its checkpoints and the key registry to a JSON file" + + @moduledoc """ + Exports a scope for the standalone verifier (slice 024, AC7): + + mix trinity.receipts.export --scope session: --out receipts.json + mix trinity.receipts.export --scope boot --out boot.json + + The file is `Trinity.Receipts.export/1`'s map: the rows in seq order, the checkpoints and + the registry, so `bin/verify_receipt.exs` needs nothing else. Exit 2 on usage. + """ + use Boundary, classify_to: Trinity + use Mix.Task + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: [scope: :string, out: :string]) + + with {:ok, scope} <- Map.fetch(Map.new(opts), :scope), + {:ok, out} <- Map.fetch(Map.new(opts), :out) do + Mix.Task.run("app.start") + + case Trinity.Receipts.export(scope) do + {:ok, export} -> + File.write!(out, JSON.encode!(export)) + + Mix.shell().info( + "exported #{length(export["receipts"])} receipts, #{length(export["checkpoints"])} checkpoints of #{scope} to #{out}" + ) + + {:error, reason} -> + Mix.raise("export failed: #{inspect(reason)}") + end + else + :error -> + Mix.shell().error("usage: mix trinity.receipts.export --scope --out ") + exit({:shutdown, 2}) + end + end +end diff --git a/lib/mix/tasks/trinity.receipts.verify.ex b/lib/mix/tasks/trinity.receipts.verify.ex new file mode 100644 index 0000000..f13ddc8 --- /dev/null +++ b/lib/mix/tasks/trinity.receipts.verify.ex @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Receipts.Verify do + @shortdoc "Verifies a receipt chain scope, or an exported file, with the exit vocabulary" + + @moduledoc """ + Runs `Trinity.Receipts.Verifier` over a scope in the database or over an exported file + (slice 024): + + mix trinity.receipts.verify --scope session: + 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. + """ + use Boundary, classify_to: Trinity + use Mix.Task + + alias Trinity.Receipts.Verifier + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: [scope: :string, file: :string]) + opts = Map.new(opts) + + export = + cond do + Map.has_key?(opts, :file) -> + opts.file |> File.read!() |> JSON.decode!() + + Map.has_key?(opts, :scope) -> + Mix.Task.run("app.start") + {:ok, export} = Trinity.Receipts.export(opts.scope) + export + + true -> + Mix.shell().error("usage: mix trinity.receipts.verify --scope | --file ") + exit({:shutdown, 2}) + end + + outcome = Verifier.verify(export) + code = Verifier.exit_code(outcome) + + case outcome do + {:ok, %{receipts: n, checkpoints: c}} -> + Mix.shell().info("verified: #{n} receipts, #{c} checkpoints, exit #{code}") + + {:error, ^code, reason} -> + Mix.shell().error("#{label(code)}: #{inspect(reason)}, exit #{code}") + end + + if code != 0, do: exit({:shutdown, code}) + end + + defp label(1), do: "invalid" + defp label(5), do: "trust not established" + defp label(6), do: "compromised key" +end diff --git a/lib/trinity.ex b/lib/trinity.ex index 9a1eb75..0005393 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -8,14 +8,17 @@ defmodule Trinity do # use them), and the Sessions sub-boundary: a context TrinityWeb may call (docs/01). Slice # 013 exports the schemas the chat renders, `Sessions.Message` and `Sessions.SessionRow`, # which the Sessions boundary exports itself; Store stays inside. Slice 020 exports the - # Tools and Permissions sub-boundaries and `Effects.Catalog`, a plain module of this - # boundary the tool registry reads (Effects becomes its own boundary at 024). + # Tools and Permissions sub-boundaries and, until 024, `Effects.Catalog` (now + # `Tools.Catalog`, exported by Tools). Slice 024 exports Repo.Receipts (the Receipts + # boundary writes it), CorePolicy (the boot receipt reads it) and the Receipts, Authority + # and Effects sub-boundaries. use Boundary, deps: [], exports: [ Paths, Repo, + Repo.Receipts, UUID, Config, Sessions, @@ -29,7 +32,14 @@ defmodule Trinity do Permissions, Permissions.Approval, Permissions.Rule, - Effects.Catalog, + Effects, + CorePolicy, + Receipts, + Receipts.Receipt, + Receipts.Checkpoint, + Receipts.Verifier, + Receipts.KeyCustody, + Authority, Content.Part ] ++ if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: []) diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 26ad11a..eb2917c 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -28,7 +28,12 @@ defmodule Trinity.Application do # Slice 010: one node per data directory. Before the Repo, so a refused boot has # opened no database file; the reason names the holder's OS pid and mode. {Trinity.DataDir.Lock, dir: lock_dir(), mode: mode()}, + # Slice 024 (ADR-0010): the authority is selected once, here, before anything that + # could act; a refused selection stops the boot with its reason. + Trinity.Authority.Selection, Trinity.Repo, + # Slice 024: the receipts chain's own file (ADR-0013, `Trinity.Repo.Receipts`). + Trinity.Repo.Receipts, {Ecto.Migrator, repos: Application.fetch_env!(:trinity, :ecto_repos), skip: skip_migrations?()}, {DNSCluster, query: Application.get_env(:trinity, :dns_cluster_query) || :ignore}, @@ -37,6 +42,11 @@ defmodule Trinity.Application do {Task.Supervisor, name: Trinity.LLM.TaskSupervisor}, # Slice 012: one session process per conversation, found by id. {Registry, keys: :unique, name: Trinity.Registry}, + # Slice 024: the signer and its key, the chain writers, the boot receipt. Before the + # tools and the sessions, which receipt through it. + Trinity.Receipts.Supervisor, + # Slice 024: the boot receipt, once the signer and the authority are known. + Trinity.Effects.Boot, # Slice 020: the tool registry and the task supervisor tool calls run under, before # the sessions that call them. Trinity.Tools.Supervisor, diff --git a/lib/trinity/authority.ex b/lib/trinity/authority.ex new file mode 100644 index 0000000..3ee9d05 --- /dev/null +++ b/lib/trinity/authority.ex @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Authority do + @moduledoc """ + For every effect, something decides whether it may happen (ADR-0008). This behaviour is + that something's shape: `stage/2` records an effect as about to happen, `decide/3` answers + whether it may, `execute/3` makes it happen, `receipt/2` records what happened. One + implementation ships in this tree, `Trinity.Authority.Local` (the permission gate decides, + the tool runs here, the receipt is local). An external authority layer is an adapter in + another repository, named by `TRINITY_AUTHORITY`, selected once at boot (ADR-0010) by + `Trinity.Authority.Selection`, and never changed afterwards. + + Identity is not authority: who is calling is `Trinity.MCP.Auth`'s question (slice 062); + whether the effect happens is this one's. + """ + use Boundary, deps: [Trinity, Trinity.Receipts], exports: [Local, Selection, Staged] + + alias Trinity.Authority.Staged + + @type decision :: :allow | :deny + @type basis :: map() + + @doc "Records an effect as staged; the adapter may enrich or refuse it." + @callback stage(Staged.t(), context :: map()) :: {:ok, Staged.t()} | {:error, term()} + + @doc "Decides a staged effect given the gate's decision; the basis says why." + @callback decide(Staged.t(), gate_decision :: decision(), context :: map()) :: + {:ok, decision(), basis()} | {:error, term()} + + @doc "Executes a decided effect (locally: the tool's `execute/2`; an adapter: a proposal)." + @callback execute(Staged.t(), decision(), context :: map()) :: {:ok, term()} | {:error, term()} + + @doc "Records a receipt of a kind with attributes; the adapter may forward it." + @callback receipt(kind :: String.t(), attrs :: map()) :: {:ok, term()} | {:error, term()} + + @callbacks [stage: 2, decide: 3, execute: 3, receipt: 2] + + @doc "The callbacks every implementation must export, as `{name, arity}`." + @spec callbacks() :: [{atom(), arity()}] + def callbacks, do: @callbacks + + @doc "The implementation in force, selected at boot; `Local` before selection." + @spec impl() :: module() + def impl, do: Trinity.Authority.Selection.selected() || Trinity.Authority.Local + + @doc "The selected module's name as the boot receipt records it." + @spec selected_name() :: String.t() + def selected_name, do: inspect(impl()) + + @doc "True when `module` is loaded and exports every callback." + @spec implemented_by?(module()) :: + :ok + | {:error, {:not_loaded, module()} | {:missing_callback, module(), {atom(), arity()}}} + def implemented_by?(module) when is_atom(module) do + if Code.ensure_loaded?(module), + do: missing_callback(module), + else: {:error, {:not_loaded, module}} + end + + defp missing_callback(module) do + case Enum.find(@callbacks, fn {f, a} -> not function_exported?(module, f, a) end) do + nil -> :ok + missing -> {:error, {:missing_callback, module, missing}} + end + end +end diff --git a/lib/trinity/authority/local.ex b/lib/trinity/authority/local.ex new file mode 100644 index 0000000..a3854ea --- /dev/null +++ b/lib/trinity/authority/local.ex @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Authority.Local do + @moduledoc """ + The authority this tree ships (ADR-0008 decision 2): the permission gate's decision is the + decision, the effect runs here, the receipt is local. `execute/3` is the one place in the + tree that calls a tool's `execute/2` for an effectful tool; the census test in + test/trinity/effects/census_test.exs holds that, and when an external adapter is in force + this module is not the one selected, so Trinity keeps no executor for the effects that + adapter governs (decision 5). + """ + @behaviour Trinity.Authority + + alias Trinity.Authority.Staged + + @impl true + def stage(%Staged{} = staged, _ctx), do: {:ok, %{staged | staged_at: DateTime.utc_now()}} + + @impl true + def decide(%Staged{}, :allow, _ctx), do: {:ok, :allow, %{"by" => "gate"}} + def decide(%Staged{}, :deny, _ctx), do: {:ok, :deny, %{"by" => "gate"}} + def decide(%Staged{}, :ask, _ctx), do: {:ok, :deny, %{"by" => "gate", "reason" => "undecided"}} + + @impl true + def execute(%Staged{module: module, args: args}, :allow, ctx) do + module.execute(args, ctx) + rescue + e -> {:error, {:crash, {e, __STACKTRACE__}}} + end + + def execute(%Staged{}, :deny, _ctx), do: {:error, :denied} + + @impl true + def receipt(kind, attrs) when is_binary(kind) and is_map(attrs) do + scope = Map.fetch!(attrs, :scope) + Trinity.Receipts.append(scope, Map.put(attrs, :kind, kind)) + end +end diff --git a/lib/trinity/authority/selection.ex b/lib/trinity/authority/selection.ex new file mode 100644 index 0000000..54b9571 --- /dev/null +++ b/lib/trinity/authority/selection.ex @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Authority.Selection do + @moduledoc """ + Reads `TRINITY_AUTHORITY` once at boot (ADR-0010). `local` (the default) selects + `Trinity.Authority.Local`. Any other value is a module name; Trinity refuses to start unless + that module is loaded and implements every callback, and the refusal names which condition + failed: `{:not_loaded, module}` or `{:missing_callback, module, {name, arity}}`. The + selection is in `:persistent_term`, recorded in the boot receipt, and no runtime path + changes it. Under `local` no adapter module is loaded and no outbound connection is made on + its behalf, which a test asserts rather than this sentence. + """ + + @key {__MODULE__, :selected} + @env "TRINITY_AUTHORITY" + + @doc """ + The child spec: a transient task that selects at boot, as the first child of the + application after the data directory lock, so a refusal stops the boot with its reason + before anything else starts. + """ + @spec child_spec(term()) :: Supervisor.child_spec() + def child_spec(_arg) do + %{id: __MODULE__, start: {Task, :start_link, [fn -> boot!() end]}, restart: :transient} + end + + @doc "Selects from the environment and records the selection; raises with the reason on refusal." + @spec boot!() :: module() + def boot! do + case select(System.get_env(@env)) do + {:ok, module} -> + :persistent_term.put(@key, module) + module + + {:error, reason} -> + raise "#{@env} refused: #{format(reason)}" + end + end + + @doc "The selection rule, pure: a value from the environment to a module or a named refusal." + @spec select(String.t() | nil) :: {:ok, module()} | {:error, term()} + def select(nil), do: {:ok, Trinity.Authority.Local} + def select(""), do: {:ok, Trinity.Authority.Local} + def select("local"), do: {:ok, Trinity.Authority.Local} + + def select(value) when is_binary(value) do + case module_from(value) do + :"Elixir.Trinity.Authority.Unknown" -> + {:error, {:not_loaded, value}} + + module -> + case Trinity.Authority.implemented_by?(module) do + :ok -> {:ok, module} + {:error, reason} -> {:error, reason} + end + end + end + + @doc "The module selected at boot, or nil before it." + @spec selected() :: module() | nil + def selected, do: :persistent_term.get(@key, nil) + + @doc "The environment variable's name." + @spec env() :: String.t() + def env, do: @env + + # "Elixir.Foo.Bar" and "Foo.Bar" both name the Elixir module. A loaded module's name is an + # existing atom; a name that is no existing atom names no loaded module, so it is reported as + # not loaded without ever creating an atom from the environment's text. + defp module_from("Elixir." <> _ = value), do: existing(value) + defp module_from(value), do: existing("Elixir." <> value) + + defp existing(name) do + String.to_existing_atom(name) + rescue + ArgumentError -> :"Elixir.Trinity.Authority.Unknown" + end + + defp format({:not_loaded, m}) when is_binary(m), do: "module #{m} is not loaded" + defp format({:not_loaded, m}), do: "module #{inspect(m)} is not loaded" + + defp format({:missing_callback, m, {f, a}}), + do: "module #{inspect(m)} does not implement #{f}/#{a} of Trinity.Authority" +end diff --git a/lib/trinity/authority/staged.ex b/lib/trinity/authority/staged.ex new file mode 100644 index 0000000..7978e06 --- /dev/null +++ b/lib/trinity/authority/staged.ex @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Authority.Staged do + @moduledoc """ + An effect about to happen, as the membrane hands it to the authority (slice 024): the tool + (name, module, effect class), the validated arguments, the call id (the idempotency key + with the session), the session and scope, the working directory, the gate's decision and + the fingerprint that decision bound. + """ + + @type t :: %__MODULE__{ + tool: String.t(), + module: module(), + effect: :artifact | :catalog, + args: map(), + call_id: String.t() | nil, + session_id: String.t() | nil, + scope: String.t(), + cwd: String.t() | nil, + decision: :allow | :deny | :ask, + basis: map(), + fingerprint: String.t() | nil, + staged_at: DateTime.t() | nil + } + + @enforce_keys [:tool, :module, :effect, :args, :scope, :decision, :fingerprint] + defstruct tool: nil, + module: nil, + effect: nil, + args: %{}, + call_id: nil, + session_id: nil, + scope: nil, + cwd: nil, + decision: nil, + basis: %{}, + fingerprint: nil, + staged_at: nil + + @doc "The subject reference an effect receipt carries: `effect::`." + @spec subject_ref(t()) :: String.t() + def subject_ref(%__MODULE__{session_id: s, call_id: c}), + do: "effect:#{s || "none"}:#{c || "none"}" +end diff --git a/lib/trinity/core_policy.ex b/lib/trinity/core_policy.ex index e584954..251d4c8 100644 --- a/lib/trinity/core_policy.ex +++ b/lib/trinity/core_policy.ex @@ -8,25 +8,55 @@ defmodule Trinity.CorePolicy do state. """ + # Slice 024 extends the list with the modules that decide whether an effect happens: the + # gate and its policy, the fingerprint, the catalog, the membrane and its runner, the + # authority behaviour, its local implementation and its selection, the signer seam. A + # change to any of them is a different hash in the next boot receipt. @modules [ Trinity.Sessions.Session, Trinity.Sessions.Caps, Trinity.Sessions.ToolRunner, Trinity.Sessions.ToolRunner.Stub, - Trinity.Sessions.Sentinel + Trinity.Sessions.Sentinel, + Trinity.Permissions, + Trinity.Permissions.Policy.Layered, + Trinity.Permissions.Fingerprint, + Trinity.Permissions.Gate, + Trinity.Tools.Catalog, + Trinity.Tools.Runner, + Trinity.Effects, + Trinity.Effects.Runner, + Trinity.Authority, + Trinity.Authority.Local, + Trinity.Authority.Selection, + Trinity.Receipts.KeyCustody, + Trinity.Receipts.ChainWriter ] @doc "The modules the hash covers, in order." @spec modules() :: [module()] def modules, do: @modules - @doc "SHA-256, hex, over the concatenated object code of `modules/0`." + @doc "SHA-256, hex, over the concatenated, stripped object code of `modules/0`." @spec hash() :: String.t() - def hash do - @modules + def hash, do: hash_of(@modules) + + @doc """ + The same digest over any list of loaded modules (the AC6 test plants its own). Each + module's beam is stripped first (`:beam_lib.strip/1`: no debug info, no docs), so the + digest covers what the code does and not its metadata. Found at slice 024: the debug-info + chunk stores the expanded AST, and a large map literal in an Ecto query is rendered there + in a key order that depends on the compiling VM's atom table, so the same source compiled + in two VMs (the suite's, then the boundary test's forced recompile) hashed differently and + the boot receipt disagreed with the test that read it. + """ + @spec hash_of([module()]) :: String.t() + def hash_of(modules) when is_list(modules) do + modules |> Enum.map(fn mod -> {^mod, binary, _path} = :code.get_object_code(mod) - binary + {:ok, {^mod, stripped}} = :beam_lib.strip(binary) + stripped end) |> IO.iodata_to_binary() |> then(&:crypto.hash(:sha256, &1)) diff --git a/lib/trinity/effects.ex b/lib/trinity/effects.ex new file mode 100644 index 0000000..dd9e67f --- /dev/null +++ b/lib/trinity/effects.ex @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Effects do + @moduledoc """ + The membrane (slice 024, docs/07): the one side-effect boundary every `:artifact` and + `:catalog` effect crosses. A module, not a process; it holds no state, and the only state + it consults is the receipt chain. + + `execute/2` takes a `Trinity.Authority.Staged` and, in this order, denies with a receipt on + the first thing that is wrong: the decision is not `:allow`; the tool's effect class is not + one the membrane admits, or a `:catalog` tool is not in `Trinity.Tools.Catalog`; the + fingerprint re-derived from the arguments it holds is not the one the decision bound + (AC4, the M2 re-verify); an effect receipt already names this call (the idempotency key: + session and call id); the authority in force refuses. Then it writes the effect's admission + receipt (kind `effect`, phase `admit`), and only if that signed row is in the chain does + the authority execute (`Trinity.Authority.Local`: the tool's `execute/2`); a signer that + cannot sign means the effect is denied and the alarm sounds (AC5). The outcome is a second + effect receipt (phase `done`) with the result's digest; if that one cannot be written the + effect has already happened, which the alarm and the missing row both say. + """ + use Boundary, + deps: [Trinity, Trinity.Tools, Trinity.Permissions, Trinity.Authority, Trinity.Receipts], + exports: [Boot, Runner] + + alias Trinity.Authority + alias Trinity.Authority.Staged + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools.{Catalog, Context, Result} + + @admits [:artifact, :catalog] + + @type outcome :: {:ok, Result.t()} | {:error, term()} + + @doc "The effect classes the membrane admits." + @spec admits() :: [atom()] + def admits, do: @admits + + @doc "Runs a staged effect through the membrane; every path leaves a receipt or an alarm." + @spec execute(Staged.t(), Context.t()) :: outcome() + def execute(%Staged{} = staged, %Context{} = ctx) do + authority = Authority.impl() + + with :ok <- check_decision(staged), + :ok <- check_effect(staged), + :ok <- check_fingerprint(staged), + :ok <- check_idempotency(staged), + {:ok, staged} <- authority.stage(staged, ctx), + {:ok, :allow, basis} <- authority.decide(staged, staged.decision, ctx), + {:ok, _admit} <- receipt(staged, "admit", %{"basis" => basis}) do + result = authority.execute(staged, :allow, ctx) + done(staged, result) + result + else + {:error, reason} -> + deny(staged, reason) + + {:ok, :deny, basis} -> + deny(staged, {:authority_denied, basis}) + end + end + + defp check_decision(%Staged{decision: :allow}), do: :ok + defp check_decision(%Staged{decision: d}), do: {:error, {:decision_not_allow, d}} + + defp check_effect(%Staged{effect: :catalog, tool: tool}) do + if tool in Catalog.names(), do: :ok, else: {:error, {:not_in_catalog, tool}} + end + + defp check_effect(%Staged{effect: :artifact}), do: :ok + defp check_effect(%Staged{effect: e}), do: {:error, {:effect_not_admitted, e}} + + # M2: the approval bound a fingerprint over the arguments the gate saw; the membrane + # re-derives it over the arguments it is about to execute, and a divergence denies. + defp check_fingerprint(%Staged{} = s) do + derived = Permissions.fingerprint(s.session_id, s.tool, s.args, s.cwd) + + if derived == s.fingerprint, + do: :ok, + else: {:error, {:fingerprint_mismatch, s.fingerprint, derived}} + end + + # The idempotency key is the session and the call id, and the chain is the record: an + # admission receipt for this reference means the effect has been run (or is running). + defp check_idempotency(%Staged{} = s) do + ref = Staged.subject_ref(s) + + case Receipts.by_subject_ref(ref, kind: "effect") do + [] -> :ok + [_ | _] -> {:error, {:duplicate_effect, ref}} + end + end + + defp deny(%Staged{} = staged, reason) do + # A denial's receipt may itself fail when the signer is gone; the reason then names both. + case receipt(staged, "denied", %{"reason" => inspect(reason)}) do + {:ok, _} -> {:error, {:denied, reason}} + {:error, why} -> {:error, {:denied, reason, {:receipt_failed, why}}} + end + end + + defp done(%Staged{} = staged, result) do + outcome = + case result do + {:ok, %Result{} = r} -> + %{"ok" => true, "content_digest" => digest(r.content), "truncated" => r.truncated?} + + {:error, reason} -> + %{"ok" => false, "error" => inspect(reason)} + end + + receipt(staged, "done", outcome) + end + + defp receipt(%Staged{} = s, phase, extra) do + Authority.impl().receipt("effect", %{ + scope: s.scope, + subject: %{ + "session_id" => s.session_id, + "call_id" => s.call_id, + "tool" => s.tool, + "effect" => Atom.to_string(s.effect), + "phase" => phase + }, + decision: Map.merge(%{"outcome" => phase, "gate" => Atom.to_string(s.decision)}, extra), + fingerprint: s.fingerprint, + subject_ref: Staged.subject_ref(s), + meta: %{"authority" => Authority.selected_name()} + }) + end + + defp digest(content) when is_binary(content), + do: :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) + + defp digest(other), do: digest(inspect(other)) +end diff --git a/lib/trinity/effects/boot.ex b/lib/trinity/effects/boot.ex new file mode 100644 index 0000000..5364c07 --- /dev/null +++ b/lib/trinity/effects/boot.ex @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Effects.Boot do + @moduledoc """ + Writes the boot receipt (slice 024, ADR-0010), a child of the application after the + receipts supervisor: scope `boot`, kind `boot`, its signed subject + naming the authority in force, the signer's algorithm, scheme and key id, the OTP release + and `crypto:info_fips/0`; `core_policy_hash` and the canonicalisation version in the + unsigned `meta`, per the R21 default. Its hash is what every checkpoint of this run names. + A run without a signer writes no boot receipt and says so. + """ + use Task, restart: :transient + + require Logger + + def start_link(_), do: Task.start_link(__MODULE__, :run, []) + + @doc "Writes the boot receipt and records its hash; returns it." + @spec run() :: {:ok, Trinity.Receipts.Receipt.t()} | {:error, term()} + def run do + subject = %{ + "authority" => Trinity.Authority.selected_name(), + "signer" => signer_subject(), + "otp_release" => List.to_string(:erlang.system_info(:otp_release)), + "fips" => Atom.to_string(:crypto.info_fips()), + "node" => Atom.to_string(node()) + } + + meta = %{ + "core_policy_hash" => Trinity.CorePolicy.hash(), + "canonicalization_version" => Trinity.Permissions.Fingerprint.version() + } + + case Trinity.Receipts.append(Trinity.Receipts.boot_scope(), %{ + kind: "boot", + subject: subject, + subject_ref: "boot:" <> Atom.to_string(node()), + meta: meta + }) do + {:ok, receipt} -> + Trinity.Receipts.put_boot_hash(receipt.receipt_hash) + + Logger.info( + "receipts: boot receipt #{receipt.chain_scope}/#{receipt.seq} #{receipt.receipt_hash}" + ) + + {:ok, receipt} + + {:error, reason} -> + Logger.error("receipts: boot receipt not written: #{inspect(reason)}") + {:error, reason} + end + end + + defp signer_subject do + case Trinity.Receipts.KeyCustody.selected() do + %{algorithm: a, scheme: s, key_id: k} -> + %{"algorithm" => Atom.to_string(a), "scheme" => s, "key_id" => k} + + other -> + %{"unavailable" => inspect(other)} + end + end +end diff --git a/lib/trinity/effects/runner.ex b/lib/trinity/effects/runner.ex new file mode 100644 index 0000000..49026e6 --- /dev/null +++ b/lib/trinity/effects/runner.ex @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Effects.Runner do + @moduledoc """ + The tool runner in force from slice 024 (`config :trinity, :tool_runner`), behind the + `Trinity.Sessions.ToolRunner` seam. `Trinity.Tools.Runner` does the lookup, validation, + timeouts and concurrency; this module supplies the executor: the gate's decision, asked + once and receipted every time (kind `decision`); then for an `effect: :none` tool the call + runs directly with a query receipt, and for anything else the staged effect goes through + `Trinity.Effects.execute/2`, the membrane. + + Nothing fails open: a decision that cannot be receipted (no signer) refuses the call, + reads included, and the alarm says why. + """ + + alias Trinity.Authority.Staged + alias Trinity.Receipts + alias Trinity.Tools.{Context, Result, Runner} + + @doc "One call (the seam's `run/2`)." + @spec run(map(), map()) :: {:ok, Result.t(), map()} | {:error, term(), map()} + def run(call, context) do + [{_call, outcome}] = run_all([call], context) + outcome + end + + @doc "Every call of a turn (the seam's `run_all/2`), through the membrane's executor." + @spec run_all([map()], map()) :: [{map(), {:ok, Result.t(), map()} | {:error, term(), map()}}] + def run_all(calls, context) when is_list(calls), do: Runner.run_all(calls, context, &execute/3) + + @doc "The executor: decide and receipt, then read directly or cross the membrane." + @spec execute(map(), map(), Context.t()) :: {:ok, Result.t()} | {:error, term()} + def execute(entry, args, %Context{} = ctx) do + {decision, fp, reason} = + case Runner.decide(entry, args, ctx) do + {:allow, fp} -> {:allow, fp, nil} + {:deny, fp} -> {:deny, fp, :denied} + {:ask, why, fp} -> {:ask, fp, why} + end + + scope = scope(ctx) + + case decision_receipt(scope, entry, ctx, decision, fp, reason) do + {:ok, _} -> dispatch(decision, entry, args, ctx, scope, fp, reason) + {:error, why} -> {:error, {:decision_not_receipted, why}} + end + end + + defp dispatch(:allow, %{effect: :none} = entry, args, ctx, scope, _fp, _reason) do + result = Runner.call_tool(entry, args, ctx) + query_receipt(scope, entry, ctx, result) + result + end + + defp dispatch(:allow, %{name: name, effect: effect} = entry, args, ctx, scope, fp, _reason) do + Trinity.Effects.execute( + %Staged{ + tool: name, + module: entry.module, + effect: effect, + args: args, + call_id: ctx.call_id, + session_id: ctx.session_id, + scope: scope, + cwd: ctx.cwd, + decision: :allow, + fingerprint: fp + }, + ctx + ) + end + + defp dispatch(_decision, _entry, _args, _ctx, _scope, _fp, reason), do: {:error, reason} + + @doc "The chain scope a context's receipts go to." + @spec scope(Context.t()) :: String.t() + def scope(%Context{session_id: nil}), do: Receipts.session_scope("none") + def scope(%Context{session_id: sid}), do: Receipts.session_scope(sid) + + defp decision_receipt( + scope, + %{name: name, effect: effect, digest: digest}, + ctx, + decision, + fp, + reason + ) do + Receipts.append(scope, %{ + kind: "decision", + subject: %{ + "session_id" => ctx.session_id, + "call_id" => ctx.call_id, + "tool" => name, + "effect" => Atom.to_string(effect) + }, + decision: %{"outcome" => Atom.to_string(decision), "reason" => reason && inspect(reason)}, + fingerprint: fp, + subject_ref: "decision:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}", + meta: %{"tool_definition_digest" => digest} + }) + end + + # Every read emits a query receipt (docs/07): chained, checkpointed, never blocking the read. + defp query_receipt(scope, %{name: name, digest: digest}, ctx, result) do + outcome = + case result do + {:ok, %Result{}} -> %{"ok" => true} + {:error, reason} -> %{"ok" => false, "error" => inspect(reason)} + end + + Receipts.append(scope, %{ + kind: "query", + subject: %{"session_id" => ctx.session_id, "call_id" => ctx.call_id, "tool" => name}, + decision: outcome, + subject_ref: "query:#{ctx.session_id || "none"}:#{ctx.call_id || "none"}", + meta: %{"tool_definition_digest" => digest} + }) + end +end diff --git a/lib/trinity/paths.ex b/lib/trinity/paths.ex index 514241c..1c5e07d 100644 --- a/lib/trinity/paths.ex +++ b/lib/trinity/paths.ex @@ -98,6 +98,29 @@ defmodule Trinity.Paths do @spec database_path() :: String.t() def database_path, do: Path.join(ensure_data_dir(), "trinity.db") + @doc """ + The receipts chain's own SQLite file, beside the primary (slice 024): its own file so it can + run `synchronous: :full` without slowing the primary, per `Trinity.Repo.Receipts`. + """ + @spec receipts_database_path() :: String.t() + def receipts_database_path, do: Path.join(ensure_data_dir(), "receipts.db") + + @doc """ + The directory the receipt signing key and the key registry live in (slice 024): under the + data directory, not `priv/`, because `priv` is the packaged tree and a key made on this + machine is not the tree's to carry. Created with mode 0700 when absent. + """ + # sobelow_skip reason: Traversal.FileModule, as ensure_data_dir/0 above: the path is the data + # directory plus a constant, never input. + @sobelow_skip ["Traversal.FileModule"] + @spec keys_dir() :: String.t() + def keys_dir do + dir = Path.join(ensure_data_dir(), "keys") + File.mkdir_p!(dir) + File.chmod!(dir, 0o700) + dir + end + @spec home(getenv()) :: String.t() defp home(getenv) do getenv.("HOME") || getenv.("USERPROFILE") || "." diff --git a/lib/trinity/receipts.ex b/lib/trinity/receipts.ex new file mode 100644 index 0000000..32b4a0e --- /dev/null +++ b/lib/trinity/receipts.ex @@ -0,0 +1,160 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts do + @moduledoc """ + Local receipts (slice 024, docs/07): per-scope hash chains in their own database, signed + through the signer seam, verifiable offline by a stranger with the key registry. This is + the context's surface; `ChainWriter` is the one process that inserts (ADR-0013), + `KeyCustody` holds the key, `Verifier` walks a chain, `Alarm` sounds when signing fails. + + Chain scopes: `session:` for a session's decision, effect and query receipts (one + writer per live session), `boot` for boot receipts. The count of writers is therefore the + count of live sessions plus one. + """ + use Boundary, + deps: [Trinity], + exports: [ + Receipt, + Checkpoint, + KeyCustody, + KeyRegistry, + Signer, + Envelope, + Verifier, + Alarm, + ChainWriter + ] + + import Ecto.Query + + alias Trinity.Receipts.{ChainWriter, Checkpoint, KeyCustody, KeyRegistry, Receipt} + alias Trinity.Repo.Receipts, as: Repo + + @boot_key {__MODULE__, :boot_hash} + @boot_scope "boot" + + @doc "The scope of a session's chain." + @spec session_scope(String.t()) :: String.t() + def session_scope(session_id) when is_binary(session_id), do: "session:" <> session_id + + @doc "The boot chain's scope." + @spec boot_scope() :: String.t() + def boot_scope, do: @boot_scope + + @doc "Appends a receipt to a scope, starting its writer if needed. See `ChainWriter.append/2`." + @spec append(String.t(), map()) :: {:ok, Receipt.t()} | {:error, term()} + def append(scope, attrs) when is_binary(scope) and is_map(attrs) do + with {:ok, pid} <- ensure_writer(scope) do + ChainWriter.append(pid, attrs) + end + end + + @doc "The scope's writer, started under the writer supervisor if it is not running." + @spec ensure_writer(String.t()) :: {:ok, pid()} | {:error, term()} + def ensure_writer(scope) do + case ChainWriter.whereis(scope) do + nil -> + case DynamicSupervisor.start_child( + Trinity.Receipts.WriterSupervisor, + {ChainWriter, scope} + ) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, reason} -> {:error, reason} + end + + pid -> + {:ok, pid} + end + end + + @doc "Stops a scope's writer (it checkpoints on the way out); a no-op when none runs." + @spec stop_writer(String.t()) :: :ok + def stop_writer(scope) do + case ChainWriter.whereis(scope) do + nil -> + :ok + + pid -> + DynamicSupervisor.terminate_child(Trinity.Receipts.WriterSupervisor, pid) + |> then(fn _ -> :ok end) + end + end + + @doc "A scope's receipts by seq (`limit:`, `kind:`)." + @spec list(String.t(), keyword()) :: [Receipt.t()] + def list(scope, opts \\ []) do + q = from r in Receipt, where: r.chain_scope == ^scope, order_by: r.seq + + q = if k = opts[:kind], do: where(q, [r], r.kind == ^k), else: q + q = if l = opts[:limit], do: limit(q, ^l), else: q + Repo.all(q) + end + + @doc "The newest receipt of a scope, or nil." + @spec tail(String.t()) :: Receipt.t() | nil + def tail(scope), + do: + Repo.one( + from r in Receipt, where: r.chain_scope == ^scope, order_by: [desc: r.seq], limit: 1 + ) + + @doc "A scope's checkpoints by last_seq." + @spec checkpoints(String.t()) :: [Checkpoint.t()] + def checkpoints(scope), + do: Repo.all(from c in Checkpoint, where: c.chain_scope == ^scope, order_by: c.last_seq) + + @doc "Receipts carrying a subject reference (the membrane's idempotency lookup)." + @spec by_subject_ref(String.t(), keyword()) :: [Receipt.t()] + def by_subject_ref(ref, opts \\ []) do + q = from r in Receipt, where: r.subject_ref == ^ref, order_by: r.seq + q = if k = opts[:kind], do: where(q, [r], r.kind == ^k), else: q + Repo.all(q) + end + + @doc "Every scope with at least one receipt." + @spec scopes() :: [String.t()] + def scopes, + do: + Repo.all(from r in Receipt, distinct: true, select: r.chain_scope, order_by: r.chain_scope) + + @doc "Counts per scope, for the pages." + @spec count(String.t()) :: non_neg_integer() + def count(scope), do: Repo.aggregate(from(r in Receipt, where: r.chain_scope == ^scope), :count) + + @doc """ + A scope as the standalone verifier reads it: its receipts, its checkpoints and the key + registry, one map, JSON-encodable. + """ + @spec export(String.t()) :: {:ok, map()} | {:error, term()} + def export(scope) do + with {:ok, registry} <- KeyRegistry.read(KeyCustody.keys_dir()) do + {:ok, + %{ + "format" => "trinity-receipts-export/1", + "chain_scope" => scope, + "exported_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "receipts" => scope |> list() |> Enum.map(&Receipt.to_export/1), + "checkpoints" => scope |> checkpoints() |> Enum.map(&Checkpoint.to_export/1), + "registry" => registry + }} + end + end + + @doc "The boot receipt of this run, or nil before it is written." + @spec boot_receipt() :: Receipt.t() | nil + def boot_receipt do + case boot_hash() do + nil -> nil + hash -> Repo.one(from r in Receipt, where: r.receipt_hash == ^hash) + end + end + + @doc "The boot receipt's hash for this run (checkpoints carry it), or nil." + @spec boot_hash() :: String.t() | nil + def boot_hash, do: :persistent_term.get(@boot_key, nil) + + @doc false + @spec put_boot_hash(String.t()) :: :ok + def put_boot_hash(hash) when is_binary(hash), do: :persistent_term.put(@boot_key, hash) +end diff --git a/lib/trinity/receipts/alarm.ex b/lib/trinity/receipts/alarm.ex new file mode 100644 index 0000000..6c2e819 --- /dev/null +++ b/lib/trinity/receipts/alarm.ex @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Alarm do + @moduledoc """ + The failure that must sound outside the receipt stream (slice 024, the C1 resolution): + a signer that cannot sign. Sets the OTP alarm `:trinity_receipts_signer` through + `:alarm_handler` and emits `[:trinity, :receipts, :signer_unavailable]` with the reason, + so an operator's telemetry sees it where no receipt can say it. + """ + + @alarm :trinity_receipts_signer + @event [:trinity, :receipts, :signer_unavailable] + + @doc "The alarm id." + @spec alarm_id() :: atom() + def alarm_id, do: @alarm + + @doc "The telemetry event name." + @spec event() :: [atom()] + def event, do: @event + + @doc "Raises the alarm (idempotent) and emits the event." + @spec signer_unavailable(term()) :: :ok + def signer_unavailable(reason) do + :alarm_handler.set_alarm({@alarm, reason}) + :telemetry.execute(@event, %{count: 1}, %{reason: reason}) + :ok + end + + @doc "Clears the alarm once a signer signs again." + @spec clear() :: :ok + def clear do + :alarm_handler.clear_alarm(@alarm) + :ok + end + + @doc "True while the alarm is set." + @spec set?() :: boolean() + def set?, do: Enum.any?(:alarm_handler.get_alarms(), fn {id, _} -> id == @alarm end) +end diff --git a/lib/trinity/receipts/chain_writer.ex b/lib/trinity/receipts/chain_writer.ex new file mode 100644 index 0000000..e1e5b32 --- /dev/null +++ b/lib/trinity/receipts/chain_writer.ex @@ -0,0 +1,438 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.ChainWriter do + @moduledoc """ + The one process that inserts into `receipts` for a chain scope (ADR-0013). Registered + `:unique` in `Trinity.Registry` under `{__MODULE__, scope}`, started on demand under + `Trinity.Receipts.WriterSupervisor`. Read-modify-write for the scope happens inside this + process, so a predecessor read and a successor append cannot interleave and the chain + cannot fork. `append/2` is a call: the caller learns whether its receipt is in the chain. + + Per row: the body (`scheme`, `seq`, `chain_scope`, `prev_hash`, `kind`, `subject`, + `decision`, `fingerprint`, `at`, `key_id`) is canonicalised, wrapped in the PAE with the + receipt type, hashed; decision, effect, boot and cap receipts are signed through + `Trinity.Receipts.KeyCustody` before the row is written, and a signer that cannot sign + means no row and `{:error, {:signer_unavailable, reason}}` (never an unsigned row; AC5). + Query receipts are chained unsigned and covered by a checkpoint every N rows, every T + milliseconds after the first uncovered row, on shutdown, and on rehydrate before a new row + is accepted (amendment 5, AC9). + + On start the writer rehydrates the tail: it recomputes the tail row's hash from its stored + body (a tail altered on disk stops the writer with the reason), checks the newest + checkpoint names a tail hash that is in the chain and verifies under its key (C2SP's rule: + never sign a checkpoint inconsistent with one signed before), then checkpoints any + uncovered query rows. + """ + # Temporary: writers start on demand and a crashed one is started again by the next + # append, which rehydrates. A supervisor restart loop on a chain that refuses to start + # (`{:chain_inconsistent, ...}`) would take the supervisor down with it. + use GenServer, restart: :temporary + + import Ecto.Query + + alias Trinity.Receipts.{Checkpoint, Envelope, KeyCustody, KeyRegistry, Receipt, Signer} + alias Trinity.Repo.Receipts, as: Repo + + require Logger + + @default_every 100 + @default_after_ms 5_000 + + defstruct scope: nil, + seq: 0, + prev_hash: nil, + uncovered_first: nil, + uncovered_count: 0, + timer: nil + + @doc "The registry name of a scope's writer." + @spec via(String.t()) :: {:via, Registry, {Trinity.Registry, {module(), String.t()}}} + def via(scope), do: {:via, Registry, {Trinity.Registry, {__MODULE__, scope}}} + + @doc false + def start_link(scope) when is_binary(scope), + do: GenServer.start_link(__MODULE__, scope, name: via(scope)) + + @doc "The writer's pid for a scope, or nil." + @spec whereis(String.t()) :: pid() | nil + def whereis(scope) do + case Registry.lookup(Trinity.Registry, {__MODULE__, scope}) do + [{pid, _}] -> pid + [] -> nil + end + end + + @doc """ + Appends a receipt to the scope's chain. `attrs`: `kind` (one of `Receipt.kinds/0`), + `subject` (map), `decision` (map or nil), `fingerprint` (hex or nil), `subject_ref`, + `meta` (unsigned). + """ + @spec append(pid() | String.t(), map()) :: {:ok, Receipt.t()} | {:error, term()} + def append(scope, attrs) when is_binary(scope), do: GenServer.call(via(scope), {:append, attrs}) + def append(pid, attrs) when is_pid(pid), do: GenServer.call(pid, {:append, attrs}) + + @doc "Forces a checkpoint over any uncovered query rows now; `{:ok, nil}` when there are none." + @spec checkpoint(pid() | String.t(), String.t()) :: + {:ok, Checkpoint.t() | nil} | {:error, term()} + def checkpoint(scope, reason \\ "manual") + + def checkpoint(scope, reason) when is_binary(scope), + do: GenServer.call(via(scope), {:checkpoint, reason}) + + def checkpoint(pid, reason) when is_pid(pid), do: GenServer.call(pid, {:checkpoint, reason}) + + @doc "The checkpoint window in rows and milliseconds, from config." + @spec window() :: {pos_integer(), pos_integer()} + def window do + cfg = Application.get_env(:trinity, :receipts, []) + + {Keyword.get(cfg, :checkpoint_every, @default_every), + Keyword.get(cfg, :checkpoint_after_ms, @default_after_ms)} + end + + ## Server + + @impl true + def init(scope) do + Process.flag(:trap_exit, true) + + case rehydrate(scope) do + {:ok, state} -> {:ok, state, {:continue, :cover_tail}} + {:error, reason} -> {:stop, {:chain_inconsistent, scope, reason}} + end + end + + @impl true + def handle_continue(:cover_tail, state) do + case write_checkpoint(state, "rehydrate") do + {:ok, _, state} -> + {:noreply, state} + + {:error, reason} -> + # Uncovered query rows stay uncovered until a signer is back; the writer runs. + Logger.warning( + "receipts: #{state.scope}: rehydrate checkpoint not written: #{inspect(reason)}" + ) + + {:noreply, state} + end + end + + @impl true + def handle_call({:append, attrs}, _from, state) do + case do_append(attrs, state) do + {:ok, receipt, state} -> {:reply, {:ok, receipt}, maybe_checkpoint(receipt, state)} + {:error, reason} -> {:reply, {:error, reason}, state} + end + end + + def handle_call({:checkpoint, reason}, _from, state) do + case write_checkpoint(state, reason) do + {:ok, cp, state} -> {:reply, {:ok, cp}, state} + {:error, why} -> {:reply, {:error, why}, state} + end + end + + @impl true + def handle_info(:checkpoint_timer, state) do + state = %{state | timer: nil} + + case write_checkpoint(state, "time") do + {:ok, _, state} -> {:noreply, state} + {:error, _} -> {:noreply, state} + end + end + + def handle_info(_msg, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + case write_checkpoint(state, "shutdown") do + {:ok, _, _} -> + :ok + + {:error, reason} -> + Logger.warning( + "receipts: #{state.scope}: shutdown checkpoint not written: #{inspect(reason)}" + ) + end + end + + ## The append + + defp do_append(attrs, state) do + kind = Map.fetch!(attrs, :kind) + + unless kind in Receipt.kinds(), + do: raise(ArgumentError, "unknown receipt kind #{inspect(kind)}") + + with {:ok, %{scheme: scheme, key_id: key_id}} <- selection(), + {row, at} = build_row(attrs, kind, scheme, key_id, state), + bytes = Envelope.pae(Envelope.receipt_type(scheme), row.signed_payload), + {:ok, signature} <- sign_if_needed(kind, bytes) do + insert_row(%{row | signature: signature, inserted_at: at}, state) + end + end + + defp build_row(attrs, kind, scheme, key_id, state) do + seq = state.seq + 1 + at = DateTime.utc_now() + + body = %{ + "scheme" => scheme, + "seq" => seq, + "chain_scope" => state.scope, + "prev_hash" => state.prev_hash, + "kind" => kind, + "subject" => Map.get(attrs, :subject, %{}), + "decision" => Map.get(attrs, :decision), + "fingerprint" => Map.get(attrs, :fingerprint), + "at" => DateTime.to_iso8601(at), + "key_id" => key_id + } + + payload = Envelope.canonical(body) + hash = Envelope.hash(Envelope.pae(Envelope.receipt_type(scheme), payload)) + + row = %Receipt{ + chain_scope: state.scope, + seq: seq, + prev_hash: state.prev_hash, + receipt_hash: hash, + scheme: scheme, + kind: kind, + signed_payload: payload, + key_id: key_id, + subject: Map.get(attrs, :subject, %{}), + subject_ref: Map.get(attrs, :subject_ref), + meta: Map.get(attrs, :meta, %{}) + } + + {row, at} + end + + defp insert_row(%Receipt{} = row, state) do + case Repo.insert(row) do + {:ok, receipt} -> + state = %{state | seq: receipt.seq, prev_hash: receipt.receipt_hash} + {:ok, receipt, track_uncovered(receipt, state)} + + {:error, changeset} -> + {:error, {:insert, changeset.errors}} + end + end + + defp sign_if_needed(kind, bytes) do + if Receipt.signed?(kind) do + case KeyCustody.sign(bytes) do + {:ok, sig} -> + {:ok, sig} + + {:error, reason} -> + Trinity.Receipts.Alarm.signer_unavailable(reason) + {:error, {:signer_unavailable, reason}} + end + else + {:ok, nil} + end + end + + defp selection do + case KeyCustody.selected() do + %{} = s -> {:ok, s} + {:unavailable, reason} -> alarm_and_error(reason) + nil -> alarm_and_error(:not_booted) + end + end + + defp alarm_and_error(reason) do + Trinity.Receipts.Alarm.signer_unavailable(reason) + {:error, {:signer_unavailable, reason}} + end + + ## Checkpoints over query rows + + defp track_uncovered(%Receipt{kind: "query", seq: seq}, state) do + {_every, after_ms} = window() + first = state.uncovered_first || seq + timer = state.timer || Process.send_after(self(), :checkpoint_timer, after_ms) + %{state | uncovered_first: first, uncovered_count: state.uncovered_count + 1, timer: timer} + end + + defp track_uncovered(_receipt, state), do: state + + defp maybe_checkpoint(%Receipt{kind: "query"}, state) do + {every, _} = window() + + if state.uncovered_count >= every do + case write_checkpoint(state, "count") do + {:ok, _, state} -> state + {:error, _} -> state + end + else + state + end + end + + defp maybe_checkpoint(_receipt, state), do: state + + defp write_checkpoint(%{uncovered_first: nil} = state, _reason), do: {:ok, nil, state} + + defp write_checkpoint(state, reason) do + with {:ok, %{scheme: scheme, key_id: key_id}} <- selection(), + {row, bytes} = build_checkpoint(state, reason, scheme, key_id), + {:ok, signature} <- sign_checkpoint(bytes) do + insert_checkpoint(%{row | signature: signature}, state) + end + end + + defp build_checkpoint(state, reason, scheme, key_id) do + at = DateTime.utc_now() + + body = %{ + "scheme" => scheme, + "chain_scope" => state.scope, + "boot_receipt_hash" => Trinity.Receipts.boot_hash(), + "first_seq" => state.uncovered_first, + "last_seq" => state.seq, + "tail_hash" => state.prev_hash, + "key_id" => key_id, + "reason" => reason, + "at" => DateTime.to_iso8601(at) + } + + payload = Envelope.canonical(body) + + row = %Checkpoint{ + chain_scope: state.scope, + boot_receipt_hash: body["boot_receipt_hash"], + first_seq: state.uncovered_first, + last_seq: state.seq, + tail_hash: state.prev_hash, + scheme: scheme, + signed_payload: payload, + key_id: key_id, + reason: reason, + inserted_at: at + } + + {row, Envelope.pae(Envelope.checkpoint_type(scheme), payload)} + end + + defp sign_checkpoint(bytes) do + case KeyCustody.sign(bytes) do + {:ok, signature} -> + {:ok, signature} + + {:error, why} -> + Trinity.Receipts.Alarm.signer_unavailable(why) + {:error, {:signer_unavailable, why}} + end + end + + defp insert_checkpoint(%Checkpoint{} = row, state) do + case Repo.insert(row) do + {:ok, cp} -> + if state.timer, do: Process.cancel_timer(state.timer) + {:ok, cp, %{state | uncovered_first: nil, uncovered_count: 0, timer: nil}} + + {:error, changeset} -> + {:error, {:insert, changeset.errors}} + end + end + + ## Rehydrate + + defp rehydrate(scope) do + tail = + Repo.one( + from r in Receipt, where: r.chain_scope == ^scope, order_by: [desc: r.seq], limit: 1 + ) + + with :ok <- check_tail(tail), + newest = + Repo.one( + from c in Checkpoint, + where: c.chain_scope == ^scope, + order_by: [desc: c.last_seq], + limit: 1 + ), + :ok <- check_checkpoint(newest, scope) do + {first, count} = uncovered(scope, newest) + + {:ok, + %__MODULE__{ + scope: scope, + seq: (tail && tail.seq) || 0, + prev_hash: tail && tail.receipt_hash, + uncovered_first: first, + uncovered_count: count + }} + end + end + + defp check_tail(nil), do: :ok + + defp check_tail(%Receipt{} = tail) do + bytes = Envelope.pae(Envelope.receipt_type(tail.scheme), tail.signed_payload) + + if Envelope.hash(bytes) == tail.receipt_hash, + do: :ok, + else: {:error, {:tail_hash_mismatch, tail.seq}} + end + + defp check_checkpoint(nil, _scope), do: :ok + + defp check_checkpoint(%Checkpoint{} = cp, scope) do + row = Repo.one(from r in Receipt, where: r.chain_scope == ^scope and r.seq == ^cp.last_seq) + + with true <- + (row && row.receipt_hash == cp.tail_hash) || + {:error, {:checkpoint_tail_not_in_chain, cp.last_seq}}, + {:ok, impl} <- + Signer.impl_for_scheme(cp.scheme) |> ok_or({:error, {:unknown_scheme, cp.scheme}}), + {:ok, pub} <- public_key(cp.key_id), + true <- + impl.verify( + Envelope.pae(Envelope.checkpoint_type(cp.scheme), cp.signed_payload), + cp.signature, + pub + ) || {:error, {:checkpoint_signature_invalid, cp.last_seq}} do + :ok + end + end + + defp ok_or({:ok, v}, _), do: {:ok, v} + defp ok_or(:error, err), do: err + + defp public_key(key_id) do + dir = + case KeyCustody.selected() do + %{keys_dir: d} -> d + _ -> KeyCustody.keys_dir() + end + + with {:ok, rows} <- KeyRegistry.read(dir), + %{} = row <- KeyRegistry.lookup(rows, key_id) || {:error, {:unknown_key_id, key_id}} do + KeyRegistry.public_key(row) |> ok_or({:error, {:key_without_public, key_id}}) + end + end + + # The uncovered query rows: after the newest checkpoint's last_seq (or from the start). + defp uncovered(scope, newest) do + since = (newest && newest.last_seq) || 0 + + rows = + Repo.all( + from r in Receipt, + where: r.chain_scope == ^scope and r.seq > ^since and r.kind == "query", + select: r.seq, + order_by: r.seq + ) + + case rows do + [] -> {nil, 0} + [first | _] -> {first, length(rows)} + end + end +end diff --git a/lib/trinity/receipts/checkpoint.ex b/lib/trinity/receipts/checkpoint.ex new file mode 100644 index 0000000..5901133 --- /dev/null +++ b/lib/trinity/receipts/checkpoint.ex @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Checkpoint do + @moduledoc """ + A signed coverage block over a scope's query receipts (slice 024, amendment 5; RFC 5848's + shape, research amendment D): which boot it belongs to, the first and last seq it covers, + the tail hash at `last_seq`, and a signature over the PAE of its own canonical body. A + row, never a write onto a receipt. + """ + use Ecto.Schema + + @primary_key {:id, Trinity.UUID, autogenerate: true} + + @type t :: %__MODULE__{} + + schema "receipt_checkpoints" do + field :chain_scope, :string + field :boot_receipt_hash, :string + field :first_seq, :integer + field :last_seq, :integer + field :tail_hash, :string + field :scheme, :string + field :signed_payload, :string + field :signature, :binary + field :key_id, :string + field :reason, :string + field :inserted_at, :utc_datetime_usec + end + + @doc "The row as the export and the standalone verifier read it." + @spec to_export(t()) :: map() + def to_export(%__MODULE__{} = c) do + %{ + "chain_scope" => c.chain_scope, + "boot_receipt_hash" => c.boot_receipt_hash, + "first_seq" => c.first_seq, + "last_seq" => c.last_seq, + "tail_hash" => c.tail_hash, + "scheme" => c.scheme, + "signed_payload" => c.signed_payload, + "signature_b64" => Base.encode64(c.signature), + "key_id" => c.key_id, + "reason" => c.reason + } + end +end diff --git a/lib/trinity/receipts/envelope.ex b/lib/trinity/receipts/envelope.ex new file mode 100644 index 0000000..59f99d3 --- /dev/null +++ b/lib/trinity/receipts/envelope.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Envelope do + @moduledoc """ + The bytes a signature covers (slice 024, research amendment A). A receipt body is RFC 8785 + canonical JSON; the signature is over DSSE's pre-authentication encoding of a payload type + and that body, `"DSSEv1" SP LEN(type) SP type SP LEN(body) SP body`, with the type naming + what the bytes are (`trinity/receipt/`, `trinity/checkpoint/`). The same key + signs receipts here and, at slice 061, MCP envelopes; the type inside the signed bytes is + what keeps a signature made for one from being presented as the other. + """ + + @doc "RFC 8785 canonical JSON of a term with string keys." + @spec canonical(map()) :: String.t() + def canonical(map) when is_map(map), do: Jcs.encode(map) + + @doc "DSSE's PAE over a payload type and a body." + @spec pae(String.t(), binary()) :: binary() + def pae(type, body) when is_binary(type) and is_binary(body) do + "DSSEv1 " <> + Integer.to_string(byte_size(type)) <> + " " <> type <> " " <> Integer.to_string(byte_size(body)) <> " " <> body + end + + @doc "The payload type of a receipt of this scheme." + @spec receipt_type(String.t()) :: String.t() + def receipt_type(scheme), do: "trinity/receipt/" <> scheme + + @doc "The payload type of a checkpoint of this scheme." + @spec checkpoint_type(String.t()) :: String.t() + def checkpoint_type(scheme), do: "trinity/checkpoint/" <> scheme + + @doc "SHA-256 of bytes, lowercase hex: the `receipt_hash`, and the next row's `prev_hash`." + @spec hash(binary()) :: String.t() + def hash(bytes) when is_binary(bytes), + do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) +end diff --git a/lib/trinity/receipts/key_custody.ex b/lib/trinity/receipts/key_custody.ex new file mode 100644 index 0000000..8725aba --- /dev/null +++ b/lib/trinity/receipts/key_custody.ex @@ -0,0 +1,212 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.KeyCustody do + @moduledoc """ + Where the receipt signing key lives and how the algorithm is chosen (slice 024, + amendments 1 and 2). Selection happens once, at boot, in `boot!/1`: ECDSA P-384 when + `crypto:info_fips/0` returns `enabled`, Ed25519 otherwise; ML-DSA-87 only by configuration + (`config :trinity, :receipts, algorithm: :mldsa87`) and only where the runtime carries it. + Denial happens only when no approved algorithm is available, never because the default is. + + The key is a file, `receipts-.key` under the keys directory, mode 0600, made on + first run; its registry row is appended to `registry.json` the same moment. **What a + file-backed key establishes**: that the chain was not altered after the fact by anything + lacking read access to that file, and nothing more. Slice 100 moves it to the OS keychain + and the registry records the change as a new row. + + `sign/1` reads the key file on every call and never caches the key, so a key removed + mid-run is a signer that has become unavailable at the next receipt, not at the next + restart (AC5). The selection itself is in `:persistent_term`, set once and read by every + chain writer; no runtime path changes it (ADR-0010's rule for the authority, applied here). + """ + + alias Trinity.Receipts.{KeyRegistry, Signer} + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @key {__MODULE__, :selected} + + @type selection :: %{ + algorithm: Signer.algorithm(), + scheme: String.t(), + key_id: String.t(), + key_path: Path.t(), + keys_dir: Path.t(), + impl: module() + } + + @doc "The keys directory in force: config `:trinity, :receipts, :keys_dir`, else the data directory's." + # sobelow_skip reason: Traversal.FileModule: the directory comes from this application's + # configuration or from Trinity.Paths, never from a request. + @sobelow_skip ["Traversal.FileModule"] + @spec keys_dir() :: Path.t() + def keys_dir do + case Application.get_env(:trinity, :receipts, [])[:keys_dir] do + nil -> + Trinity.Paths.keys_dir() + + dir -> + File.mkdir_p!(dir) + dir + end + end + + @doc """ + Selects the algorithm, ensures its key and registry row exist, and records the selection. + Returns the selection, or `{:error, reason}` when no approved algorithm can sign here (the + chain then denies every effect, and the boot receipt cannot be written). + """ + @spec boot!(Path.t() | nil) :: {:ok, selection()} | {:error, term()} + def boot!(dir \\ nil) do + dir = dir || keys_dir() + + with {:ok, algorithm} <- select(), + impl = Signer.impl(algorithm), + {:ok, key_id} <- ensure_key(dir, impl) do + selection = %{ + algorithm: algorithm, + scheme: impl.scheme(), + key_id: key_id, + key_path: key_path(dir, algorithm), + keys_dir: dir, + impl: impl + } + + :persistent_term.put(@key, selection) + {:ok, selection} + else + {:error, reason} -> + :persistent_term.put(@key, {:unavailable, reason}) + {:error, reason} + end + end + + @doc "The selection made at boot, or `nil` before it, or `{:unavailable, reason}`." + @spec selected() :: selection() | {:unavailable, term()} | nil + def selected, do: :persistent_term.get(@key, nil) + + @doc """ + The algorithm the rules select on this runtime: the configured one when it is available, + else P-384 in FIPS mode, else Ed25519; `{:error, :no_approved_signer}` when the selected + implementation reports itself unavailable. + """ + @spec select() :: {:ok, Signer.algorithm()} | {:error, term()} + def select do + configured = Application.get_env(:trinity, :receipts, [])[:algorithm] + + algorithm = + cond do + configured in [:ed25519, :p384, :mldsa87] -> configured + :crypto.info_fips() == :enabled -> :p384 + true -> :ed25519 + end + + if Signer.impl(algorithm).available?(), + do: {:ok, algorithm}, + else: {:error, {:no_approved_signer, algorithm, :crypto.info_fips()}} + end + + @doc "Signs the PAE bytes with the selected key, read from its file at this call." + @spec sign(binary()) :: {:ok, binary()} | {:error, :signer_unavailable | term()} + def sign(bytes) when is_binary(bytes) do + case selected() do + %{impl: impl, key_path: path, key_id: key_id} -> + with {:ok, priv} <- read_private(path, impl, key_id) do + {:ok, impl.sign(bytes, priv)} + end + + {:unavailable, reason} -> + {:error, {:signer_unavailable, reason}} + + nil -> + {:error, {:signer_unavailable, :not_booted}} + end + end + + @doc "The MCP core's signer seam shape (slice 061 wires it): `sign/2` with options ignored here." + @spec sign(binary(), keyword()) :: {:ok, binary()} | {:error, term()} + def sign(bytes, _opts), do: sign(bytes) + + @doc "The key file for an algorithm in a keys directory." + @spec key_path(Path.t(), Signer.algorithm()) :: Path.t() + def key_path(dir, algorithm), do: Path.join(dir, "receipts-#{algorithm}.key") + + # sobelow_skip reason: Traversal.FileModule: the path is the keys directory plus a constant + # per algorithm, never input. + @sobelow_skip ["Traversal.FileModule"] + defp ensure_key(dir, impl) do + path = key_path(dir, impl.algorithm()) + + case File.read(path) do + {:ok, bin} -> + with {:ok, %{"key_id" => key_id, "algorithm" => alg}} <- JSON.decode(bin), + true <- alg == Atom.to_string(impl.algorithm()) || {:error, :key_file_algorithm}, + {:ok, rows} <- KeyRegistry.read(dir), + %{} <- KeyRegistry.lookup(rows, key_id) || {:error, {:key_not_in_registry, key_id}} do + {:ok, key_id} + end + + {:error, :enoent} -> + generate(dir, impl, path) + + {:error, reason} -> + {:error, {:key_file, reason}} + end + end + + # sobelow_skip reason: Traversal.FileModule: `path` is the keys directory plus a constant per + # algorithm (key_path/2), never input. + @sobelow_skip ["Traversal.FileModule"] + defp generate(dir, impl, path) do + {pub, priv} = impl.generate_key() + jwk = impl.jwk(pub) + + {key_id, kid_scheme} = + case jwk do + nil -> {:crypto.hash(:sha256, pub) |> Base.url_encode64(padding: false), "sha256-raw"} + jwk -> {Signer.thumbprint(jwk), "rfc7638"} + end + + row = %{ + "key_id" => key_id, + "kid_scheme" => kid_scheme, + "algorithm" => Atom.to_string(impl.algorithm()), + "scheme" => impl.scheme(), + "jwk" => jwk, + "public_key_b64" => Base.encode64(pub), + "fingerprint" => :crypto.hash(:sha256, pub) |> Base.encode16(case: :lower), + "valid_from" => DateTime.utc_now() |> DateTime.to_iso8601(), + "status" => "active", + "custody" => "file" + } + + file = + JSON.encode!(%{ + "algorithm" => Atom.to_string(impl.algorithm()), + "key_id" => key_id, + "private_b64" => Base.encode64(impl.encode_private(priv)), + "public_b64" => Base.encode64(pub) + }) + + with :ok <- File.write(path, file), + :ok <- File.chmod(path, 0o600), + {:ok, _} <- KeyRegistry.append(dir, row) do + {:ok, key_id} + end + end + + # sobelow_skip reason: Traversal.FileModule: `path` is the selection's key path, built by + # key_path/2 at boot from the keys directory and the algorithm, never input. + @sobelow_skip ["Traversal.FileModule"] + defp read_private(path, impl, key_id) do + with {:ok, bin} <- File.read(path), + {:ok, %{"private_b64" => b64, "key_id" => ^key_id}} <- JSON.decode(bin), + {:ok, encoded} <- Base.decode64(b64) do + {:ok, impl.decode_private(encoded)} + else + {:error, :enoent} -> {:error, :signer_unavailable} + {:error, reason} -> {:error, {:signer_unavailable, reason}} + _ -> {:error, {:signer_unavailable, :key_file_mismatch}} + end + end +end diff --git a/lib/trinity/receipts/key_registry.ex b/lib/trinity/receipts/key_registry.ex new file mode 100644 index 0000000..a822cf2 --- /dev/null +++ b/lib/trinity/receipts/key_registry.ex @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.KeyRegistry do + @moduledoc """ + The append-only key registry, `registry.json` in the keys directory (slice 024). One JSON + array of rows; a row is never edited: a status change (`retired`, `compromised`) is a new + row for the same `key_id` with the new status and its own `valid_from`, and the newest row + for a key id is the one in force. `append/2` refuses to write if any earlier row would + change, so the file only grows. + + A row: `key_id` (RFC 7638 thumbprint of the JWK, or SHA-256 of the raw public key where no + JWK form exists; `kid_scheme` says which), `algorithm`, `scheme`, `jwk` or + `public_key_b64`, `fingerprint` (SHA-256 of the raw public key, hex), `valid_from`, `status`. + The verifier reads the algorithm from here and nowhere else. + """ + + @type row :: %{required(String.t()) => term()} + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @file_name "registry.json" + + @doc "The registry file's path in a keys directory." + @spec path(Path.t()) :: Path.t() + def path(keys_dir), do: Path.join(keys_dir, @file_name) + + @doc "Every row, oldest first; an absent file is an empty registry." + # sobelow_skip reason: Traversal.FileModule: the path is a keys directory the caller took + # from configuration or Trinity.Paths, plus the constant file name; never input. + @sobelow_skip ["Traversal.FileModule"] + @spec read(Path.t()) :: {:ok, [row()]} | {:error, term()} + def read(keys_dir) do + case File.read(path(keys_dir)) do + {:ok, bin} -> decode(bin) + {:error, :enoent} -> {:ok, []} + {:error, reason} -> {:error, reason} + end + end + + @doc "Parses registry bytes (the standalone verifier reads a copied file this way too)." + @spec decode(binary()) :: {:ok, [row()]} | {:error, term()} + def decode(bin) do + case JSON.decode(bin) do + {:ok, rows} when is_list(rows) -> {:ok, rows} + {:ok, _} -> {:error, :not_a_list} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Appends a row. Reads the file, checks every existing row is byte-for-byte what it was, + writes the array with the row at the end. Returns the rows as written. + """ + # sobelow_skip reason: Traversal.FileModule: as read/1, the keys directory plus the constant + # file name and its `.tmp` sibling. + @sobelow_skip ["Traversal.FileModule"] + @spec append(Path.t(), row()) :: {:ok, [row()]} | {:error, term()} + def append(keys_dir, row) when is_map(row) do + with {:ok, rows} <- read(keys_dir) do + rows = rows ++ [row] + tmp = path(keys_dir) <> ".tmp" + + with :ok <- File.write(tmp, JSON.encode!(rows)), + :ok <- File.chmod(tmp, 0o600), + :ok <- File.rename(tmp, path(keys_dir)) do + {:ok, rows} + end + end + end + + @doc "The newest row for a key id from a list of rows, or `nil`." + @spec lookup([row()], String.t()) :: row() | nil + def lookup(rows, key_id) when is_list(rows) and is_binary(key_id) do + rows |> Enum.filter(&(&1["key_id"] == key_id)) |> List.last() + end + + @doc "The newest active row for an algorithm, or `nil`." + @spec active_for([row()], atom()) :: row() | nil + def active_for(rows, algorithm) do + alg = Atom.to_string(algorithm) + + rows + |> Enum.group_by(& &1["key_id"]) + |> Enum.map(fn {_, rs} -> List.last(rs) end) + |> Enum.filter(&(&1["algorithm"] == alg and &1["status"] == "active")) + |> Enum.sort_by(& &1["valid_from"]) + |> List.last() + end + + @doc "The raw public key of a row." + @spec public_key(row()) :: {:ok, binary()} | :error + def public_key(%{"public_key_b64" => b64}) when is_binary(b64), do: Base.decode64(b64) + def public_key(_), do: :error +end diff --git a/lib/trinity/receipts/receipt.ex b/lib/trinity/receipts/receipt.ex new file mode 100644 index 0000000..0ce33e6 --- /dev/null +++ b/lib/trinity/receipts/receipt.ex @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Receipt do + @moduledoc """ + One row of a receipt chain (slice 024, docs/05). `signed_payload` is the RFC 8785 body the + signature covers through the PAE; `receipt_hash` is SHA-256 over those PAE bytes and the + next row's `prev_hash`. `signature` is present for decision, effect, boot and cap receipts + and absent for query receipts, which a checkpoint covers. Never updated, never deleted. + """ + use Ecto.Schema + + @primary_key {:id, Trinity.UUID, autogenerate: true} + + @type t :: %__MODULE__{} + + schema "receipts" do + field :chain_scope, :string + field :seq, :integer + field :prev_hash, :string + field :receipt_hash, :string + field :scheme, :string + field :kind, :string + field :signed_payload, :string + field :signature, :binary + field :key_id, :string + field :subject, :map, default: %{} + field :subject_ref, :string + field :meta, :map, default: %{} + field :inserted_at, :utc_datetime_usec + end + + @kinds ~w(decision effect query boot cap) + @signed_kinds ~w(decision effect boot cap) + + @doc "The five kinds." + @spec kinds() :: [String.t()] + def kinds, do: @kinds + + @doc "The kinds signed one by one; `query` is checkpointed instead." + @spec signed?(String.t()) :: boolean() + def signed?(kind), do: kind in @signed_kinds + + @doc "The row as the export and the standalone verifier read it." + @spec to_export(t()) :: map() + def to_export(%__MODULE__{} = r) do + %{ + "chain_scope" => r.chain_scope, + "seq" => r.seq, + "prev_hash" => r.prev_hash, + "receipt_hash" => r.receipt_hash, + "scheme" => r.scheme, + "kind" => r.kind, + "signed_payload" => r.signed_payload, + "signature_b64" => r.signature && Base.encode64(r.signature), + "key_id" => r.key_id, + "meta" => r.meta + } + end +end diff --git a/lib/trinity/receipts/signer.ex b/lib/trinity/receipts/signer.ex new file mode 100644 index 0000000..2159e4a --- /dev/null +++ b/lib/trinity/receipts/signer.ex @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Signer do + @moduledoc """ + The signer seam (slice 024, amendment 1): one behaviour, one algorithm per implementation, + selected once at boot by `Trinity.Receipts.KeyCustody`. A receipt names its family in its + scheme string and its key in `key_id`; the verifier takes the algorithm from the key + registry row and never from the receipt (amendment 3, RFC 8725 section 3.1). + + Implementations: `Ed25519` (the default), `P384` (selected when `crypto:info_fips/0` is + `enabled`), `MLDSA87` (opt-in, available only where the linked OpenSSL carries it). + """ + + @type algorithm :: :ed25519 | :p384 | :mldsa87 + @type public_key :: binary() + @type private_key :: term() + + @doc "The algorithm this implementation signs with." + @callback algorithm() :: algorithm() + + @doc "The scheme string a receipt of this family carries: `receipt_v2_`." + @callback scheme() :: String.t() + + @doc "True where this runtime can sign and verify with this algorithm." + @callback available?() :: boolean() + + @doc "A fresh key pair." + @callback generate_key() :: {public_key(), private_key()} + + @doc "Signs `bytes` with the private key; the bytes are the PAE, never a bare payload." + @callback sign(bytes :: binary(), private_key()) :: binary() + + @doc "Verifies `signature` over `bytes` with the public key." + @callback verify(bytes :: binary(), signature :: binary(), public_key()) :: boolean() + + @doc """ + The RFC 7638 required JWK members for the public key (`crv`, `kty`, `x`, `y` for EC; `crv`, + `kty`, `x` for OKP), or `nil` where no JWK form is registered for the algorithm. + """ + @callback jwk(public_key()) :: map() | nil + + @doc "Encodes a private key for the key file; the inverse of `decode_private/1`." + @callback encode_private(private_key()) :: binary() + + @doc "Decodes a private key from the key file." + @callback decode_private(binary()) :: private_key() + + @implementations %{ + ed25519: Trinity.Receipts.Signer.Ed25519, + p384: Trinity.Receipts.Signer.P384, + mldsa87: Trinity.Receipts.Signer.MLDSA87 + } + + @doc "The implementation for an algorithm." + @spec impl(algorithm()) :: module() + def impl(algorithm) when is_map_key(@implementations, algorithm), + do: Map.fetch!(@implementations, algorithm) + + @doc "The implementation whose scheme string this is, or `:error`." + @spec impl_for_scheme(String.t()) :: {:ok, module()} | :error + def impl_for_scheme(scheme) do + case Enum.find(@implementations, fn {_, m} -> m.scheme() == scheme end) do + {_, m} -> {:ok, m} + nil -> :error + end + end + + @doc "Every algorithm this tree knows, in the order of preference outside FIPS mode." + @spec algorithms() :: [algorithm()] + def algorithms, do: [:ed25519, :p384, :mldsa87] + + @doc """ + The RFC 7638 thumbprint of a JWK: the required members serialised with no whitespace in + lexicographic order, SHA-256, base64url without padding. + """ + @spec thumbprint(map()) :: String.t() + def thumbprint(jwk) when is_map(jwk) do + json = + jwk + |> Enum.sort_by(fn {k, _} -> k end) + |> Enum.map_join(",", fn {k, v} -> ~s("#{k}":"#{v}") end) + + :crypto.hash(:sha256, "{" <> json <> "}") |> Base.url_encode64(padding: false) + end +end diff --git a/lib/trinity/receipts/signer/ed25519.ex b/lib/trinity/receipts/signer/ed25519.ex new file mode 100644 index 0000000..7527517 --- /dev/null +++ b/lib/trinity/receipts/signer/ed25519.ex @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Signer.Ed25519 do + @moduledoc "Ed25519 (RFC 8032) through `:crypto`; the default outside FIPS mode. Slice 024." + @behaviour Trinity.Receipts.Signer + + @impl true + def algorithm, do: :ed25519 + + @impl true + def scheme, do: "receipt_v2_ed25519" + + @impl true + def available?, do: :ed25519 in :crypto.supports(:curves) and :crypto.info_fips() != :enabled + + @impl true + def generate_key, do: :crypto.generate_key(:eddsa, :ed25519) + + @impl true + def sign(bytes, priv) when is_binary(bytes) and is_binary(priv), + do: :crypto.sign(:eddsa, :none, bytes, [priv, :ed25519]) + + @impl true + def verify(bytes, sig, pub) when is_binary(bytes) and is_binary(sig) and is_binary(pub), + do: :crypto.verify(:eddsa, :none, bytes, sig, [pub, :ed25519]) + + @impl true + def jwk(pub) when byte_size(pub) == 32, + do: %{"crv" => "Ed25519", "kty" => "OKP", "x" => Base.url_encode64(pub, padding: false)} + + @impl true + def encode_private(priv), do: priv + + @impl true + def decode_private(bin), do: bin +end diff --git a/lib/trinity/receipts/signer/mldsa87.ex b/lib/trinity/receipts/signer/mldsa87.ex new file mode 100644 index 0000000..84c9366 --- /dev/null +++ b/lib/trinity/receipts/signer/mldsa87.ex @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Signer.MLDSA87 do + @moduledoc """ + ML-DSA-87 (FIPS 204) through `:crypto`, opt-in and never the default (slice 024, amendment + 6). Available only where the linked OpenSSL is 3.5 or later; `available?/0` asks the runtime + rather than the build, so the module compiles everywhere and refuses where it cannot run. + Measured on the slice 003 image (OpenSSL 3.5.8, mode off): a 4,627-byte signature, 905 µs + to sign, 172 µs to verify; refused in FIPS mode there. The private key OTP returns is a + tagged tuple (`{:seed | :expandedkey, binary}`); the key file keeps the tag. + + No JWK form is registered for ML-DSA at this date; `jwk/1` is `nil` and the key id is the + SHA-256 of the raw public key (`kid_scheme` `sha256-raw` in the registry row). + """ + @behaviour Trinity.Receipts.Signer + + @impl true + def algorithm, do: :mldsa87 + + @impl true + def scheme, do: "receipt_v2_mldsa87" + + @impl true + def available?, do: :mldsa87 in :crypto.supports(:public_keys) + + @impl true + def generate_key, do: :crypto.generate_key(:mldsa87, []) + + @impl true + def sign(bytes, priv) when is_binary(bytes), do: :crypto.sign(:mldsa87, :none, bytes, priv) + + @impl true + def verify(bytes, sig, pub) when is_binary(bytes) and is_binary(sig) and is_binary(pub), + do: :crypto.verify(:mldsa87, :none, bytes, sig, pub) + + @impl true + def jwk(_pub), do: nil + + @impl true + def encode_private({tag, bin}) when tag in [:seed, :expandedkey] and is_binary(bin), + do: Atom.to_string(tag) <> ":" <> bin + + @impl true + def decode_private("seed:" <> bin), do: {:seed, bin} + def decode_private("expandedkey:" <> bin), do: {:expandedkey, bin} +end diff --git a/lib/trinity/receipts/signer/p384.ex b/lib/trinity/receipts/signer/p384.ex new file mode 100644 index 0000000..2796df9 --- /dev/null +++ b/lib/trinity/receipts/signer/p384.ex @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Signer.P384 do + @moduledoc """ + ECDSA on secp384r1 with SHA-384 through `:crypto`; selected when FIPS mode is enabled + (slice 024, amendment 2). The signature is DER as OTP returns it, 102 to 104 bytes + (measured at G1; SLICE.md's table says 96, which is the raw r||s size). + """ + @behaviour Trinity.Receipts.Signer + + @impl true + def algorithm, do: :p384 + + @impl true + def scheme, do: "receipt_v2_p384" + + @impl true + def available?, do: :secp384r1 in :crypto.supports(:curves) + + @impl true + def generate_key, do: :crypto.generate_key(:ecdh, :secp384r1) + + @impl true + def sign(bytes, priv) when is_binary(bytes) and is_binary(priv), + do: :crypto.sign(:ecdsa, :sha384, bytes, [priv, :secp384r1]) + + @impl true + def verify(bytes, sig, pub) when is_binary(bytes) and is_binary(sig) and is_binary(pub), + do: :crypto.verify(:ecdsa, :sha384, bytes, sig, [pub, :secp384r1]) + + # The public key is the uncompressed point 0x04 || x || y, 97 bytes. + @impl true + def jwk(<<4, x::binary-size(48), y::binary-size(48)>>) do + %{ + "crv" => "P-384", + "kty" => "EC", + "x" => Base.url_encode64(x, padding: false), + "y" => Base.url_encode64(y, padding: false) + } + end + + @impl true + def encode_private(priv), do: priv + + @impl true + def decode_private(bin), do: bin +end diff --git a/lib/trinity/receipts/supervisor.ex b/lib/trinity/receipts/supervisor.ex new file mode 100644 index 0000000..fdbf7e8 --- /dev/null +++ b/lib/trinity/receipts/supervisor.ex @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Supervisor do + @moduledoc """ + Starts the receipts side (slice 024): selects the signer and its key once (`KeyCustody.boot!/1`) + before any writer exists, then the writer supervisor; the boot receipt is + `Trinity.Effects.Boot`, the application's next child. A failed + selection is logged and the alarm set; the tree still starts, and every effect is denied + until a signer is available (the C1 resolution: fail closed, never unsigned). + """ + use Supervisor + + require Logger + + def start_link(opts), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + + @impl true + def init(_opts) do + case Trinity.Receipts.KeyCustody.boot!() do + {:ok, %{algorithm: alg, key_id: key_id}} -> + Logger.info("receipts: signer #{alg}, key #{key_id}") + + {:error, reason} -> + Logger.error( + "receipts: no signer: #{inspect(reason)}; every effect is denied until one is" + ) + + Trinity.Receipts.Alarm.signer_unavailable(reason) + end + + children = [ + {DynamicSupervisor, name: Trinity.Receipts.WriterSupervisor, strategy: :one_for_one} + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/lib/trinity/receipts/verifier.ex b/lib/trinity/receipts/verifier.ex new file mode 100644 index 0000000..75587a3 --- /dev/null +++ b/lib/trinity/receipts/verifier.ex @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.Verifier do + @moduledoc """ + Walks one scope's chain and says whether it verifies (slice 024, AC3, AC7, AC8). Pure: + it takes the rows, the checkpoints and the registry as plain maps (the export format, so + the in-app task and the standalone `bin/verify_receipt.exs` run the same code), and uses + `:crypto` and nothing else. + + For each receipt in seq order: the seq is the previous plus one; `prev_hash` is the + previous `receipt_hash`; the scheme is one this verifier was told to accept (RFC 8725 + section 3.1: the caller names the allowed set); the key id resolves in the registry and the + registry row's algorithm is the one the scheme names, else the receipt is refused before + any signature is checked (SLICE.md amendments 3 and 4); the row's status is not + `compromised`; the hash recomputes from the stored body through the PAE; a signed kind's + signature verifies with the registry's public key under the registry's algorithm. Every + query receipt must be covered by a checkpoint whose tail hash is in the chain and whose + signature verifies the same way; a covered range never has a gap. + + 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), `6` a key the + registry marks compromised. + """ + + alias Trinity.Receipts.{Envelope, Signer} + + @type outcome :: + {:ok, %{receipts: non_neg_integer(), checkpoints: non_neg_integer()}} + | {:error, code :: 1 | 5 | 6, reason :: term()} + + @signed_kinds ~w(decision effect boot cap) + + @doc "Exit code for an outcome." + @spec exit_code(outcome()) :: 0 | 1 | 5 | 6 + def exit_code({:ok, _}), do: 0 + def exit_code({:error, code, _}), do: code + + @doc """ + Verifies an export map (`receipts`, `checkpoints`, `registry`). `opts`: `schemes:` the + allowed scheme strings (all three by default); `require_coverage:` whether every query + receipt needs a checkpoint (true by default; the writer covers the tail on shutdown and + rehydrate, so an export taken mid-window may carry uncovered rows and the caller says so). + """ + @spec verify(map(), keyword()) :: outcome() + def verify(export, opts \\ []) + + def verify( + %{"receipts" => receipts, "checkpoints" => checkpoints, "registry" => registry}, + opts + ) do + schemes = + Keyword.get(opts, :schemes, Enum.map(Signer.algorithms(), &Signer.impl(&1).scheme())) + + require_coverage = Keyword.get(opts, :require_coverage, true) + + with :ok <- walk(receipts, registry, schemes), + :ok <- check_checkpoints(checkpoints, receipts, registry, schemes), + :ok <- check_coverage(receipts, checkpoints, require_coverage) do + {:ok, %{receipts: length(receipts), checkpoints: length(checkpoints)}} + end + end + + def verify(_, _), do: {:error, 1, :not_an_export} + + ## The chain + + defp walk(receipts, registry, schemes) do + receipts + |> Enum.sort_by(& &1["seq"]) + |> Enum.reduce_while({:ok, 0, nil}, fn r, {:ok, prev_seq, prev_hash} -> + case check_receipt(r, prev_seq, prev_hash, registry, schemes) do + :ok -> {:cont, {:ok, r["seq"], r["receipt_hash"]}} + {:error, _, _} = e -> {:halt, e} + end + end) + |> case do + {:ok, _, _} -> :ok + error -> error + end + end + + defp check_receipt(r, prev_seq, prev_hash, registry, schemes) 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, impl, row} <- resolve(r["scheme"], r["key_id"], registry, schemes, seq), + bytes = Envelope.pae(Envelope.receipt_type(r["scheme"]), r["signed_payload"]), + :ok <- expect(Envelope.hash(bytes) == r["receipt_hash"], 1, {:hash_mismatch, seq}), + :ok <- body_matches(r, seq) do + check_signature(r, bytes, impl, row, seq) + end + end + + defp check_signature(%{"kind" => kind} = r, bytes, impl, row, seq) when kind in @signed_kinds do + with {:ok, sig} <- decode_sig(r["signature_b64"], seq), + {:ok, pub} <- public_key(row, r["key_id"]) do + expect(impl.verify(bytes, sig, pub), 1, {:signature_invalid, seq}) + end + end + + defp check_signature(_r, _bytes, _impl, _row, _seq), do: :ok + + # The stored body must say what the row says: a row whose columns disagree with its + # signed body is a row edited after the fact. + defp body_matches(r, seq) do + case JSON.decode(r["signed_payload"]) do + {:ok, body} -> + expect( + body["seq"] == r["seq"] and body["prev_hash"] == r["prev_hash"] and + body["scheme"] == r["scheme"] and body["kind"] == r["kind"] and + body["key_id"] == r["key_id"] and body["chain_scope"] == r["chain_scope"], + 1, + {:body_column_mismatch, seq} + ) + + _ -> + {:error, 1, {:body_not_json, seq}} + end + end + + # The algorithm comes from the registry row and nowhere else. A scheme the caller did not + # allow, or a scheme naming another family than the key's row, is refused here, before + # the signature is looked at. + defp resolve(scheme, key_id, registry, schemes, seq) do + with :ok <- expect(scheme in schemes, 1, {:scheme_not_allowed, seq, scheme}), + {:ok, impl} <- + Signer.impl_for_scheme(scheme) |> or_error({:error, 1, {:unknown_scheme, seq, scheme}}), + %{} = row <- lookup(registry, 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"] == Atom.to_string(impl.algorithm()), + 1, + {:scheme_family_mismatch, seq, scheme, row["algorithm"]} + ) do + {:ok, impl, row} + end + end + + ## Checkpoints + + defp check_checkpoints(checkpoints, receipts, registry, schemes) do + by_seq = Map.new(receipts, &{&1["seq"], &1}) + + Enum.reduce_while(checkpoints, :ok, fn cp, :ok -> + case check_checkpoint(cp, by_seq, registry, schemes) do + :ok -> {:cont, :ok} + e -> {:halt, e} + end + end) + end + + defp check_checkpoint(cp, by_seq, registry, schemes) 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, impl, row} <- + resolve(cp["scheme"], cp["key_id"], registry, 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} <- decode_sig(cp["signature_b64"], {:checkpoint, last}), + {:ok, pub} <- public_key(row, cp["key_id"]) do + bytes = Envelope.pae(Envelope.checkpoint_type(cp["scheme"]), cp["signed_payload"]) + expect(impl.verify(bytes, sig, pub), 1, {:checkpoint_signature_invalid, last}) + end + end + + defp check_coverage(_receipts, _checkpoints, false), do: :ok + + defp check_coverage(receipts, checkpoints, true) do + covered = + Enum.flat_map(checkpoints, fn cp -> Enum.to_list(cp["first_seq"]..cp["last_seq"]//1) end) + |> MapSet.new() + + uncovered = + receipts + |> Enum.filter(&(&1["kind"] == "query" and not MapSet.member?(covered, &1["seq"]))) + |> Enum.map(& &1["seq"]) + + expect(uncovered == [], 1, {:query_receipts_uncovered, uncovered}) + end + + ## Helpers + + defp lookup(registry, key_id) when is_list(registry) and is_binary(key_id), + do: registry |> Enum.filter(&(&1["key_id"] == key_id)) |> List.last() + + defp lookup(_, _), do: nil + + defp public_key(%{"public_key_b64" => b64}, key_id) do + Base.decode64(b64) |> or_error({:error, 5, {:key_undecodable, key_id}}) + end + + defp public_key(_, key_id), do: {:error, 5, {:key_without_public, key_id}} + + defp decode_sig(nil, seq), do: {:error, 1, {:signature_missing, seq}} + + defp decode_sig(b64, seq), + do: Base.decode64(b64) |> or_error({:error, 1, {:signature_undecodable, seq}}) + + defp expect(true, _code, _reason), do: :ok + defp expect(false, code, reason), do: {:error, code, reason} + + defp or_error({:ok, v}, _), do: {:ok, v} + defp or_error(:error, e), do: e + defp or_error({:error, _}, e), do: e +end diff --git a/lib/trinity/repo/receipts.ex b/lib/trinity/repo/receipts.ex index 84c3fd2..ee2b96b 100644 --- a/lib/trinity/repo/receipts.ex +++ b/lib/trinity/repo/receipts.ex @@ -2,14 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.Repo.Receipts do @moduledoc """ - The slot for a second database file, reserved at slice 010 and unused until slice 024. + The receipts chain's own database: reserved at slice 010, configured and started at slice + 024 (docs/adr/0013). - Declared so that the receipts chain can live in its own SQLite file with its own - `synchronous` setting (`:full`, if an auditor wants the last committed receipt durable - across power loss) without moving the primary database later. Not started by the - application, not in `:ecto_repos`, and no migration targets it at this slice. Slice 024 - configures and starts it; until then any call here fails because the repo is not running, - which is the intended state. + Its own SQLite file (`receipts.db` beside the primary; `Trinity.Paths.receipts_database_path/0`) + with `synchronous: :full`, so the last committed receipt survives power loss, without + slowing the primary; its own migrations under `priv/repo_receipts`; on Postgres the same + database as the primary with its own `receipts_schema_migrations` table. `Trinity.Receipts` + is the only context that reads and writes here, and `Trinity.Receipts.ChainWriter` the only + process that inserts (the census test). """ use Ecto.Repo, diff --git a/lib/trinity/sessions/session.ex b/lib/trinity/sessions/session.ex index a9e36ea..c92ffd3 100644 --- a/lib/trinity/sessions/session.ex +++ b/lib/trinity/sessions/session.ex @@ -239,6 +239,15 @@ defmodule Trinity.Sessions.Session do end end + # A decision that arrives while the tools are still running: the gate broadcasts the request + # from inside the runner, before the runner returns, so the owner can decide before this + # process has entered approval_wait. Postponed, gen_statem redelivers it on the next state + # change, where the clause above takes it. Found at slice 024 (the decision receipt widened + # the window from microseconds to a fsync) and seen once by chance at slice 003's close + # (its NOTES finding 11): a fix to slice 021's design, not to this slice's. + def handle_event(:info, {:approval, :decided, _}, :tool_wait, _data), + do: {:keep_state_and_data, [:postpone]} + def handle_event(:info, {:approval, _, _}, _state, _data), do: :keep_state_and_data # Slice 023: the compaction row is written here, in the Session (a row, then a broadcast), diff --git a/lib/trinity/sessions/tool_runner.ex b/lib/trinity/sessions/tool_runner.ex index e926b68..725868c 100644 --- a/lib/trinity/sessions/tool_runner.ex +++ b/lib/trinity/sessions/tool_runner.ex @@ -3,8 +3,9 @@ defmodule Trinity.Sessions.ToolRunner do @moduledoc """ The seam through which a Session runs a tool call. Slice 012 shipped the stub; slice 020's - `Trinity.Tools.Runner` is the implementation in force (config); slice 024 routes effectful - calls through the membrane. The Session depends on this behaviour and never on the runtime. + `Trinity.Tools.Runner` was the implementation in force; slice 024's `Trinity.Effects.Runner` + is, routing effectful calls through the membrane and receipting every decision. The + Session depends on this behaviour and never on the runtime. """ @type call :: %{id: String.t(), name: String.t(), args: map()} @@ -21,9 +22,9 @@ defmodule Trinity.Sessions.ToolRunner do """ @callback run_all([call()], context :: map()) :: [{call(), result()}] - @doc "The implementation in force, from config; `Trinity.Tools.Runner` by default (slice 020)." + @doc "The implementation in force, from config; `Trinity.Effects.Runner` by default (slice 024)." @spec impl() :: module() - def impl, do: Application.get_env(:trinity, :tool_runner, Trinity.Tools.Runner) + def impl, do: Application.get_env(:trinity, :tool_runner, Trinity.Effects.Runner) @doc "Runs one call through the implementation in force." @spec run(call(), map()) :: result() diff --git a/lib/trinity/tools.ex b/lib/trinity/tools.ex index cd2ec15..d4b56de 100644 --- a/lib/trinity/tools.ex +++ b/lib/trinity/tools.ex @@ -12,7 +12,7 @@ defmodule Trinity.Tools do """ use Boundary, deps: [Trinity, Trinity.Permissions], - exports: [Tool, Context, Result, Registry, Runner, Schema] + exports: [Tool, Context, Result, Registry, Runner, Schema, Catalog] alias Trinity.Sessions.Message alias Trinity.Tools.Registry diff --git a/lib/trinity/effects/catalog.ex b/lib/trinity/tools/catalog.ex similarity index 72% rename from lib/trinity/effects/catalog.ex rename to lib/trinity/tools/catalog.ex index be0edfa..1c30413 100644 --- a/lib/trinity/effects/catalog.ex +++ b/lib/trinity/tools/catalog.ex @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 -defmodule Trinity.Effects.Catalog do +defmodule Trinity.Tools.Catalog do @moduledoc """ The effect catalog, resolved at compile time (docs/07, M4). Slice 020 opened it empty; slice 022 lists the shell, an external effect under local authority (its alignment note). @@ -11,6 +11,11 @@ defmodule Trinity.Effects.Catalog do list and refuses a runtime registration claiming `:catalog` outright; the census test (slice 020 AC8) walks the tree and asserts no other path admits one. Slice 024 makes the membrane read it. + + Named `Trinity.Effects.Catalog` by SLICE.md 020 and 024 and moved here at 024: the tool + registry reads it and `Trinity.Effects` depends on `Trinity.Tools`, so the boundary + compiler refuses the catalog inside Effects; it lives with the tools whose effect classes + it lists, and the membrane reads it from here (024 NOTES.md, deviation e). """ @catalog [{"shell", :exec}] diff --git a/lib/trinity/tools/context.ex b/lib/trinity/tools/context.ex index a4a9026..167c98f 100644 --- a/lib/trinity/tools/context.ex +++ b/lib/trinity/tools/context.ex @@ -5,14 +5,17 @@ defmodule Trinity.Tools.Context do What a tool call knows about where it runs. Slice 020. `caller` names the process that asked (the Session's id at this slice; a subagent's or a gateway's later); `cwd` is the working directory 022's filesystem and shell tools resolve paths against; `persona` is the row. + `call_id` (slice 024) is the model's id for this call, set by the runner per call; with + the session it is the effect's idempotency key. """ @type t :: %__MODULE__{ session_id: String.t() | nil, cwd: String.t() | nil, persona: struct() | map() | nil, - caller: term() + caller: term(), + call_id: String.t() | nil } - defstruct session_id: nil, cwd: nil, persona: nil, caller: nil + defstruct session_id: nil, cwd: nil, persona: nil, caller: nil, call_id: nil end diff --git a/lib/trinity/tools/registry.ex b/lib/trinity/tools/registry.ex index c2ff93e..bf7713a 100644 --- a/lib/trinity/tools/registry.ex +++ b/lib/trinity/tools/registry.ex @@ -10,7 +10,7 @@ defmodule Trinity.Tools.Registry do (`mcp::`, `skill:`) so the permission tier, a function of the name alone, can never be borrowed from a core tool; and their `effect/0` may be `:none` or `:artifact` only, because the `:catalog` set is the compile-time attribute in - `Trinity.Effects.Catalog` and nothing at runtime may enter it (docs/07). + `Trinity.Tools.Catalog` and nothing at runtime may enter it (docs/07). Every entry carries the tool's definition digest (SHA-256 over name, description and schema), which each tool call record and each turn's declared surface cite. @@ -19,7 +19,7 @@ defmodule Trinity.Tools.Registry do require Logger - alias Trinity.Effects.Catalog + alias Trinity.Tools.Catalog alias Trinity.Tools.{Schema, Tool} @table __MODULE__ diff --git a/lib/trinity/tools/runner.ex b/lib/trinity/tools/runner.ex index cb6b054..5129c97 100644 --- a/lib/trinity/tools/runner.ex +++ b/lib/trinity/tools/runner.ex @@ -7,16 +7,22 @@ defmodule Trinity.Tools.Runner do All the turn's calls run at once, each in its own task under `Trinity.Tools.TaskSupervisor` with its tool's timeout; the Session waits for the set. One call: look the name up, validate - the arguments against the schema (refused, never repaired), ask `Trinity.Permissions.decide/3` - exactly once, run `execute/2`, cap the result. A crash is an error result, a timeout an error + the arguments against the schema (refused, never repaired), hand the entry and the + arguments to the executor, cap the result. A crash is an error result, a timeout an error result, an unknown name an error result: the model reads each, and the session goes on. Nothing here writes a row; the Session records what comes back, with the tool's definition digest beside it. - The contract is `Trinity.Sessions.ToolRunner`'s (`run/2`, `run_all/2`), which the Session - calls and which names this module as its default implementation. It is not declared with - `@behaviour` here: Sessions depends on Tools (the declared surface), so a reference the - other way would be a cycle `boundary` refuses; `Trinity.Tools.RunnerTest` asserts the two + Slice 024: the executor is a function argument (`run_all/3`), because the membrane lives + in `Trinity.Effects`, which depends on this boundary, and a reference the other way would + be a cycle `boundary` refuses. `Trinity.Effects.Runner` is the seam's implementation in + force and passes its executor in; the default executor here, `execute_direct/3`, decides + through the gate and runs `execute/2` for `effect: :none` tools only, refusing an + effectful tool by name (the census in test/trinity/effects/census_test.exs holds that this + guard and `Trinity.Authority.Local` are the only two callers of `execute/2`). + + The contract is `Trinity.Sessions.ToolRunner`'s (`run/2`, `run_all/2`). It is not declared + with `@behaviour` here for the reason above; `Trinity.Tools.RunnerTest` asserts the two functions exist with the seam's arities instead. """ @@ -32,21 +38,29 @@ defmodule Trinity.Tools.Runner do Application.get_env(:trinity, :tools, []) |> Keyword.get(:timeout_ms, @default_timeout) end - @doc "One call (the seam's `run/2`)." + @type executor :: (Registry.entry(), map(), Context.t() -> {:ok, Result.t()} | {:error, term()}) + + @doc "One call (the seam's `run/2`), with the default executor." @spec run(map(), map()) :: {:ok, Result.t(), map()} | {:error, term(), map()} def run(call, context) do [{_call, outcome}] = run_all([call], context) outcome end - @doc "Every call of a turn, at once, answered in the order given (the seam's `run_all/2`)." + @doc "Every call of a turn, at once, answered in the order given (the seam's `run_all/2`), with the default executor." @spec run_all([map()], map()) :: [{map(), {:ok, Result.t(), map()} | {:error, term(), map()}}] - def run_all(calls, context) when is_list(calls) do + def run_all(calls, context) when is_list(calls), do: run_all(calls, context, &execute_direct/3) + + @doc "The same, with the executor that runs a validated call (slice 024: the membrane's runner passes its own)." + @spec run_all([map()], map(), executor()) :: [ + {map(), {:ok, Result.t(), map()} | {:error, term(), map()}} + ] + def run_all(calls, context, executor) when is_list(calls) and is_function(executor, 3) do ctx = to_context(context) longest = calls |> Enum.map(&timeout_of/1) |> Enum.max(fn -> default_timeout() end) Trinity.Tools.TaskSupervisor - |> Task.Supervisor.async_stream_nolink(calls, &{&1, run_one(&1, ctx)}, + |> Task.Supervisor.async_stream_nolink(calls, &{&1, run_one(&1, ctx, executor)}, max_concurrency: max(length(calls), 1), timeout: longest + @grace, on_timeout: :kill_task, @@ -61,8 +75,8 @@ defmodule Trinity.Tools.Runner do # One call, with its own timeout inside the task so a slow tool is a timeout error for that # call rather than a killed task for the set. - defp run_one(call, ctx) do - task = Task.async(fn -> execute(call, ctx) end) + defp run_one(call, ctx, executor) do + task = Task.async(fn -> execute(call, ctx, executor) end) case Task.yield(task, timeout_of(call)) || Task.shutdown(task, :brutal_kill) do {:ok, outcome} -> outcome @@ -71,35 +85,61 @@ defmodule Trinity.Tools.Runner do end end - defp execute(%{name: name, args: args}, ctx) do + defp execute(%{name: name, args: args} = call, ctx, executor) do + ctx = %{ctx | call_id: Map.get(call, :id)} + with {:ok, entry} <- Registry.lookup(name), {:ok, args} <- validate(entry, args), - :allow <- - Permissions.decide(ctx.session_id, name, args, - persona: ctx.persona, - cwd: ctx.cwd, - escalate: escalation(entry, args, ctx) - ), - {:ok, %Result{} = result} <- call_tool(entry, args, ctx) do + {:ok, %Result{} = result} <- executor.(entry, args, ctx) do {:ok, Result.cap(result), meta(name)} else {:error, reason} -> {:error, reason, meta(name)} - :deny -> {:error, :denied, meta(name)} - :ask -> {:error, ask(ctx, name, args), meta(name)} other -> {:error, {:bad_return, other}, meta(name)} end end + @doc """ + The default executor: the gate's decision, then `execute/2` for an `effect: :none` tool. + An effectful tool is refused here by name; only the membrane runs those. + """ + @spec execute_direct(Registry.entry(), map(), Context.t()) :: + {:ok, Result.t()} | {:error, term()} + def execute_direct(entry, args, ctx) do + case decide(entry, args, ctx) do + {:allow, _fp} -> call_tool(entry, args, ctx) + {:deny, _fp} -> {:error, :denied} + {:ask, reason, _fp} -> {:error, reason} + end + end + + @doc """ + The gate's decision for a validated call, asked exactly once, with the fingerprint the + decision bound: `{:allow, fp}`, `{:deny, fp}`, or `{:ask, reason, fp}` where the reason is + `{:approval_required, id}` (a pending approval the Session waits on), `:approval_required` + (no session to ask) or `{:request_failed, why}`. + """ + @spec decide(Registry.entry(), map(), Context.t()) :: + {:allow, String.t()} | {:deny, String.t()} | {:ask, term(), String.t()} + def decide(%{name: name} = entry, args, ctx) do + fp = Permissions.fingerprint(ctx.session_id, name, args, ctx.cwd) + + case Permissions.decide(ctx.session_id, name, args, + persona: ctx.persona, + cwd: ctx.cwd, + escalate: escalation(entry, args, ctx) + ) do + :allow -> {:allow, fp} + :deny -> {:deny, fp} + :ask -> {:ask, ask(ctx, entry, args), fp} + end + end + # An :ask with a session to ask becomes a pending approval (a row, then a broadcast) the # Session waits on; without a session there is nobody to ask, and the call is refused. - defp ask(%Context{session_id: nil}, _name, _args), do: :approval_required + defp ask(%Context{session_id: nil}, _entry, _args), do: :approval_required - defp ask(%Context{session_id: sid, cwd: cwd} = ctx, name, args) do - risk = - case Registry.lookup(name) do - {:ok, entry} -> Permissions.effective_tier(name, escalation(entry, args, ctx)) - _ -> :ask - end + defp ask(%Context{session_id: sid, cwd: cwd} = ctx, %{name: name} = entry, args) do + risk = Permissions.effective_tier(name, escalation(entry, args, ctx)) case Permissions.request_approval(sid, name, args, cwd: cwd, risk: risk) do {:ok, approval} -> {:approval_required, approval.id} @@ -107,8 +147,9 @@ defmodule Trinity.Tools.Runner do end end - # The tool's own reading of its arguments (slice 022): a tier it raises the call to, or nil. - defp escalation(%{module: module}, args, ctx) do + @doc "The tool's own reading of its arguments (slice 022): a tier it raises the call to, or nil." + @spec escalation(Registry.entry(), map(), Context.t()) :: Permissions.tier() | nil + def escalation(%{module: module}, args, ctx) do if function_exported?(module, :escalate, 2), do: module.escalate(args, ctx), else: nil end @@ -119,12 +160,21 @@ defmodule Trinity.Tools.Runner do end end - defp call_tool(%{module: module}, args, ctx) do + @doc """ + Runs a validated, allowed `effect: :none` call directly. An effectful entry is refused by + name: this is one of the two callers of `execute/2` the census allows, and the guard is + what keeps it a caller for reads only. + """ + @spec call_tool(Registry.entry(), map(), Context.t()) :: {:ok, Result.t()} | {:error, term()} + def call_tool(%{effect: :none, module: module}, args, ctx) do module.execute(args, ctx) rescue e -> {:error, {:crash, {e, __STACKTRACE__}}} end + def call_tool(%{name: name}, _args, _ctx), + do: {:error, {:effectful_tool_outside_membrane, name}} + defp timeout_of(%{name: name}) do with {:ok, %{module: m}} <- Registry.lookup(name), true <- function_exported?(m, :timeout, 0) do diff --git a/lib/trinity/tools/tool.ex b/lib/trinity/tools/tool.ex index 8de5744..b3c6005 100644 --- a/lib/trinity/tools/tool.ex +++ b/lib/trinity/tools/tool.ex @@ -12,7 +12,7 @@ defmodule Trinity.Tools.Tool do `effect/0` is part of the behaviour (docs/07): `:none` is a read, `:artifact` a local write, `:catalog` an external effect. A `:catalog` tool exists only in - `Trinity.Effects.Catalog`'s module attribute; a runtime registration claiming it is refused. + `Trinity.Tools.Catalog`'s module attribute; a runtime registration claiming it is refused. """ alias Trinity.Tools.{Context, Result} diff --git a/lib/trinity_web/live/receipts_live.ex b/lib/trinity_web/live/receipts_live.ex new file mode 100644 index 0000000..cc56ffb --- /dev/null +++ b/lib/trinity_web/live/receipts_live.ex @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.ReceiptsLive do + @moduledoc """ + `/s/:id/receipts` and `/receipts/boot` (slice 024): one chain scope's receipts in seq order + with their kind, subject, decision, hash prefix and whether they are signed or + checkpointed; the checkpoints; and a verify button that runs `Trinity.Receipts.Verifier` + over the scope's export and shows the outcome with its exit code. The boot page is the boot + receipt of this run at the top of the boot chain (SLICE.md says Settings; there is no + Settings page in this tree yet, so the boot receipt has its own route). The page reads and + verifies; it writes nothing, because nothing on a page may. + """ + use TrinityWeb, :live_view + + alias Trinity.Receipts + alias Trinity.Receipts.Verifier + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign( + scope: Receipts.session_scope(id), + session_id: id, + boot?: false, + page_title: gettext("Receipts") + ) + |> load()} + end + + def mount(_params, _session, socket) do + {:ok, + socket + |> assign( + scope: Receipts.boot_scope(), + session_id: nil, + boot?: true, + page_title: gettext("Boot receipt") + ) + |> load()} + end + + defp load(socket) do + scope = socket.assigns.scope + + assign(socket, + receipts: Receipts.list(scope) |> Enum.reverse(), + checkpoints: Receipts.checkpoints(scope), + boot: if(socket.assigns.boot?, do: Receipts.boot_receipt()), + verified: nil + ) + end + + @impl true + def handle_event("verify", _params, socket) do + outcome = + case Receipts.export(socket.assigns.scope) do + {:ok, export} -> Verifier.verify(export, require_coverage: false) + {:error, reason} -> {:error, 2, reason} + end + + {:noreply, assign(socket, verified: outcome)} + end + + def handle_event("refresh", _params, socket), do: {:noreply, load(socket)} + + @impl true + def render(assigns) do + ~H""" + + <:bar> + {if @boot?, do: gettext("Boot receipt"), else: gettext("Receipts")} + {@scope} + <.link :if={@session_id} navigate={~p"/s/#{@session_id}"} class="text-meta underline"> + {gettext("back to the session")} + + +
+
+

{gettext("This run")}

+

+ {gettext("No boot receipt was written this run: no signer was available at boot.")} +

+
+
{gettext("authority")}
+
{@boot.subject["authority"]}
+
{gettext("signer")}
+
+ {@boot.subject["signer"]["algorithm"]} · {@boot.subject["signer"]["scheme"]} · {@boot.subject[ + "signer" + ]["key_id"]} +
+
{gettext("fips")}
+
{@boot.subject["fips"]}
+
{gettext("otp")}
+
{@boot.subject["otp_release"]}
+
{gettext("core policy hash")}
+
{@boot.meta["core_policy_hash"]}
+
{gettext("receipt hash")}
+
{@boot.receipt_hash}
+
{gettext("at")}
+
{stamp(@boot.inserted_at)}
+
+
+ +
+
+

{gettext("Chain")}

+ {length(@receipts)} {gettext("receipts")}, {length( + @checkpoints + )} {gettext("checkpoints")} + + + <.outcome :if={@verified} outcome={@verified} /> +
+

+ {gettext("Nothing receipted in this scope yet.")} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#{gettext("Kind")}{gettext("Subject")}{gettext("Decision")}{gettext("Signed")}{gettext("Hash")}
{r.seq}<.kind_badge kind={r.kind} /> +
{subject(r)}
+
+
{decision(r)}
+
+ {if r.signature, do: gettext("signed"), else: gettext("checkpointed")} + + {String.slice(r.receipt_hash, 0, 16)} +
+
+ +
+

{gettext("Checkpoints")}

+ + + + + + + + + + + + + + + + + +
{gettext("Covers")}{gettext("Reason")}{gettext("Tail")}{gettext("At")}
{c.first_seq} to {c.last_seq}{c.reason} + {String.slice(c.tail_hash, 0, 16)} + {stamp(c.inserted_at)}
+
+
+
+ """ + end + + attr :kind, :string, required: true + + defp kind_badge(assigns) do + ~H""" + + {@kind} + + """ + end + + attr :outcome, :any, required: true + + defp outcome(assigns) do + ~H""" + + {describe(@outcome)} + + """ + end + + defp describe({:ok, %{receipts: n, checkpoints: c}}), + do: gettext("verified: %{n} receipts, %{c} checkpoints, exit 0", n: n, c: c) + + defp describe({:error, code, reason}), + do: gettext("exit %{code}: %{reason}", code: code, reason: inspect(reason)) + + defp subject(%{subject: s}) do + [s["tool"], s["call_id"] && "call " <> s["call_id"], s["phase"], s["authority"]] + |> Enum.reject(&is_nil/1) + |> Enum.join(" · ") + end + + defp decision(%{signed_payload: p}) do + case JSON.decode(p) do + {:ok, %{"decision" => d}} when is_map(d) -> + d |> Enum.map_join(" ", fn {k, v} -> "#{k}=#{value(v)}" end) + + _ -> + "" + end + end + + defp value(v) when is_binary(v) or is_number(v) or is_boolean(v) or is_nil(v), do: to_string(v) + defp value(v), do: Jason.encode!(v) + + defp stamp(nil), do: "" + defp stamp(%DateTime{} = at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M:%S") <> " UTC" +end diff --git a/lib/trinity_web/live/session_live/show.ex b/lib/trinity_web/live/session_live/show.ex index a96ea41..9cdcdca 100644 --- a/lib/trinity_web/live/session_live/show.ex +++ b/lib/trinity_web/live/session_live/show.ex @@ -340,6 +340,14 @@ defmodule TrinityWeb.SessionLive.Show do <.model_picker models={@models} value={@session.model} default={@default_model} /> <.context_indicator used={@context_used} window={@context_window} /> <.pending_indicator count={@pending_count} /> + <.link + id="receipts-link" + navigate={~p"/s/#{@session.id}/receipts"} + class="text-meta opacity-70 hover:opacity-100" + title={gettext("This session's receipts")} + > + {gettext("receipts")} +
/keys/receipts.key` (0600) and appends the registry row to `/keys/registry.json`; reads + the key file on every sign, never caches it (AC5's premise). Tests: selection by a mocked `info_fips`; + registry append-only; a key file removed → `{:error, :signer_unavailable}`. +3. `Trinity.Receipts.ChainWriter` (ADR-0013): one per `chain_scope` under `Trinity.Receipts.Supervisor`, `:unique` + in `Trinity.Registry`; `append/2` is a call; rehydrates the tail on start; signs per row for decision, effect, + boot and cap kinds; chains query rows unsigned and checkpoints them at N/T/shutdown, re-signing the tail on + rehydrate before a new row. A census test: `ChainWriter` is the only module inserting into `receipts` (a + planted second insert path in test support must be named). Tests: 1,000 mixed receipts verify; a fork is + impossible (two concurrent appenders, no two rows share a `prev_hash`); AC9's three checkpoint cases. +4. `Trinity.Receipts.Verifier` (pure: walks a scope by seq, recomputes hashes, resolves the algorithm from the + registry row and never from the body, refuses a foreign scheme string before any signature check) with exit + vocabulary `0 / 1 / 2 / 5 / 6`; `mix trinity.receipts.verify` and `mix trinity.receipts.export`; + `bin/verify_receipt.exs` standalone on `elixir` alone (Elixir's own `JSON`, `:crypto`), run from an empty + directory in a test (AC7). Mutants as tests: drop the registry lookup, drop the scheme check (AC8). +5. `Trinity.Authority` behaviour (`stage/2`, `decide/3`, `execute/3`, `receipt/2`), `Trinity.Authority.Local`, + selection at boot from `TRINITY_AUTHORITY` in `Trinity.Authority.Selection` (refuses to start naming the + failed condition: not loaded, or missing callback by name); the standalone assertion under `local`: no adapter + module loaded, no outbound socket (AC2). `Local.execute/3` is the one caller of `execute/2` for effectful tools. +6. `Trinity.Effects` (the membrane, a module): `execute/1` over a `%Staged{}` (entry, args, ctx, decision, + fingerprint, call id); revalidates the decision, re-derives the fingerprint from the args it holds (AC4), + refuses a repeated `(session, call_id)` by asking the chain for an effect receipt with that subject (the + idempotency key, no new state), checks the tool's effect is in `Effects.Catalog` for `:catalog`, checks the + authority in force, and denies with a receipt on any mismatch; signing unavailable denies, sets + `:alarm_handler` alarm `{:trinity_receipts_signer, reason}` and emits `[:trinity, :receipts, :signer_unavailable]` + (AC5). `Trinity.Effects.Runner` becomes the `:tool_runner` implementation: `Tools.Runner` keeps lookup, + validation, timeouts and concurrency and takes the executor as a function, so Tools never depends on Effects; + the executor decides, writes the decision receipt, and for `:none` calls the tool directly with a query + receipt, otherwise goes through the membrane. Census (AC1): the population is `git ls-files 'lib/**/*.ex' + 'test/support/**/*.ex'` grepped for `.execute(`; the allowed set is `Authority.Local` and the `:none` path in + `Effects.Runner`; a planted bypass in test support is named. +7. `Trinity.CorePolicy` extended with the gate, catalog, membrane and authority modules; the boot receipt + (scope `boot`, written by `Trinity.Receipts.Boot` after the supervisor starts) carries the selected authority, + algorithm and key id in the signed bytes and `core_policy_hash` in `meta`; test: changing a policy module's + object code changes the hash (AC6). +8. UI: `/sessions/:id/receipts` (the scope's rows, verify status) and `/receipts/boot` (the boot receipt; there is + no Settings page in this tree yet, so it gets its own route and a nav link beside `/permissions`); LiveView + tests. +9. Docs: 01 (the boundaries and the effect path as built), 05 (the two tables), 07 (the receipt section as built); + ADR-0013 consequence line for the checkpoints table. +10. AC8's FIPS half runs on the `fips` leg: the boot receipt names P-384 there, no effect is denied for want of a + signer; `test/fips/receipts_test.exs` tagged `:fips`. + +Manual verification queue: none; every criterion is `[auto]`. + +Deviations stated before any code: (a) the registry and key live in the data directory, not `priv/keys/`: `priv` +is the packaged tree, read-only under a release, and a key made on the user's machine is not the tree's to +carry; (b) query checkpoints are rows in `receipt_checkpoints` rather than a signature written onto the tail +row, because `receipts` is append-only and never updated (docs/05); "the tail carries a signature" is read as +"a checkpoint row names the tail"; (c) the boot receipt's page is `/receipts/boot`, not Settings (none exists); +(d) the `ToolRunner` refactor lands as an executor function on `Tools.Runner` with `Effects.Runner` as the seam +implementation, so the boundary table's arrow (Effects depends on Tools) holds without a cycle. + +## Research, 2026-09-20, and the G1 amendments it produces + +The owner asked for the design to be checked against what the field does, not against this tree's own plan. +Read this date, primary sources only; each amendment names the line of the G1 plan it changes. + +1. **DSSE (secure-systems-lab/dsse, protocol.md).** The signature is over `PAE(UTF8(PAYLOAD_TYPE), + SERIALIZED_BODY)` where PAE is `"DSSEv1" SP LEN(type) SP type SP LEN(body) SP body`, so the payload type is + inside the signed bytes: "two different applications could use the same encoding (e.g. JSON) but interpret the + payload differently". `KEYID` is "an unauthenticated hint" that "MUST NOT be used for security decisions". + **Amendment A (lines 2, 3, 4):** the signature is over `PAE("trinity/receipt/" <> scheme, canonical_body)`, the + DSSE construction with the scheme string as the payload type, and the checkpoint signature over + `PAE("trinity/checkpoint/" <> scheme, canonical_checkpoint)`. The reason is concrete here: SLICE.md amendment 1 + has one key-custody module signing both receipts and, at 061, the MCP core's envelopes with the same key, and + without domain separation a signature made for one could be presented as the other. `key_id` stays inside the + body as SLICE.md amendment 3 says, which is stronger than DSSE's hint; the verifier still treats it only as + the registry lookup key and takes the algorithm from the registry row. +2. **RFC 8725, JWT Best Current Practices, section 3.1.** "Libraries MUST enable the caller to specify a supported + set of algorithms and MUST NOT use any other algorithms", and "each key MUST be used with exactly one + algorithm, and this MUST be checked when the cryptographic operation is performed". **Amendment B (line 4):** + the verifier takes the allowed scheme set as an argument (the three schemes, or fewer), the registry row binds + one algorithm to one key id, and a receipt whose scheme names an algorithm other than its key's row is + refused before any signature check, which is SLICE.md amendments 3 and 4 with the RFC's wording as the test + names. +3. **RFC 7638, JWK Thumbprint.** A key identifier computed by anyone from the public key: the required JWK + members (`crv`, `kty`, `x`, `y` for EC; `crv`, `kty`, `x` for OKP per RFC 8037) serialised "with no whitespace + ... ordered lexicographically", hashed with SHA-256, base64url. **Amendment C (line 2):** `key_id` is the RFC + 7638 thumbprint of the public key, and the registry row carries the JWK, so a stranger recomputes the id from + the key instead of trusting a label. ML-DSA-87 has no JWK form registered at this date; its id is the SHA-256 + of the raw public key with the same base64url encoding, and the registry row says so by a `kid_scheme` field. +4. **RFC 5848, Signed Syslog Messages.** The existing standard for signing a stream of records in groups: a + Signature Block carries a Reboot Session ID (RSID, "expected to strictly monotonically increase"), a Global + Block Counter, the First Message Number, a Count of hashes, the hashes, and a signature over the block; blocks + are emitted by count or by `sigMaxDelay`. **Amendment D (line 3):** the `receipt_checkpoints` row carries + `chain_scope`, `boot_receipt_hash` (the RSID's role: which boot this checkpoint belongs to), `first_seq`, + `last_seq`, `tail_hash`, `key_id`, `signature`, `at`, so a checkpoint states its coverage and a gap between + checkpoints is visible as a gap, not as silence; N = 100 rows or T = 5 s stands (RFC 5848 caps a block at 99 + hashes because of syslog message size, a limit this table does not have). +5. **C2SP `tlog-checkpoint` and `signed-note`.** "Verifiers MUST ignore signatures from unknown keys ... If no + known key successfully verifies, clients MUST reject the note"; "A log MUST not sign any checkpoint which is + inconsistent with any checkpoint it previously signed". **Amendment E (lines 3, 4):** the verifier's exit 5 + (trust not established) is exactly the first rule and is stated with it; the ChainWriter, on rehydrate, + verifies the newest checkpoint against the tail before re-signing it, so it can never sign a checkpoint + inconsistent with one it signed before (a test: a tail row altered on disk stops the writer from starting, + with the reason). +6. **Crosby and Wallach, "Efficient Data Structures for Tamper-Evident Logging" (USENIX Security 2009), and RFC + 9162.** A flat hash chain proves inclusion in O(n) and a history tree or Merkle tree in O(log n), with + consistency proofs between two tree heads. **No amendment; the trade-off recorded:** Trinity's scopes are per + session and verified wholesale, offline, by an operator holding the rows, so the linear chain with RFC + 5848-style checkpoints is the right size; the day a third party needs inclusion proofs without the rows (026's + store-and-forward, or 061's MCP consumers), the checkpoint gains a Merkle root over the scope under a new + scheme string, which this design leaves room for and does not build. +7. **FIPS 186-5 (NIST, February 2023) approves EdDSA.** OpenSSL's maintainers (openssl/openssl #22105, 2023-09): + Ed25519 "could be changed to approved once the corresponding self tests are implemented", which later + providers carry; the 3.0.7 module the 003 image runs does not, and OTP's `pkey.c` refuses `eddsa` in the mode + on its own (measured on the leg). **No amendment; a note for the register:** the mode selection (P-384 when + `info_fips()` is `enabled`) is the right rule for this OTP and this provider, and it lives in one function; when + OTP lifts its refusal on a provider that approves Ed25519, that function changes and nothing else, because the + registry binds each key to its algorithm and old chains verify as written. + +What the research did not change: RFC 8785 canonicalisation (the tree already uses it, `jcs` 0.2.0, the RFC +8785 vector passes); one writer per scope (ADR-0013); signing every decision, effect, boot and cap receipt; +denial when no approved signer exists; the registry as the only source of the algorithm. + +## Findings, 2026-09-20, in the order they were met + +1. **The catalog cannot live in `Trinity.Effects`.** The tool registry reads it and Effects depends on Tools, so + the boundary compiler refuses `Trinity.Tools` referring to `Trinity.Effects.Catalog`; `classify_to` is for + mix tasks and protocols only. The module is `Trinity.Tools.Catalog` (deviation e); the 020 census test + follows it; every mention of the old name in docs names the move. +2. **The boundary compiler counts calls, not child-list atoms.** `Trinity.Application` may list + `Trinity.Authority.Selection` as a child but not call `boot!/0`; the selection became a transient child + (right after the data directory lock) that refuses the boot by raising. The boot receipt moved from Receipts + to `Trinity.Effects.Boot` because it reads CorePolicy, Permissions and Authority, which only Effects may. +3. **`--warnings-as-errors` on an incremental compile hid boundary violations** until the boundary test's + forced compile showed them; `mix compile --force --warnings-as-errors` is the check to run after adding a + boundary, recorded here so it is not relearned. +4. **`:alarm_handler` needs `:sasl`** in `extra_applications`; added. +5. **A decision made while the tools still run was dropped by the Session** (slice 021's design): the gate + broadcasts the request from inside the runner, before the runner returns, and the catch-all clause dropped a + decision that arrived in `tool_wait`. Seen once by chance at slice 003's close (its finding 11) and on every + run once the decision receipt widened the window from microseconds to a fsync. Fixed as `fix(s021)` at + `85c0cdb`: the decided event is postponed in `tool_wait` and redelivered on entering `approval_wait`. +6. **A killed chain writer was restarted by its supervisor before the test looked**, and a supervisor restart + loop on a chain that refuses to start would take the supervisor down: writers are `restart: :temporary`, + started again by the next append, which rehydrates. +7. **The insert census over `git ls-files` sees only tracked files**, which is the population it should see; + a new file is invisible to it until staged. Recorded because it looked like a broken census for a minute. +8. **The signer test's temp-dir boots replaced the suite's selection**; each restores it on exit. +9. **`Trinity.Effects.Runner.run_all/2` is the seam in force**, and the 020 test that asserted + `Trinity.Tools.Runner` was updated; the seam's doc names the change. +10. **The core policy hash disagreed with itself mid-suite.** `Dbgi` (debug info) stores the expanded AST, and a + large map literal in an Ecto query (the `%Ecto.Query{}` struct has more than 32 keys) is rendered there in a + key order that depends on the compiling VM's atom table; the boundary test's forced recompile in a second VM + produced a `Trinity.Receipts.ChainWriter` beam that differed only in that chunk, and the boot receipt's hash + stopped matching `CorePolicy.hash/0` when that test ran first. The hash is now over beams stripped with + `:beam_lib.strip/1` (no debug info, no docs): what the code does, not its metadata. Measured: the two + differing beams are byte-equal once stripped. +11. **Credo and sobelow at the gate**: seven readability and nesting findings, refactored; nine sobelow findings, + two `String.to_atom` fixed properly (`String.to_existing_atom`; an absent module is reported by its text and + no atom is made from the environment), seven file-traversal ones skipped inline with reasons (the paths are + the keys directory plus constants). +12. **The fips leg selected P-384 and three tests named Ed25519** (run 35542450360): the edit that made them + mode-aware had never landed (a script aborted on its first assertion and wrote nothing). The tests now derive + the chain's family and the foreign one from `KeyCustody.selected/0`; the AC8 half that needs a real foreign + signature runs where the foreign signer is available and asserts only the refusal where it is not (Ed25519 + cannot sign in the mode). +13. **The outbound-connection assertion found Hex's TLS connection on the runners** (run 35542784455): Mix's Hex + client holds a connection to hex.pm (Cloudflare addresses, port 443, `Port<0.13>`, opened before the + application, no Trinity ancestor) in the suite's VM there; this machine's warm registry cache never opens + one. The assertion is scoped to processes `:application.get_application/1` places in `:trinity`. +14. **Fail closed includes reads.** A decision that cannot be receipted refuses the call whether or not it has + an effect; with no signer, no tool runs. Stated in docs/07 as built. The alternative (reads proceed unreceipted) + is a silent gap in the chain and was not taken. + +## Deviations found during the build (beside a to d at G1) + +- (e) `Trinity.Effects.Catalog` is `Trinity.Tools.Catalog` (finding 1). +- (f) The boot receipt is written by `Trinity.Effects.Boot`, a child of the application, not by Receipts (finding 2). +- (g) One effect gives two effect receipts, `admit` before execution and `done` after, so AC5's "next effect + denied" is a fact before anything runs and the outcome is on the chain too; a denial is one `denied` receipt. +- (h) The chain scope for calls without a session is `session:none`. + +## Follow-ups +- Slice 100: move the key to the OS keychain; the registry gets a new row with `custody` `keychain`. +- Slice 090: the cost of receipts per call (one fsync per signed row under FULL) beside the model's cost. +- Slice 026: the store-and-forward mode for `receipt/2`; the checkpoint gains a Merkle root under a new scheme + string when a third party needs inclusion proofs without the rows (research amendment 6). +- Slice 061: the MCP core's signer seam wired to `KeyCustody.sign/2`, with its own DSSE payload type. +- Slice 002: `supported_groups` or `middlebox_comp_mode` for Trinity's clients (slice 003's finding 4), unchanged. +- The `receipts` page shows a scope wholesale; when 031's search lands, a receipt can open beside its tool row. +- ML-DSA-87 is compiled and measured (the 003 image, mode off) and has no test in the default suite: this + machine's OpenSSL lacks it. A test on the 003 image with the mode off is possible and is not written here. diff --git a/slices/024-effect-catalog-authority-modes-receipts/PROOF.md b/slices/024-effect-catalog-authority-modes-receipts/PROOF.md new file mode 100644 index 0000000..4ca1810 --- /dev/null +++ b/slices/024-effect-catalog-authority-modes-receipts/PROOF.md @@ -0,0 +1,230 @@ +# Proof for slice 024: Effect catalog, authority selection, local receipts + +Agent: Trinity · Coding Agent · Date: 2026-09-20 · Branch: slice/024-effects-authority-receipts · Final commit: (the commit carrying this file; named in the closing correction) + +## Summary +The membrane: every tool call is decided once by the gate and receipted before anything runs; a read runs +directly with a query receipt, everything else becomes a staged effect that `Trinity.Effects.execute/2` admits +or denies with a receipt (decision, effect class and catalog, the fingerprint re-derived, the idempotency key, +the authority in force), and `Trinity.Authority.Local` is the one caller of `execute/2` for effectful tools. +Receipts are per-scope hash chains in their own database, signed through a seam (Ed25519 by default, P-384 in +FIPS mode as the `fips` leg proves, ML-DSA-87 by configuration where the runtime has it) over DSSE's PAE with the +scheme as payload type, with RFC 7638 key ids, an append-only registry the verifier reads the algorithm from, +RFC 5848-shaped checkpoints over query receipts, a verifier with the exit vocabulary and a standalone copy of it +that runs from an empty directory. `TRINITY_AUTHORITY` is read once at boot and refuses by name. Fourteen +findings in NOTES.md; the one that reaches past this slice is the Session dropping a decision made while its +tools still ran (`fix(s021)`, finding 5). + +## Gate +``` +$ mix gate (this machine, OTP 28.5.0.5, Elixir 1.20.4, under a 32 GiB cgroup, tree f977b84) +1411 mods/funs, found no issues. +... SCAN COMPLETE ... (sobelow --exit --skip: no finding) +No retired or security advisory packages found +No vulnerabilities found. +Result: 317 passed, 17 excluded +trinity.coverage: 003 75.39% vs 023 75.39%: OK +plan_check: PASS +exit=0 +``` +`mix credo --strict --all`: 1411 mods/funs, found no issues. + +CI, run 35542904049 on the tree at f977b84: `gate` success (317 passed, 17 excluded), `postgres` success +(309 passed, 25 excluded), `fips-tag` success, `fips` success (322 passed, 12 excluded: the `:fips` tests +included, coverage 76.43 %). + +## Tests +``` +$ mix test --cover (tree f977b84) +Result: 317 passed, 17 excluded +| 76.55% | Total | +``` +`coverage.tsv` row: `024 76.55 f977b84 2026-09-20` (from 75.39 at 023 and 003). The new modules: +Effects 90.70 %, Effects.Runner 96.97 %, Authority.Local 90.00 %, Authority.Selection 95.65 %, Verifier +89.87 %, KeyCustody 84.91 %, ChainWriter 81.82 %, KeyRegistry 79.17 %, Signer.P384 77.78 %, Signer.MLDSA87 +20.00 % (no runtime here carries it; measured on the 003 image at G1), the two mix tasks 0 % (they run the +verifier and the export, which the tests cover directly). + +The slice's 51 tests in its own files (`mix test --trace test/trinity/receipts test/trinity/effects +test/trinity/authority test/trinity_web/live/receipts_live_test.exs`): +``` +* test the planted bypass is a real bypass: it runs the effectful tool, which is what the census exists to catch [L#51] + * test the planted bypass is a real bypass: it runs the effectful tool, which is what the census exists to catch (5.9ms) [L#51] + * test the callers of execute/2 on a tool module are the two allowed and the one planted (15.2ms) [L#20] + * test Tools.Runner.call_tool/3 runs an effect: :none entry and refuses an effectful one by name (2.7ms) [L#41] + * test the standalone assertion: this suite booted under local, no adapter module is loaded, and every TCP peer belongs to the database (7.6ms) [L#60] + * test a present module implementing every callback is selected (0.7ms) [L#32] + * test a present module missing a callback is refused naming the callback (0.6ms) [L#27] + * test nil, the empty string and local all select Local (0.00ms) [L#15] + + * test boot!/0 reads the environment and raises with the named condition on refusal; the selection is unchanged (3.5ms) [L#36] + * test an absent module is refused as not loaded, by name (0.09ms) [L#19] + * test AC7: a stranger's run from an empty directory: verified is 0; the outcomes carry their codes (1590.1ms) [L#60] + * test the script and the in-app verifier agree on every outcome (1607.4ms) [L#107] + + * test changing a policy module changes the hash; an unchanged one does not (14.4ms) [L#51] + * test the boot receipt of this run: scope boot, signed, the authority, the signer and the policy hash (7.3ms) [L#15] + * test the list covers the modules that decide (5.6ms) [L#38] + * test the boot page shows this run's boot receipt with the authority, the signer and the policy hash, and verifies (96.0ms) [L#65] + * test the chat links to its receipts (19.0ms) [L#60] + * test a session's chain: the rows in order, signed or checkpointed, and verify runs to exit 0 (11.7ms) [L#29] + * test an empty scope says so (2.0ms) [L#55] + * test selection a configured ML-DSA-87 is refused where the runtime lacks it, naming the algorithm (0.1ms) [L#66] + * test the seam the RFC 7638 thumbprint: lexicographic members, no whitespace, SHA-256, base64url (0.04ms) [L#51] + * test custody boot generates the key once (0600), appends its registry row, and a second boot reuses it (0.4ms) [L#78] + * test custody sign reads the key file at every call: removed mid-run, the next sign is unavailable (1.1ms) [L#99] + * test the seam ML-DSA-87 reports itself unavailable or available from the runtime, never from the build (0.08ms) [L#47] + * test the seam each implementation signs and verifies its own family over the PAE, and refuses altered bytes (4.2ms) [L#27] + * test custody a key file whose id is not in the registry refuses to boot, naming the id (0.6ms) [L#124] + * test selection outside FIPS mode the default is Ed25519; in FIPS mode (the fips leg) it is P-384 (0.05ms) [L#61] + * test the seam the scheme strings resolve to their implementation and carry the family (0.07ms) [L#40] + * test custody the registry is append-only: a status change is a new row and the newest wins (0.4ms) [L#108] + * test AC8: the algorithm comes from the registry, not the body; a foreign family is refused at the scheme string (3.0ms) [L#143] + * test AC3: a chain with a gap, a wrong prev_hash, or a forged signature is 1 (2.7ms) [L#122] + * test AC3: 1,000 mixed receipts verify with their checkpoints; one byte in any signed_payload is 1 (180.0ms) [L#73] + * test coverage: a query receipt no checkpoint covers is 1 unless the caller waives coverage (3.3ms) [L#271] + * test AC3: an unknown key id is 5 (trust not established); a compromised key is 6 (1.9ms) [L#99] + * test AC5: the signing key removed mid-run: the next effect is denied, the alarm sounds, no unsigned receipt row exists; a read is refused too, because its decision cannot be receipted (5.4ms) [L#168] + * test the idempotency key: a second execution of the same session and call id is denied with a receipt (2.3ms) [L#126] + * test Local: stage stamps, decide follows the gate, execute runs the tool or refuses a denial, receipt appends (0.5ms) [L#233] + * test AC4: arguments mutated after the decision are denied at execution with a receipt (M2 re-verify) (1.2ms) [L#95] + * test AC3: every gate decision and every effect yields a receipt: a read gives decision and query; an effect gives decision, admit and done (2.0ms) [L#41] + * test a :catalog tool absent from the catalog, or a decision other than allow, is denied before anything runs (1.0ms) [L#140] + * test a denied call yields a decision receipt and nothing else; an asked call the same (1.0ms) [L#78] + * test AC9: query checkpoints a query row after the last checkpoint and before shutdown is covered by the shutdown checkpoint (0.7ms) [L#181] + * test AC9: query checkpoints after N query receipts a checkpoint names the tail and its coverage; its signature verifies (1.0ms) [L#136] + * test AC9: query checkpoints on rehydrate, uncovered query rows are checkpointed before a new row is accepted (12.5ms) [L#193] + * test refusals on start a tail altered on disk stops the writer with the reason (4.0ms) [L#222] + * test the scheme string resolves for every row and the implementations agree with the registry (0.1ms) [L#295] + * test AC5 at the writer: the key removed mid-run, the next signed receipt is refused, the alarm sounds, no row is written (1.0ms) [L#97] + * test rows chain: gapless seq, each prev_hash the previous receipt_hash, signed kinds verify, query rows unsigned (1.8ms) [L#43] + * test the census: ChainWriter is the only inserter into receipts; the planted bypass is named (6.1ms) [L#262] + * test AC9: query checkpoints T milliseconds after the first uncovered query row, a checkpoint is written by time (151.8ms) [L#169] + * test concurrent appenders to one scope produce one chain: no two rows share a prev_hash (3.6ms) [L#81] + * test refusals on start a checkpoint whose tail is not in the chain, or whose signature fails, stops the writer (1.2ms) [L#237] +Result: 51 passed +``` +Plus `test/fips/receipts_test.exs` (2, on the leg), the mode-aware assertions in `test/trinity/tools/units_test.exs`, +`test/trinity/sessions/units_test.exs`, `test/trinity/repo_config_test.exs` and `test/trinity/tools/catalog_census_test.exs`. + +## Acceptance criteria evidence + +### AC1 [auto]: census test: exactly one caller of `execute/2` for effectful tools; a planted bypass is flagged (both outputs) +`the callers of execute/2 on a tool module are the two allowed and the one planted` (test/trinity/effects/census_test.exs): +the population is `git ls-files 'lib/*.ex' 'test/support/*.ex'` grepped for a call of `execute(` on a module +value (receivers `authority`, `impl`, `executor` and Erlang atoms excluded by name); the result is exactly +`lib/trinity/authority/local.ex`, `lib/trinity/tools/runner.ex` and the planted +`test/support/effects/bypass.ex`. The second caller is a caller for reads only: `Tools.Runner.call_tool/3 runs an +effect: :none entry and refuses an effectful one by name` asserts `{:error, {:effectful_tool_outside_membrane, +"write_note"}`. The third test proves the plant is a real bypass (it runs the effectful tool), which is what +the census exists to catch. With `test/support/effects/bypass.ex` absent the census fails on its own assertion, +so it cannot pass by not looking. + +### AC2 [auto]: `TRINITY_AUTHORITY` set to a module that is absent, or present but not implementing the behaviour, refuses to start and names which condition failed; `local` starts; the standalone assertion passes +test/trinity/authority/selection_test.exs: `an absent module is refused as not loaded, by name` +(`{:error, {:not_loaded, "Trinity.NoSuchAuthority"}`, no atom made from the text); `a present module missing a +callback is refused naming the callback` (`{:missing_callback, Trinity.TestAuthority.Partial, {:execute, 3}`); +`boot!/0 reads the environment and raises with the named condition on refusal; the selection is unchanged` +(the message `TRINITY_AUTHORITY refused: module Trinity.TestAuthority.Partial does not implement execute/3`, +and the child spec's start exits with it); `nil, the empty string and local all select Local`; `the standalone +assertion: this suite booted under local, no adapter module is loaded, and every TCP peer belongs to the +database` (no loaded module implements the behaviour but `Local` and the test adapters; every TCP peer owned by +a `:trinity` process is a `DBConnection.Connection`; NOTES finding 13 for what the runners' Hex client is). + +### AC3 [auto]: every gate decision and every effect yields a receipt; chain verifies across 1,000 mixed receipts; tampering one byte in any `signed_payload` → verifier exit 1; unknown key id → 5; registry status `compromised` → 6 +test/trinity/effects/membrane_test.exs `AC3: every gate decision and every effect yields a receipt: a read gives +decision and query; an effect gives decision, admit and done` and `a denied call yields a decision receipt and +nothing else; an asked call the same`. test/trinity/receipts/verifier_test.exs `AC3: 1,000 mixed receipts verify +with their checkpoints; one byte in any signed_payload is 1` (bytes flipped at seq 1, 7, 500 and 1,000, each +`{:error, 1, {:hash_mismatch, seq}` or a body mismatch, `exit_code/1` 1); `AC3: an unknown key id is 5 (trust +not established); a compromised key is 6`; `AC3: a chain with a gap, a wrong prev_hash, or a forged signature is 1`. +`mix trinity.receipts.verify --scope ` and `--file ` exit with the same codes. + +### AC4 [auto]: fingerprint mismatch at execution (args mutated after approval) → denied + receipt (M2 re-verify) +`AC4: arguments mutated after the decision are denied at execution with a receipt (M2 re-verify)`: a staged effect +carrying the decision's fingerprint over one set of arguments and different arguments is denied +`{:fingerprint_mismatch, bound, derived}` with a `denied` effect receipt whose reason names it; the same staged +effect with the arguments the decision bound runs. + +### AC5 [auto]: signing key removed mid-run → next effect denied, alarm event emitted, no unsigned receipt row exists +`AC5: the signing key removed mid-run: the next effect is denied, the alarm sounds, no unsigned receipt row exists; +a read is refused too, because its decision cannot be receipted` (membrane_test.exs): after one effect, the key +file is removed; the next effect's decision cannot be receipted (`{:decision_not_receipted, {:signer_unavailable, +:signer_unavailable}`), the telemetry event `[:trinity, :receipts, :signer_unavailable]` arrives and +`:alarm_handler` holds `:trinity_receipts_signer`; the row count is unchanged and every non-query row has a +signature; a staged effect reaching the membrane directly is denied at admission and the denial's own receipt +failure is named; the key restored, the next effect runs. At the writer: `AC5 at the writer: the key removed +mid-run, the next signed receipt is refused, the alarm sounds, no row is written` (chain_writer_test.exs). + +### AC6 [auto]: boot receipt carries `core_policy_hash`; changing a policy module changes the hash (test) +test/trinity/effects/boot_receipt_test.exs: `the boot receipt of this run: scope boot, signed, the authority, +the signer and the policy hash` (`meta["core_policy_hash"] == CorePolicy.hash()`, unsigned per the R21 default, +the boot chain verifies); `changing a policy module changes the hash; an unchanged one does not` (a module +created, hashed twice, recompiled with another body, hashed again: equal, then different); `the list covers the +modules that decide`. NOTES finding 10 for why the hash is over stripped beams. + +### AC7 [auto]: `bin/verify_receipt.exs` runs from an empty directory against an exported receipt file + registry (stranger test) +`AC7: a stranger's run from an empty directory: verified is 0; the outcomes carry their codes` +(test/trinity/receipts/standalone_verifier_test.exs): the script and the export copied into a directory holding +nothing else (asserted by `File.ls!`), run with `elixir` as a separate OS process: `verified: 12 receipts, 1 +checkpoints` exit 0; a tampered row `invalid: ...` exit 1; an empty registry `trust not established: ...` exit 5; +a compromised row exit 6; a disallowed scheme exit 1; no argument exit 2. `the script and the in-app verifier +agree on every outcome` over six variants. + +### AC8 [auto]: algorithm agility +Registry over body, and the scheme string before any signature, on both legs: +`AC8: the algorithm comes from the registry, not the body; a foreign family is refused at the scheme string` +(verifier_test.exs): a receipt whose scheme names the foreign family under a key whose registry row names the +chain's is `{:scheme_family_mismatch, ...}` before any signature check, both with the column alone and with the +body and hash rewritten to agree (mutant 1: a verifier reading the algorithm from the receipt would report a +signature failure instead, a different reason); a receipt of the foreign family with its own registry row is +`{:scheme_not_allowed, ...}` when the verifier is told to accept only the chain's family, and verifies on its own +where the foreign signer can sign (mutant 2). The FIPS half, run 35542904049 job 106163682937 on the `fips` +leg: `the mode is on, Ed25519 reports unavailable, P-384 is selected and the boot receipt names it` and `no effect +is denied for want of a signer: an effect runs, its receipts are P-384 and verify` (test/fips/receipts_test.exs), +`Result: 6 passed` for `mix test --trace test/fips`, and the whole suite there `322 passed, 12 excluded`. + +### AC9 [auto]: query-receipt checkpoints +test/trinity/receipts/chain_writer_test.exs: `after N query receipts a checkpoint names the tail and its +coverage; its signature verifies` (N = 3 in the test's window); `T milliseconds after the first uncovered query +row, a checkpoint is written by time`; `a query row after the last checkpoint and before shutdown is covered by the +shutdown checkpoint`; `on rehydrate, uncovered query rows are checkpointed before a new row is accepted` (a +killed writer, the next start's continue writes the `rehydrate` checkpoint before the append it then serves). +The AC5 mutant (the denial on signer error removed) goes red in `AC5 at the writer` and in the membrane's AC5. + +## Manual verification for the reviewer +None; SLICE.md tags every criterion `[auto]`. + +## Deviations from SLICE.md +G1 (a) to (d) and, found during the build, (e) to (h): NOTES.md, "Deviations found during the build". The +research amendments A to E (NOTES.md, "Research") are additions within the slice's design, not deviations. + +## Versions touched +`VERSIONS.md` updated: no dependency changed; `:sasl` added to `extra_applications` (OTP's own). `mix +hex.outdated` not run. + +## Git +``` +$ git log --oneline main..HEAD +f977b84 test(s024): the outbound-connection assertion is scoped to this application's processes +d6a2054 test(s024): the receipts tests derive the chain's family and the foreign one from the selection; the peer assertion describes the owner +120ff73 test(s024): mode-aware expectations for the fips leg; the peer assertion names the owner +a0ef284 refactor(s024): credo's seven findings and sobelow's nine +c583ef3 docs(s024): 01, 05, 07 and ADR-0013 as built +7170545 feat(s024): the FIPS half of AC8 for the leg; the policy hash over stripped beams; mode-aware assertions +3d6fc93 feat(s024): the receipts pages: a session's chain and the boot receipt, with verify +a44d820 feat(s024): CorePolicy covers the modules that decide; the boot receipt's tests (AC6) +e1461d2 test(s024): the membrane: the execute/2 census with a planted bypass, every decision and effect receipted, the M2 re-verify, the idempotency key, the catalog and decision checks, the key removed mid-run, Local's callbacks +69cb4e4 feat(s024): the membrane, its runner as the seam in force, the executor on Tools.Runner +85c0cdb fix(s021): a decision that arrives while the tools still run is postponed, not dropped +4b356af test(s024): the authority selection: named refusals, the child spec's start failure, the standalone assertion under local +689542a feat(s024): the verifier's tests, the export and verify tasks, the standalone script +6dd6a86 feat(s024): the chain writer, checkpoints, the authority behaviour and Local, the boot receipt, the verifier +b34cd8f feat(s024): the receipts repo and file, the signer seam, key custody and the registry +9f646ca docs(s024): the design checked against DSSE, RFC 8725, RFC 7638, RFC 5848, C2SP and FIPS 186-5; five G1 amendments +95b3660 docs(s024): G1 plan with the signing and insert costs measured, and the slice opens +``` + +## Closing correction, 2026-09-20 +Supersedes the header's "Final commit" placeholder: the closing commit is `2ab8d28` (`feat(s024): complete +slice 024 (effect catalog, authority selection, local receipts)`), and this correction rides on the commit after it. diff --git a/test/fips/receipts_test.exs b/test/fips/receipts_test.exs new file mode 100644 index 0000000..b77f3ec --- /dev/null +++ b/test/fips/receipts_test.exs @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Fips.ReceiptsTest do + @moduledoc """ + Slice 024 AC8, the FIPS half, on slice 003's leg: with FIPS mode enabled and P-384 + available, no effect is denied for want of a signer, and the boot receipt names P-384. + Tagged `:fips`: included where `TRINITY_FIPS_LEG=1`, excluded by tag elsewhere. + """ + use Trinity.DataCase, async: false + + @moduletag :fips + + alias Trinity.Effects + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Receipts.{KeyCustody, Signer, Verifier} + alias Trinity.Tools.Context + + @note_args %{"path" => "/home/me/notes/a.md", "text" => "hi"} + + test "the mode is on, Ed25519 reports unavailable, P-384 is selected and the boot receipt names it" do + assert :crypto.info_fips() == :enabled + refute Signer.Ed25519.available?() + assert Signer.P384.available?() + assert {:ok, :p384} = KeyCustody.select() + assert %{algorithm: :p384, scheme: "receipt_v2_p384"} = KeyCustody.selected() + + boot = Receipts.boot_receipt() + assert boot.subject["signer"]["algorithm"] == "p384" + assert boot.subject["signer"]["scheme"] == "receipt_v2_p384" + assert boot.subject["fips"] == "enabled" + assert boot.scheme == "receipt_v2_p384" + end + + test "no effect is denied for want of a signer: an effect runs, its receipts are P-384 and verify" do + session = Trinity.Factory.session!() + scope = Receipts.session_scope(session.id) + {:ok, rule} = Permissions.put_rule(%{tool: "write_note", pattern: "*", decision: "allow"}) + + on_exit(fn -> + Permissions.revoke_rule(rule.id) + Receipts.stop_writer(scope) + end) + + ctx = %Context{session_id: session.id, caller: session.id} + + assert {:ok, %{content: "wrote 2 bytes" <> _}, _} = + Effects.Runner.run(%{id: "c1", name: "write_note", args: @note_args}, ctx) + + refute Receipts.Alarm.set?() + + rows = Receipts.list(scope) + assert Enum.map(rows, & &1.kind) == ["decision", "effect", "effect"] + assert Enum.all?(rows, &(&1.scheme == "receipt_v2_p384" and is_binary(&1.signature))) + + :ok = Receipts.stop_writer(scope) + {:ok, export} = Receipts.export(scope) + assert {:ok, %{receipts: 3}} = Verifier.verify(export) + # And an Ed25519-only verifier refuses the chain at the scheme string, not at a signature. + assert {:error, 1, {:scheme_not_allowed, 1, "receipt_v2_p384"}} = + Verifier.verify(export, schemes: ["receipt_v2_ed25519"]) + end +end diff --git a/test/support/authority/adapters.ex b/test/support/authority/adapters.ex new file mode 100644 index 0000000..416ea60 --- /dev/null +++ b/test/support/authority/adapters.ex @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestAuthority.Partial do + @moduledoc "An adapter missing `execute/3` and `receipt/2`: the selection must name the first missing callback." + def stage(staged, _ctx), do: {:ok, staged} + def decide(_staged, decision, _ctx), do: {:ok, decision, %{}} +end + +defmodule Trinity.TestAuthority.Full do + @moduledoc "An adapter implementing every callback; it records what it was asked and executes nothing." + @behaviour Trinity.Authority + + @impl true + def stage(staged, _ctx), do: {:ok, staged} + + @impl true + def decide(_staged, _decision, _ctx), do: {:ok, :deny, %{"by" => "test adapter"}} + + @impl true + def execute(_staged, _decision, _ctx), do: {:error, :adapter_executes_nothing} + + @impl true + def receipt(_kind, _attrs), do: {:ok, :recorded_elsewhere} +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 1e75571..6c8fa43 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -46,7 +46,14 @@ defmodule Trinity.DataCase do if t = tags[:ownership_timeout], do: Keyword.put(opts, :ownership_timeout, t), else: opts pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Trinity.Repo, opts) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + # Slice 024: the receipts Repo under the same ownership, so a chain writer started by a + # test writes inside the test's sandbox and the rows go with it. + rpid = Ecto.Adapters.SQL.Sandbox.start_owner!(Trinity.Repo.Receipts, opts) + + on_exit(fn -> + Ecto.Adapters.SQL.Sandbox.stop_owner(rpid) + Ecto.Adapters.SQL.Sandbox.stop_owner(pid) + end) end @doc """ diff --git a/test/support/effects/bypass.ex b/test/support/effects/bypass.ex new file mode 100644 index 0000000..f05c2f6 --- /dev/null +++ b/test/support/effects/bypass.ex @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestEffects.Bypass do + @moduledoc """ + A planted bypass of the membrane (slice 024 AC1, the F6 pattern): a module that calls an + effectful tool's `execute/2` directly. The census must name it. Never called by product code. + """ + + @doc "Runs the tool without the membrane. The census must flag this call." + def run(module, args, ctx), do: module.execute(args, ctx) +end diff --git a/test/support/receipts/bypass_inserter.ex b/test/support/receipts/bypass_inserter.ex new file mode 100644 index 0000000..a3aaf3a --- /dev/null +++ b/test/support/receipts/bypass_inserter.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestReceipts.BypassInserter do + @moduledoc """ + A planted second insert path into `receipts` (slice 024, ADR-0013's census). It exists so + the census test has something to name: a module that writes a receipt row without going + through `Trinity.Receipts.ChainWriter`. Never called by product code. + """ + + @doc "Inserts a row directly. The census must flag this call." + def insert(row), do: Trinity.Repo.Receipts.insert(row) +end diff --git a/test/support/tools/catalog_claimer_core.ex b/test/support/tools/catalog_claimer_core.ex index 9334a7f..8a54e16 100644 --- a/test/support/tools/catalog_claimer_core.ex +++ b/test/support/tools/catalog_claimer_core.ex @@ -3,7 +3,7 @@ defmodule Trinity.TestTools.CatalogClaimerCore do @moduledoc """ Slice 020, the census plant for the config path (AC8): a core-shaped name claiming - `:catalog` while absent from `Trinity.Effects.Catalog`. A config line naming it must fail + `:catalog` while absent from `Trinity.Tools.Catalog`. A config line naming it must fail the registry's start by name. """ @behaviour Trinity.Tools.Tool diff --git a/test/test_helper.exs b/test/test_helper.exs index 340eee3..ffd8b48 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -10,6 +10,7 @@ fips_leg? = System.get_env("TRINITY_FIPS_LEG") == "1" ExUnit.start(exclude: [:live, :desktop, :eval] ++ if(fips_leg?, do: [], else: [:fips])) Ecto.Adapters.SQL.Sandbox.mode(Trinity.Repo, :manual) +Ecto.Adapters.SQL.Sandbox.mode(Trinity.Repo.Receipts, :manual) # Slice 011: the Mox mock the registry's :mock provider points at. Mox.defmock(Trinity.LLM.ProviderMock, for: Trinity.LLM.Provider) diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs new file mode 100644 index 0000000..9bde01c --- /dev/null +++ b/test/trinity/authority/selection_test.exs @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Authority.SelectionTest do + @moduledoc """ + Slice 024, AC2: `TRINITY_AUTHORITY` set to a module that is absent, or present but not + implementing the behaviour, refuses to start and names which condition failed; `local` + starts; under `local` no adapter module is loaded and no outbound connection exists but + the database's. + """ + use ExUnit.Case, async: false + + alias Trinity.Authority + alias Trinity.Authority.Selection + + test "nil, the empty string and local all select Local" do + for v <- [nil, "", "local"], do: assert({:ok, Trinity.Authority.Local} = Selection.select(v)) + end + + test "an absent module is refused as not loaded, by name" do + assert {:error, {:not_loaded, Trinity.NoSuchAuthority}} = + Selection.select("Trinity.NoSuchAuthority") + + assert {:error, {:not_loaded, Trinity.NoSuchAuthority}} = + Selection.select("Elixir.Trinity.NoSuchAuthority") + end + + test "a present module missing a callback is refused naming the callback" do + assert {:error, {:missing_callback, Trinity.TestAuthority.Partial, {:execute, 3}}} = + Selection.select("Trinity.TestAuthority.Partial") + end + + test "a present module implementing every callback is selected" do + assert {:ok, Trinity.TestAuthority.Full} = Selection.select("Trinity.TestAuthority.Full") + end + + test "boot!/0 reads the environment and raises with the named condition on refusal; the selection is unchanged" do + before = Selection.selected() + System.put_env(Selection.env(), "Trinity.TestAuthority.Partial") + on_exit(fn -> System.delete_env(Selection.env()) end) + + assert_raise RuntimeError, + ~r/TRINITY_AUTHORITY refused: module Trinity.TestAuthority.Partial does not implement execute\/3/, + fn -> + Selection.boot!() + end + + assert Selection.selected() == before + + System.put_env(Selection.env(), "Nope") + assert_raise RuntimeError, ~r/module Nope is not loaded/, fn -> Selection.boot!() end + + # As a child spec, the same refusal is a start failure the supervisor reports. + spec = Selection.child_spec([]) + {m, f, a} = spec.start + Process.flag(:trap_exit, true) + {:ok, pid} = apply(m, f, a) + assert_receive {:EXIT, ^pid, {%RuntimeError{message: "TRINITY_AUTHORITY refused: " <> _}, _}} + end + + test "the standalone assertion: this suite booted under local, no adapter module is loaded, and every TCP peer belongs to the database" do + assert Selection.selected() == Trinity.Authority.Local + assert Authority.impl() == Trinity.Authority.Local + assert Authority.selected_name() == "Trinity.Authority.Local" + + # Every loaded module implementing the behaviour, other than Local and this suite's own + # test adapters (which other tests in this file load by naming them). + loaded_adapters = + for {mod, _} <- :code.all_loaded(), + mod != Trinity.Authority.Local, + not String.starts_with?(Atom.to_string(mod), "Elixir.Trinity.TestAuthority."), + Trinity.Authority in List.flatten( + Keyword.get_values(mod.module_info(:attributes), :behaviour) + ), + do: mod + + assert loaded_adapters == [] + + # Outbound connections: every TCP port with a peer whose owner belongs to this + # application is a database connection (the Postgres job's pool; none under SQLite). + # Listening sockets have no peer. Owners outside the application are the tooling that + # shares the suite's VM: on the hosted runners Mix's Hex client holds a TLS connection to + # hex.pm (Cloudflare addresses on port 443, opened before the application started, no + # Trinity ancestor), which this machine's warm registry cache never opens. Found on run + # 35542784455; not Trinity's connection, and the census asks about Trinity's. + peers = + for port <- Port.list(), + {:name, ~c"tcp_inet"} <- [Port.info(port, :name)], + {:ok, peer} <- [:inet.peername(port)], + {:connected, pid} <- [Port.info(port, :connected)], + do: {port, peer, describe(pid)} + + trinity_peers = Enum.filter(peers, fn {_, _, %{app: app}} -> app == {:ok, :trinity} end) + + for {port, peer, %{initial_call: call} = who} <- trinity_peers do + assert call in [{DBConnection.Connection, :init, 1}, {Postgrex.Protocol, :init, 1}], + "an outbound connection of this application not owned by the database: #{inspect(port)} to #{inspect(peer)} owned by #{inspect(who)}" + end + end + + defp describe(pid) do + d = + case Process.info(pid, :dictionary) do + {:dictionary, d} -> d + _ -> [] + end + + %{ + initial_call: Keyword.get(d, :"$initial_call"), + ancestors: Keyword.get(d, :"$ancestors"), + registered: Process.info(pid, :registered_name), + app: :application.get_application(pid) + } + end +end diff --git a/test/trinity/effects/boot_receipt_test.exs b/test/trinity/effects/boot_receipt_test.exs new file mode 100644 index 0000000..b16ed64 --- /dev/null +++ b/test/trinity/effects/boot_receipt_test.exs @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Effects.BootReceiptTest do + @moduledoc """ + Slice 024 AC6: the boot receipt carries `core_policy_hash`, and changing a policy module + changes the hash. The receipt was written when this suite's application started; the + first test reads it back. The second plants a module, hashes it, changes its body, + recompiles it and hashes again: the digest is over object code, so a changed module is + a changed hash and an unchanged one is not. + """ + use Trinity.DataCase, async: false + + alias Trinity.{CorePolicy, Receipts} + + test "the boot receipt of this run: scope boot, signed, the authority, the signer and the policy hash" do + assert %Receipts.Receipt{kind: "boot", chain_scope: "boot", signature: sig} = + r = Receipts.boot_receipt() + + assert is_binary(sig) + assert r.subject["authority"] == "Trinity.Authority.Local" + selected = Receipts.KeyCustody.selected() + assert r.subject["signer"]["algorithm"] == Atom.to_string(selected.algorithm) + assert r.subject["signer"]["key_id"] == selected.key_id + assert r.subject["fips"] == Atom.to_string(:crypto.info_fips()) + assert r.meta["core_policy_hash"] == CorePolicy.hash() + assert r.meta["canonicalization_version"] == 1 + body = JSON.decode!(r.signed_payload) + assert body["subject"]["authority"] == "Trinity.Authority.Local" + # The policy hash is unsigned metadata, per the R21 default: not in the signed body. + refute Map.has_key?(body, "core_policy_hash") + assert Receipts.boot_hash() == r.receipt_hash + + {:ok, export} = Receipts.export("boot") + assert {:ok, %{receipts: n}} = Receipts.Verifier.verify(export) + assert n >= 1 + end + + test "the list covers the modules that decide" do + for m <- [ + Trinity.Permissions.Policy.Layered, + Trinity.Tools.Catalog, + Trinity.Effects, + Trinity.Authority.Local, + Trinity.Receipts.KeyCustody + ], + do: assert(m in CorePolicy.modules()) + + assert CorePolicy.hash() == CorePolicy.hash_of(CorePolicy.modules()) + end + + test "changing a policy module changes the hash; an unchanged one does not" do + mod = :"Elixir.Trinity.PlantedPolicy#{System.unique_integer([:positive])}" + + compile = fn body -> + {:module, ^mod, binary, _} = Module.create(mod, body, Macro.Env.location(__ENV__)) + # get_object_code/1 reads the loaded module's binary through the code path; a module + # created in memory has none, so the test writes it where the code server looks. + dir = Path.join(System.tmp_dir!(), "trinity-policy-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + File.write!(Path.join(dir, "#{mod}.beam"), binary) + :code.purge(mod) + :code.delete(mod) + true = Code.prepend_path(dir) + {:module, ^mod} = :code.load_file(mod) + dir + end + + d1 = compile.(quote(do: def(decide, do: :allow))) + h1 = CorePolicy.hash_of([mod]) + h1_again = CorePolicy.hash_of([mod]) + d2 = compile.(quote(do: def(decide, do: :deny))) + h2 = CorePolicy.hash_of([mod]) + + on_exit(fn -> + File.rm_rf!(d1) + File.rm_rf!(d2) + end) + + assert h1 == h1_again + assert h1 != h2 + assert String.length(h1) == 64 + end +end diff --git a/test/trinity/effects/census_test.exs b/test/trinity/effects/census_test.exs new file mode 100644 index 0000000..fa01de6 --- /dev/null +++ b/test/trinity/effects/census_test.exs @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Effects.CensusTest do + @moduledoc """ + Slice 024 AC1: exactly one caller of `execute/2` for effectful tools, and a planted bypass + is flagged. The population is every source file in `lib/` and `test/support/` that + `git ls-files` names, grepped for a call of `execute(` on a module value; the allowed set is + `Trinity.Authority.Local` (the executor for effectful tools) and `Trinity.Tools.Runner` + (whose `call_tool/3` is guarded to `effect: :none`, which the second test proves). The + planted `Trinity.TestEffects.Bypass` is the third name and must be there, or the census is + not looking. + """ + use ExUnit.Case, async: true + + alias Trinity.Tools.{Context, Runner} + + @allowed ["lib/trinity/authority/local.ex", "lib/trinity/tools/runner.ex"] + @planted ["test/support/effects/bypass.ex"] + + test "the callers of execute/2 on a tool module are the two allowed and the one planted" do + {out, 0} = System.cmd("git", ["ls-files", "lib/*.ex", "test/support/*.ex"]) + files = String.split(out, "\n", trim: true) + assert length(files) > 100 + + # `.execute(` is a call on a module held in a variable, which is how a tool is + # invoked; `Trinity.Effects.execute(` and `authority.execute(` are the membrane and the + # authority behaviour, named and excluded by their receivers; `:telemetry.execute(` is an + # Erlang module, excluded by the colon before it. + callers = + for f <- files, + src = File.read!(f), + Regex.scan(~r/(? Enum.map(fn [_, recv] -> recv end) + |> Enum.reject(&(&1 in ["authority", "impl", "executor"])) + |> Enum.any?(), + do: f + + assert Enum.sort(callers) == Enum.sort(@allowed ++ @planted) + end + + test "Tools.Runner.call_tool/3 runs an effect: :none entry and refuses an effectful one by name" do + {:ok, echo} = Trinity.Tools.lookup("echo") + {:ok, note} = Trinity.Tools.lookup("write_note") + ctx = %Context{} + assert {:ok, %{content: "hi"}} = Runner.call_tool(echo, %{"text" => "hi"}, ctx) + + assert {:error, {:effectful_tool_outside_membrane, "write_note"}} = + Runner.call_tool(note, %{"path" => "/x", "text" => "t"}, ctx) + end + + test "the planted bypass is a real bypass: it runs the effectful tool, which is what the census exists to catch" do + assert {:ok, %{content: "wrote 1 bytes to /x"}} = + Trinity.TestEffects.Bypass.run( + Trinity.TestTools.WriteNote, + %{"path" => "/x", "text" => "t"}, + %Context{} + ) + end +end diff --git a/test/trinity/effects/membrane_test.exs b/test/trinity/effects/membrane_test.exs new file mode 100644 index 0000000..a8b56aa --- /dev/null +++ b/test/trinity/effects/membrane_test.exs @@ -0,0 +1,261 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Effects.MembraneTest do + @moduledoc """ + Slice 024: the membrane's refusals and receipts (AC3's first half, AC4, AC5), through the + runner in force (`Trinity.Effects.Runner`) as the Session calls it, and through + `Trinity.Effects.execute/2` directly where a mutation between decision and execution + has to be planted. + """ + use Trinity.DataCase, async: false + + alias Trinity.Authority.Staged + alias Trinity.Effects + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Receipts.{Alarm, KeyCustody} + alias Trinity.Tools.Context + + @note_args %{"path" => "/home/me/notes/a.md", "text" => "hi"} + + setup do + session = Trinity.Factory.session!() + scope = Receipts.session_scope(session.id) + ctx = %Context{session_id: session.id, caller: session.id, call_id: "c1"} + # A global rule so write_note is allowed without an approval round-trip. + {:ok, rule} = Permissions.put_rule(%{tool: "write_note", pattern: "*", decision: "allow"}) + + on_exit(fn -> + Permissions.revoke_rule(rule.id) + Receipts.stop_writer(scope) + Alarm.clear() + end) + + {:ok, ctx: ctx, scope: scope} + end + + defp kinds(scope), + do: + scope |> Receipts.list() |> Enum.map(&{&1.kind, &1.subject["call_id"], &1.subject["phase"]}) + + test "AC3: every gate decision and every effect yields a receipt: a read gives decision and query; an effect gives decision, admit and done", + %{ctx: ctx, scope: scope} do + calls = [ + %{id: "c1", name: "echo", args: %{"text" => "hi"}}, + %{id: "c2", name: "write_note", args: @note_args} + ] + + results = Effects.Runner.run_all(calls, ctx) + + assert [{_, {:ok, %{content: "hi"}, _}}, {_, {:ok, %{content: "wrote 2 bytes" <> _}, _}}] = + results + + rows = Receipts.list(scope) + + assert Enum.sort(kinds(scope)) == + Enum.sort([ + {"decision", "c1", nil}, + {"query", "c1", nil}, + {"decision", "c2", nil}, + {"effect", "c2", "admit"}, + {"effect", "c2", "done"} + ]) + + admit = Enum.find(rows, &(&1.kind == "effect" and &1.subject["phase"] == "admit")) + done = Enum.find(rows, &(&1.kind == "effect" and &1.subject["phase"] == "done")) + assert admit.seq < done.seq + assert admit.signature != nil and done.signature != nil + assert admit.subject_ref == "effect:#{ctx.session_id}:c2" + body = JSON.decode!(done.signed_payload) + assert body["decision"]["ok"] == true + + assert body["fingerprint"] == + Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) + + assert Enum.all?(rows, &(&1.meta["tool_definition_digest"] != nil or &1.kind == "effect")) + end + + test "a denied call yields a decision receipt and nothing else; an asked call the same", %{ + ctx: ctx, + scope: scope + } do + {:ok, deny} = + Permissions.put_rule(%{tool: "write_note", pattern: "*", decision: "deny", scope: "global"}) + + on_exit(fn -> Permissions.revoke_rule(deny.id) end) + + assert {:error, :denied, _} = + Effects.Runner.run(%{id: "c9", name: "write_note", args: @note_args}, ctx) + + assert [{"decision", "c9", nil}] = kinds(scope) + [row] = Receipts.list(scope) + assert JSON.decode!(row.signed_payload)["decision"]["outcome"] == "deny" + end + + test "AC4: arguments mutated after the decision are denied at execution with a receipt (M2 re-verify)", + %{ctx: ctx, scope: scope} do + fp = Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) + {:ok, entry} = Trinity.Tools.lookup("write_note") + + staged = %Staged{ + tool: "write_note", + module: entry.module, + effect: :artifact, + args: Map.put(@note_args, "text", "something else"), + call_id: "c4", + session_id: ctx.session_id, + scope: scope, + cwd: nil, + decision: :allow, + fingerprint: fp + } + + assert {:error, {:denied, {:fingerprint_mismatch, ^fp, derived}}} = + Effects.execute(staged, ctx) + + assert derived == Permissions.fingerprint(ctx.session_id, "write_note", staged.args, nil) + assert [{"effect", "c4", "denied"}] = kinds(scope) + [row] = Receipts.list(scope) + assert JSON.decode!(row.signed_payload)["decision"]["reason"] =~ "fingerprint_mismatch" + + # The same staged effect with the arguments the decision bound runs. + assert {:ok, %{content: "wrote 2 bytes" <> _}} = + Effects.execute(%{staged | args: @note_args, call_id: "c5"}, ctx) + end + + test "the idempotency key: a second execution of the same session and call id is denied with a receipt", + %{ctx: ctx, scope: scope} do + call = %{id: "c6", name: "write_note", args: @note_args} + assert {:ok, _, _} = Effects.Runner.run(call, ctx) + assert {:error, {:denied, {:duplicate_effect, ref}}, _} = Effects.Runner.run(call, ctx) + assert ref == "effect:#{ctx.session_id}:c6" + + assert Enum.filter(kinds(scope), &match?({"effect", "c6", _}, &1)) == [ + {"effect", "c6", "admit"}, + {"effect", "c6", "done"}, + {"effect", "c6", "denied"} + ] + end + + test "a :catalog tool absent from the catalog, or a decision other than allow, is denied before anything runs", + %{ctx: ctx, scope: scope} do + {:ok, entry} = Trinity.Tools.lookup("write_note") + + base = %Staged{ + tool: "write_note", + module: entry.module, + effect: :artifact, + args: @note_args, + call_id: "c7", + session_id: ctx.session_id, + scope: scope, + decision: :allow, + fingerprint: Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) + } + + assert {:error, {:denied, {:not_in_catalog, "write_note"}}} = + Effects.execute(%{base | effect: :catalog}, ctx) + + assert {:error, {:denied, {:decision_not_allow, :ask}}} = + Effects.execute(%{base | decision: :ask, call_id: "c8"}, ctx) + + assert {:error, {:denied, {:effect_not_admitted, :none}}} = + Effects.execute(%{base | effect: :none, call_id: "c9"}, ctx) + + assert Enum.map(kinds(scope), &elem(&1, 2)) == ["denied", "denied", "denied"] + end + + test "AC5: the signing key removed mid-run: the next effect is denied, the alarm sounds, no unsigned receipt row exists; a read is refused too, because its decision cannot be receipted", + %{ctx: ctx, scope: scope} do + %{key_path: path} = KeyCustody.selected() + bytes = File.read!(path) + on_exit(fn -> File.write!(path, bytes) end) + Alarm.clear() + + assert {:ok, _, _} = + Effects.Runner.run(%{id: "c10", name: "write_note", args: @note_args}, ctx) + + before = Receipts.count(scope) + + :telemetry.attach( + "s024-membrane-alarm", + Alarm.event(), + fn _, _, meta, pid -> send(pid, {:alarm, meta.reason}) end, + self() + ) + + on_exit(fn -> :telemetry.detach("s024-membrane-alarm") end) + + File.rm!(path) + + assert {:error, {:decision_not_receipted, {:signer_unavailable, :signer_unavailable}}, _} = + Effects.Runner.run(%{id: "c11", name: "write_note", args: @note_args}, ctx) + + assert_receive {:alarm, :signer_unavailable} + assert Alarm.set?() + + assert {:error, {:decision_not_receipted, _}, _} = + Effects.Runner.run(%{id: "c12", name: "echo", args: %{"text" => "x"}}, ctx) + + assert Receipts.count(scope) == before + + assert Receipts.list(scope) + |> Enum.filter(&(&1.kind != "query")) + |> Enum.all?(&(&1.signature != nil)) + + # A membrane call whose decision was receipted before the key went: denied at admission, + # and the denial cannot be receipted either, which the error names. + {:ok, entry} = Trinity.Tools.lookup("write_note") + + staged = %Staged{ + tool: "write_note", + module: entry.module, + effect: :artifact, + args: @note_args, + call_id: "c13", + session_id: ctx.session_id, + scope: scope, + decision: :allow, + fingerprint: Permissions.fingerprint(ctx.session_id, "write_note", @note_args, nil) + } + + assert {:error, {:denied, {:signer_unavailable, _}, {:receipt_failed, _}}} = + Effects.execute(staged, ctx) + + assert Receipts.count(scope) == before + + File.write!(path, bytes) + + assert {:ok, _, _} = + Effects.Runner.run(%{id: "c14", name: "write_note", args: @note_args}, ctx) + end + + test "Local: stage stamps, decide follows the gate, execute runs the tool or refuses a denial, receipt appends" do + {:ok, entry} = Trinity.Tools.lookup("write_note") + scope = "local:" <> Trinity.UUID.generate() + on_exit(fn -> Receipts.stop_writer(scope) end) + + staged = %Staged{ + tool: "write_note", + module: entry.module, + effect: :artifact, + args: @note_args, + call_id: "x", + session_id: nil, + scope: scope, + decision: :allow, + fingerprint: nil + } + + local = Trinity.Authority.Local + assert {:ok, %Staged{staged_at: %DateTime{}}} = local.stage(staged, %Context{}) + assert {:ok, :allow, %{"by" => "gate"}} = local.decide(staged, :allow, %Context{}) + assert {:ok, :deny, %{"by" => "gate"}} = local.decide(staged, :deny, %Context{}) + assert {:ok, :deny, %{"reason" => "undecided"}} = local.decide(staged, :ask, %Context{}) + assert {:ok, %{content: "wrote 2 bytes" <> _}} = local.execute(staged, :allow, %Context{}) + assert {:error, :denied} = local.execute(staged, :deny, %Context{}) + + assert {:ok, %Receipts.Receipt{kind: "cap"}} = + local.receipt("cap", %{scope: scope, subject: %{"cap" => "iterations"}}) + end +end diff --git a/test/trinity/receipts/chain_writer_test.exs b/test/trinity/receipts/chain_writer_test.exs new file mode 100644 index 0000000..f791e97 --- /dev/null +++ b/test/trinity/receipts/chain_writer_test.exs @@ -0,0 +1,299 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.ChainWriterTest do + @moduledoc "Slice 024, G1 line 3: one writer per scope, the chain, the checkpoints, the refusals." + use Trinity.DataCase, async: false + + alias Trinity.Receipts + alias Trinity.Receipts.{Alarm, ChainWriter, Checkpoint, Envelope, KeyCustody, Receipt, Signer} + alias Trinity.Repo.Receipts, as: RRepo + + setup do + scope = "test:" <> Trinity.UUID.generate() + on_exit(fn -> Receipts.stop_writer(scope) end) + {:ok, scope: scope} + end + + defp decision(n), + do: %{ + kind: "decision", + subject: %{"n" => n}, + decision: %{"outcome" => "allow"}, + fingerprint: "ab" <> Integer.to_string(n) + } + + defp query(n), do: %{kind: "query", subject: %{"n" => n}} + + defp with_window(every, after_ms, fun) do + old = Application.get_env(:trinity, :receipts, []) + + Application.put_env( + :trinity, + :receipts, + Keyword.merge(old, checkpoint_every: every, checkpoint_after_ms: after_ms) + ) + + try do + fun.() + after + Application.put_env(:trinity, :receipts, old) + end + end + + test "rows chain: gapless seq, each prev_hash the previous receipt_hash, signed kinds verify, query rows unsigned", + %{scope: scope} do + for n <- 1..5, do: assert({:ok, %Receipt{}} = Receipts.append(scope, decision(n))) + for n <- 6..8, do: assert({:ok, %Receipt{}} = Receipts.append(scope, query(n))) + rows = Receipts.list(scope) + assert Enum.map(rows, & &1.seq) == Enum.to_list(1..8) + assert hd(rows).prev_hash == nil + + rows + |> Enum.chunk_every(2, 1, :discard) + |> Enum.each(fn [a, b] -> assert b.prev_hash == a.receipt_hash end) + + %{impl: impl, keys_dir: dir, key_id: key_id} = KeyCustody.selected() + {:ok, registry} = Receipts.KeyRegistry.read(dir) + + {:ok, pub} = + registry |> Receipts.KeyRegistry.lookup(key_id) |> Receipts.KeyRegistry.public_key() + + for r <- rows do + bytes = Envelope.pae(Envelope.receipt_type(r.scheme), r.signed_payload) + assert r.receipt_hash == Envelope.hash(bytes) + assert r.scheme == impl.scheme() + assert r.key_id == key_id + + if r.kind == "query", + do: assert(r.signature == nil), + else: assert(impl.verify(bytes, r.signature, pub)) + end + + # The body carries the scheme, the seq, the scope and the key id (the R21 default set). + body = JSON.decode!(hd(rows).signed_payload) + + assert Map.keys(body) |> Enum.sort() == + ~w(at chain_scope decision fingerprint key_id kind prev_hash scheme seq subject) + + assert body["scheme"] == impl.scheme() + end + + test "concurrent appenders to one scope produce one chain: no two rows share a prev_hash", %{ + scope: scope + } do + {:ok, _} = Receipts.ensure_writer(scope) + + 1..40 + |> Task.async_stream(fn n -> Receipts.append(scope, query(n)) end, max_concurrency: 40) + |> Enum.each(fn {:ok, {:ok, %Receipt{}}} -> :ok end) + + rows = Receipts.list(scope) + assert length(rows) == 40 + assert Enum.map(rows, & &1.seq) == Enum.to_list(1..40) + prevs = rows |> Enum.map(& &1.prev_hash) |> Enum.reject(&is_nil/1) + assert length(prevs) == length(Enum.uniq(prevs)) + end + + test "AC5 at the writer: the key removed mid-run, the next signed receipt is refused, the alarm sounds, no row is written", + %{scope: scope} do + %{key_path: path} = KeyCustody.selected() + bytes = File.read!(path) + + on_exit(fn -> + File.write!(path, bytes) + Alarm.clear() + end) + + Alarm.clear() + assert {:ok, _} = Receipts.append(scope, decision(1)) + + :telemetry.attach( + "s024-alarm-test", + Alarm.event(), + fn _, m, meta, pid -> send(pid, {:alarm_event, m, meta}) end, + self() + ) + + on_exit(fn -> :telemetry.detach("s024-alarm-test") end) + + File.rm!(path) + + assert {:error, {:signer_unavailable, :signer_unavailable}} = + Receipts.append(scope, decision(2)) + + assert Alarm.set?() + assert_receive {:alarm_event, %{count: 1}, %{reason: :signer_unavailable}} + assert Receipts.count(scope) == 1 + assert Receipts.list(scope) |> Enum.all?(&(&1.signature != nil)) + + # A query row is chained without a signature and still goes in; its checkpoint waits. + assert {:ok, %Receipt{kind: "query"}} = Receipts.append(scope, query(3)) + File.write!(path, bytes) + assert {:ok, _} = Receipts.append(scope, decision(4)) + end + + describe "AC9: query checkpoints" do + test "after N query receipts a checkpoint names the tail and its coverage; its signature verifies", + %{scope: scope} do + with_window(3, 60_000, fn -> + for n <- 1..3, do: {:ok, _} = Receipts.append(scope, query(n)) + + assert [%Checkpoint{first_seq: 1, last_seq: 3, reason: "count"} = cp] = + Receipts.checkpoints(scope) + + assert cp.tail_hash == Receipts.tail(scope).receipt_hash + %{impl: impl, keys_dir: dir, key_id: key_id} = KeyCustody.selected() + {:ok, registry} = Receipts.KeyRegistry.read(dir) + + {:ok, pub} = + registry |> Receipts.KeyRegistry.lookup(key_id) |> Receipts.KeyRegistry.public_key() + + assert impl.verify( + Envelope.pae(Envelope.checkpoint_type(cp.scheme), cp.signed_payload), + cp.signature, + pub + ) + + body = JSON.decode!(cp.signed_payload) + + assert body["first_seq"] == 1 and body["last_seq"] == 3 and + body["tail_hash"] == cp.tail_hash + + # A fourth opens a new window; a decision receipt does not count toward it. + {:ok, _} = Receipts.append(scope, query(4)) + {:ok, _} = Receipts.append(scope, decision(5)) + assert length(Receipts.checkpoints(scope)) == 1 + end) + end + + test "T milliseconds after the first uncovered query row, a checkpoint is written by time", %{ + scope: scope + } do + with_window(1_000, 50, fn -> + {:ok, _} = Receipts.append(scope, query(1)) + Process.sleep(150) + + assert [%Checkpoint{first_seq: 1, last_seq: 1, reason: "time"}] = + Receipts.checkpoints(scope) + end) + end + + test "a query row after the last checkpoint and before shutdown is covered by the shutdown checkpoint", + %{scope: scope} do + with_window(1_000, 60_000, fn -> + {:ok, _} = Receipts.append(scope, query(1)) + {:ok, _} = Receipts.append(scope, query(2)) + :ok = Receipts.stop_writer(scope) + + assert [%Checkpoint{first_seq: 1, last_seq: 2, reason: "shutdown"}] = + Receipts.checkpoints(scope) + end) + end + + test "on rehydrate, uncovered query rows are checkpointed before a new row is accepted", %{ + scope: scope + } do + with_window(1_000, 60_000, fn -> + {:ok, pid} = Receipts.ensure_writer(scope) + {:ok, _} = Receipts.append(scope, query(1)) + # A crash, not a shutdown: terminate/2 does not run, nothing is covered, and the + # writer is temporary, so nothing restarts it until the next demand. + ref = Process.monitor(pid) + Process.exit(pid, :kill) + assert_receive {:DOWN, ^ref, :process, ^pid, :killed} + # The registry drops the name a moment after the DOWN; wait for that, not for luck. + assert Enum.any?(1..50, fn _ -> + ChainWriter.whereis(scope) == nil || (Process.sleep(10) && false) + end) + + assert Receipts.checkpoints(scope) == [] + {:ok, _} = Receipts.ensure_writer(scope) + # The rehydrate checkpoint is written in the writer's continue, before this append is + # served; the append's answer is the proof of the order. + {:ok, %Receipt{seq: 2}} = Receipts.append(scope, query(2)) + + assert [%Checkpoint{first_seq: 1, last_seq: 1, reason: "rehydrate"}] = + Receipts.checkpoints(scope) + end) + end + end + + describe "refusals on start" do + test "a tail altered on disk stops the writer with the reason", %{scope: scope} do + {:ok, _} = Receipts.append(scope, decision(1)) + :ok = Receipts.stop_writer(scope) + tail = Receipts.tail(scope) + + RRepo.update_all(from(r in Receipt, where: r.id == ^tail.id), + set: [signed_payload: String.replace(tail.signed_payload, "allow", "deny!")] + ) + + Process.flag(:trap_exit, true) + + assert {:error, {:chain_inconsistent, ^scope, {:tail_hash_mismatch, 1}}} = + ChainWriter.start_link(scope) + end + + test "a checkpoint whose tail is not in the chain, or whose signature fails, stops the writer", + %{scope: scope} do + with_window(1, 60_000, fn -> + {:ok, _} = Receipts.append(scope, query(1)) + [cp] = Receipts.checkpoints(scope) + :ok = Receipts.stop_writer(scope) + Process.flag(:trap_exit, true) + + RRepo.update_all(from(c in Checkpoint, where: c.id == ^cp.id), + set: [tail_hash: String.duplicate("0", 64)] + ) + + assert {:error, {:chain_inconsistent, ^scope, {:checkpoint_tail_not_in_chain, 1}}} = + ChainWriter.start_link(scope) + + RRepo.update_all(from(c in Checkpoint, where: c.id == ^cp.id), + set: [tail_hash: cp.tail_hash, signature: :crypto.strong_rand_bytes(64)] + ) + + assert {:error, {:chain_inconsistent, ^scope, {:checkpoint_signature_invalid, 1}}} = + ChainWriter.start_link(scope) + end) + end + end + + test "the census: ChainWriter is the only inserter into receipts; the planted bypass is named" do + {out, 0} = System.cmd("git", ["ls-files", "lib/*.ex", "test/support/*.ex"]) + files = out |> String.split("\n", trim: true) + assert length(files) > 50 + + writes = ~w(insert insert! insert_all update update_all delete delete_all) + + # A file writes through the receipts Repo when it names the module, or an alias of it, + # followed by a write function. Reads (`all`, `one`, `aggregate`) do not count. + inserters = + for f <- files, + src = File.read!(f), + aliases = + Regex.scan(~r/alias Trinity\.Repo\.Receipts(?:, as: ([A-Z]\w*))?/, src) + |> Enum.map(fn + [_, as] -> as + [_] -> "Receipts" + end), + names = ["Trinity.Repo.Receipts" | aliases], + Enum.any?(names, fn n -> + Enum.any?(writes, &String.contains?(src, n <> "." <> &1 <> "(")) + end), + do: f + + assert Enum.sort(inserters) == [ + "lib/trinity/receipts/chain_writer.ex", + "test/support/receipts/bypass_inserter.ex" + ] + + assert Trinity.TestReceipts.BypassInserter in (:application.get_key(:trinity, :modules) + |> elem(1)) + end + + test "the scheme string resolves for every row and the implementations agree with the registry" do + %{impl: impl, scheme: scheme} = KeyCustody.selected() + assert {:ok, ^impl} = Signer.impl_for_scheme(scheme) + end +end diff --git a/test/trinity/receipts/signer_test.exs b/test/trinity/receipts/signer_test.exs new file mode 100644 index 0000000..4425797 --- /dev/null +++ b/test/trinity/receipts/signer_test.exs @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.SignerTest do + @moduledoc """ + Slice 024, G1 line 2: the signer seam, key custody and the registry. The selection rule + is measured against `crypto:info_fips/0` on this runtime (`not_supported` here, `enabled` + on the FIPS leg, where test/fips/receipts_test.exs asserts the other branch). + """ + use ExUnit.Case, async: false + + alias Trinity.Receipts.{Envelope, KeyCustody, KeyRegistry, Signer} + + setup do + dir = Path.join(System.tmp_dir!(), "trinity-keys-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + + # `boot!/1` on a temp dir replaces the suite's selection; put the suite's back after. + on_exit(fn -> + File.rm_rf!(dir) + {:ok, _} = KeyCustody.boot!() + end) + + {:ok, dir: dir} + end + + describe "the seam" do + test "each implementation signs and verifies its own family over the PAE, and refuses altered bytes" do + for impl <- [Signer.Ed25519, Signer.P384], impl.available?() do + {pub, priv} = impl.generate_key() + bytes = Envelope.pae(Envelope.receipt_type(impl.scheme()), ~s({"a":1})) + sig = impl.sign(bytes, priv) + assert impl.verify(bytes, sig, pub) + refute impl.verify(bytes <> "x", sig, pub) + # A signature over the bare body is not a signature over the envelope: the type is + # inside the signed bytes (research amendment A). + refute impl.verify(~s({"a":1}), sig, pub) + end + end + + test "the scheme strings resolve to their implementation and carry the family" do + assert {:ok, Signer.Ed25519} = Signer.impl_for_scheme("receipt_v2_ed25519") + assert {:ok, Signer.P384} = Signer.impl_for_scheme("receipt_v2_p384") + assert {:ok, Signer.MLDSA87} = Signer.impl_for_scheme("receipt_v2_mldsa87") + assert :error = Signer.impl_for_scheme("receipt_v1_ed25519") + end + + test "ML-DSA-87 reports itself unavailable or available from the runtime, never from the build" do + assert Signer.MLDSA87.available?() == :mldsa87 in :crypto.supports(:public_keys) + end + + test "the RFC 7638 thumbprint: lexicographic members, no whitespace, SHA-256, base64url" do + # RFC 7638 section 3.1's example is RSA; the rule is the same and this is the OKP case + # computed by hand from the definition. + jwk = %{"kty" => "OKP", "crv" => "Ed25519", "x" => "abc"} + expected = :crypto.hash(:sha256, ~s({"crv":"Ed25519","kty":"OKP","x":"abc"})) + assert Signer.thumbprint(jwk) == Base.url_encode64(expected, padding: false) + end + end + + describe "selection" do + test "outside FIPS mode the default is Ed25519; in FIPS mode (the fips leg) it is P-384" do + expected = if :crypto.info_fips() == :enabled, do: :p384, else: :ed25519 + assert {:ok, ^expected} = KeyCustody.select() + end + + test "a configured ML-DSA-87 is refused where the runtime lacks it, naming the algorithm" do + Application.put_env(:trinity, :receipts, algorithm: :mldsa87, keys_dir: nil) + on_exit(fn -> Application.put_env(:trinity, :receipts, keys_dir: test_keys_dir()) end) + + case Signer.MLDSA87.available?() do + false -> assert {:error, {:no_approved_signer, :mldsa87, _}} = KeyCustody.select() + true -> assert {:ok, :mldsa87} = KeyCustody.select() + end + end + end + + describe "custody" do + test "boot generates the key once (0600), appends its registry row, and a second boot reuses it", + %{dir: dir} do + {:ok, expected} = KeyCustody.select() + + assert {:ok, %{algorithm: ^expected, key_id: key_id, key_path: path}} = + KeyCustody.boot!(dir) + + assert File.exists?(path) + assert File.stat!(path).mode |> Bitwise.band(0o777) == 0o600 + assert {:ok, [row]} = KeyRegistry.read(dir) + assert row["key_id"] == key_id + assert row["algorithm"] == Atom.to_string(expected) + assert row["scheme"] == Signer.impl(expected).scheme() + assert row["kid_scheme"] == "rfc7638" + assert row["status"] == "active" + assert key_id == Signer.thumbprint(row["jwk"]) + + assert {:ok, %{key_id: ^key_id}} = KeyCustody.boot!(dir) + assert {:ok, [_one]} = KeyRegistry.read(dir) + end + + test "sign reads the key file at every call: removed mid-run, the next sign is unavailable", + %{dir: dir} do + {:ok, %{key_path: path}} = KeyCustody.boot!(dir) + assert {:ok, sig} = KeyCustody.sign("bytes") + assert is_binary(sig) + File.rm!(path) + assert {:error, :signer_unavailable} = KeyCustody.sign("bytes") + end + + test "the registry is append-only: a status change is a new row and the newest wins", %{ + dir: dir + } do + {:ok, %{key_id: key_id}} = KeyCustody.boot!(dir) + {:ok, [row]} = KeyRegistry.read(dir) + + compromised = + Map.merge(row, %{"status" => "compromised", "valid_from" => "2026-09-21T00:00:00Z"}) + + assert {:ok, [^row, ^compromised]} = KeyRegistry.append(dir, compromised) + {:ok, rows} = KeyRegistry.read(dir) + assert KeyRegistry.lookup(rows, key_id)["status"] == "compromised" + {:ok, alg} = KeyCustody.select() + assert KeyRegistry.active_for(rows, alg) == nil + end + + test "a key file whose id is not in the registry refuses to boot, naming the id", %{dir: dir} do + {:ok, %{key_path: path}} = KeyCustody.boot!(dir) + File.rm!(KeyRegistry.path(dir)) + assert {:error, {:key_not_in_registry, _}} = KeyCustody.boot!(dir) + assert File.exists?(path) + end + end + + defp test_keys_dir, do: Path.expand("../../../tmp/test_keys", __DIR__) +end diff --git a/test/trinity/receipts/standalone_verifier_test.exs b/test/trinity/receipts/standalone_verifier_test.exs new file mode 100644 index 0000000..bf63429 --- /dev/null +++ b/test/trinity/receipts/standalone_verifier_test.exs @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.StandaloneVerifierTest do + @moduledoc """ + Slice 024, AC7: `bin/verify_receipt.exs` runs from an empty directory, with `elixir` alone, + against an exported file, and answers with the exit vocabulary. And the drift guard: on + the same inputs it agrees with `Trinity.Receipts.Verifier`, outcome for outcome. + """ + use Trinity.DataCase, async: false + + alias Trinity.Receipts + alias Trinity.Receipts.Verifier + + @script Path.expand("../../../bin/verify_receipt.exs", __DIR__) + + setup do + scope = "standalone:" <> Trinity.UUID.generate() + dir = Path.join(System.tmp_dir!(), "trinity-empty-#{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, n) do + for i <- 1..n do + attrs = + if rem(i, 2) == 0, + do: %{ + kind: "effect", + subject: %{"i" => i}, + decision: %{"outcome" => "allow"}, + fingerprint: "f#{i}" + }, + else: %{kind: "query", subject: %{"i" => i}} + + {:ok, _} = Receipts.append(scope, attrs) + end + + :ok = Receipts.stop_writer(scope) + {:ok, export} = Receipts.export(scope) + export + end + + # The script copied into the empty directory, run there with only elixir on the path. + defp run_script(dir, export, args \\ []) do + script = Path.join(dir, "verify_receipt.exs") + File.cp!(@script, script) + file = Path.join(dir, "export.json") + File.write!(file, JSON.encode!(export)) + assert File.ls!(dir) |> Enum.sort() == ["export.json", "verify_receipt.exs"] + {out, code} = System.cmd("elixir", [script, file | args], cd: dir, stderr_to_stdout: true) + {String.trim(out), code} + end + + test "AC7: a stranger's run from an empty directory: verified is 0; the outcomes carry their codes", + %{scope: scope, dir: dir} do + export = export(scope, 12) + assert {"verified: 12 receipts, 1 checkpoints", 0} = run_script(dir, export) + + tampered = + update_in(export["receipts"], fn rows -> + Enum.map( + rows, + &if(&1["seq"] == 6, + do: Map.put(&1, "signed_payload", &1["signed_payload"] <> " "), + else: &1 + ) + ) + end) + + assert {"invalid: " <> _, 1} = run_script(dir, tampered) + + assert {"trust not established: " <> _, 5} = run_script(dir, Map.put(export, "registry", [])) + + compromised = + update_in(export["registry"], fn rows -> + rows ++ [Map.put(List.last(rows), "status", "compromised")] + end) + + assert {"compromised key: " <> _, 6} = run_script(dir, compromised) + + assert {"invalid: {:scheme_not_allowed, " <> _, 1} = + run_script(dir, export, ["--schemes", other_scheme()]) + + {out, 2} = + System.cmd("elixir", [Path.join(dir, "verify_receipt.exs")], + cd: dir, + stderr_to_stdout: true + ) + + assert out =~ "usage" + end + + # A scheme the chain is not: P-384's where the selection is Ed25519, Ed25519's on the fips leg. + defp other_scheme do + case Trinity.Receipts.KeyCustody.selected() do + %{algorithm: :ed25519} -> "receipt_v2_p384" + _ -> "receipt_v2_ed25519" + end + end + + test "the script and the in-app verifier agree on every outcome", %{scope: scope, dir: dir} do + export = export(scope, 12) + + variants = [ + export, + update_in(export["receipts"], fn rows -> + Enum.map( + rows, + &if(&1["seq"] == 3, + do: Map.put(&1, "receipt_hash", String.duplicate("a", 64)), + else: &1 + ) + ) + end), + Map.put(export, "registry", []), + update_in(export["registry"], fn rows -> + rows ++ [Map.put(List.last(rows), "status", "compromised")] + end), + Map.put(export, "checkpoints", []), + Map.put(export, "receipts", Enum.reject(export["receipts"], &(&1["seq"] == 5))) + ] + + for v <- variants do + {_out, code} = run_script(dir, v) + assert code == Verifier.exit_code(Verifier.verify(v)) + end + end +end diff --git a/test/trinity/receipts/verifier_test.exs b/test/trinity/receipts/verifier_test.exs new file mode 100644 index 0000000..3b77b15 --- /dev/null +++ b/test/trinity/receipts/verifier_test.exs @@ -0,0 +1,280 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Receipts.VerifierTest do + @moduledoc """ + Slice 024, AC3 and AC8: a chain of 1,000 mixed receipts verifies; one byte in any + `signed_payload` gives 1; an unknown key id 5; a compromised key 6; a body claiming + another algorithm verifies against the registry's, not the body's; a foreign family is + refused at the scheme string before any signature is checked. + """ + use Trinity.DataCase, async: false + + alias Trinity.Receipts + alias Trinity.Receipts.{Envelope, Signer, Verifier} + + setup do + scope = "verify:" <> Trinity.UUID.generate() + on_exit(fn -> Receipts.stop_writer(scope) end) + {:ok, scope: scope} + end + + defp build(scope, n) do + old = Application.get_env(:trinity, :receipts, []) + + Application.put_env( + :trinity, + :receipts, + Keyword.merge(old, checkpoint_every: 50, checkpoint_after_ms: 60_000) + ) + + try do + for i <- 1..n do + attrs = + case rem(i, 4) do + 0 -> + %{ + kind: "decision", + subject: %{"i" => i}, + decision: %{"outcome" => "allow"}, + fingerprint: "f#{i}" + } + + 1 -> + %{kind: "query", subject: %{"i" => i}} + + 2 -> + %{ + kind: "effect", + subject: %{"i" => i}, + decision: %{"outcome" => "allow"}, + fingerprint: "f#{i}" + } + + 3 -> + %{kind: "query", subject: %{"i" => i}} + end + + {:ok, _} = Receipts.append(scope, attrs) + end + + :ok = Receipts.stop_writer(scope) + {:ok, export} = Receipts.export(scope) + export + after + Application.put_env(:trinity, :receipts, old) + end + end + + defp flip_byte(payload, at) do + <> = payload + <> + end + + test "AC3: 1,000 mixed receipts verify with their checkpoints; one byte in any signed_payload is 1", + %{scope: scope} do + export = build(scope, 1_000) + assert length(export["receipts"]) == 1_000 + assert length(export["checkpoints"]) >= 10 + assert {:ok, %{receipts: 1_000}} = Verifier.verify(export) + + # A byte inside the JSON text of a random row, not on a structural character: still 1, + # because the hash no longer matches, before any signature is looked at. + for seq <- [1, 7, 500, 1_000] do + tampered = + update_in(export["receipts"], fn rows -> + Enum.map(rows, fn r -> + if r["seq"] == seq, do: Map.update!(r, "signed_payload", &flip_byte(&1, 2)), else: r + end) + end) + + assert {:error, 1, reason} = Verifier.verify(tampered) + + assert elem(reason, 0) in [:hash_mismatch, :body_column_mismatch, :body_not_json], + inspect(reason) + + assert Verifier.exit_code({:error, 1, reason}) == 1 + end + end + + test "AC3: an unknown key id is 5 (trust not established); a compromised key is 6", %{ + scope: scope + } do + export = build(scope, 8) + assert {:ok, _} = Verifier.verify(export) + + unknown = Map.put(export, "registry", []) + assert {:error, 5, {:unknown_key_id, 1, _}} = Verifier.verify(unknown) + + compromised = + update_in(export["registry"], fn rows -> + rows ++ + [ + Map.merge(List.last(rows), %{ + "status" => "compromised", + "valid_from" => "2099-01-01T00:00:00Z" + }) + ] + end) + + assert {:error, 6, {:key_compromised, 1, _}} = Verifier.verify(compromised) + end + + test "AC3: a chain with a gap, a wrong prev_hash, or a forged signature is 1", %{scope: scope} do + export = build(scope, 8) + rows = export["receipts"] + + gap = Map.put(export, "receipts", Enum.reject(rows, &(&1["seq"] == 4))) + assert {:error, 1, {:seq_gap, 3, 5}} = Verifier.verify(gap) + + forged = + Map.put( + export, + "receipts", + Enum.map(rows, fn r -> + if r["kind"] == "decision", + do: Map.put(r, "signature_b64", Base.encode64(:crypto.strong_rand_bytes(64))), + else: r + end) + ) + + assert {:error, 1, {:signature_invalid, _}} = Verifier.verify(forged) + end + + test "AC8: the algorithm comes from the registry, not the body; a foreign family is refused at the scheme string", + %{scope: scope} do + export = build(scope, 4) + [row | _] = export["registry"] + # The chain's family is the selection's (Ed25519 here, P-384 on the fips leg); the foreign + # family is the other one. P-384 signs everywhere; Ed25519 does not in FIPS mode, so the + # half that needs a real foreign signature runs only where its signer is available. + %{algorithm: here} = Trinity.Receipts.KeyCustody.selected() + here_s = Atom.to_string(here) + assert row["algorithm"] == here_s + foreign = if here == :ed25519, do: :p384, else: :ed25519 + foreign_impl = Signer.impl(foreign) + foreign_scheme = foreign_impl.scheme() + here_scheme = Signer.impl(here).scheme() + + # Mutant 1 (the registry lookup dropped): a receipt whose scheme names the foreign family + # while its key's registry row says the chain's must be refused for the family mismatch, + # before any signature is checked; a verifier that read the algorithm from the receipt + # would try the foreign family and report a signature failure instead. The reason names + # the refusal, so the two are distinguishable. + claimed = + update_in(export["receipts"], fn rows -> + Enum.map(rows, fn r -> + if r["seq"] == 1, do: Map.put(r, "scheme", foreign_scheme), else: r + end) + end) + + assert {:error, 1, {:scheme_family_mismatch, 1, ^foreign_scheme, ^here_s}} = + Verifier.verify(claimed) + + # The same with the body and the hash rewritten to agree with the column (an attacker who + # controls the row controls all three): the registry row's family still refuses the scheme. + rebuilt = + update_in(export["receipts"], fn rows -> + Enum.map(rows, fn r -> + if r["seq"] == 1 do + body = r["signed_payload"] |> JSON.decode!() |> Map.put("scheme", foreign_scheme) + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.receipt_type(foreign_scheme), payload) + + %{ + r + | "scheme" => foreign_scheme, + "signed_payload" => payload, + "receipt_hash" => Envelope.hash(bytes) + } + else + r + end + end) + end) + + assert {:error, 1, {:scheme_family_mismatch, 1, ^foreign_scheme, ^here_s}} = + Verifier.verify(rebuilt) + + # Mutant 2 (the scheme check dropped): a receipt of the foreign family with its own key in + # the registry, presented to a verifier told to accept only the chain's family, is refused + # at the scheme string. Where the foreign signer is available the same row, with all three + # schemes allowed, is a valid row on its own; where it is not (Ed25519 in FIPS mode) the + # row carries a signature that cannot be made here and only the refusal is asserted. + {pub, sig_fun} = + if foreign_impl.available?() do + {pub, priv} = foreign_impl.generate_key() + {pub, fn bytes -> foreign_impl.sign(bytes, priv) end} + else + {:crypto.strong_rand_bytes(32), fn _ -> :crypto.strong_rand_bytes(64) end} + end + + jwk = foreign_impl.jwk(pub) + + foreign_id = + if jwk, + do: Signer.thumbprint(jwk), + else: Base.url_encode64(:crypto.hash(:sha256, pub), padding: false) + + foreign_row = %{ + "key_id" => foreign_id, + "algorithm" => Atom.to_string(foreign), + "scheme" => foreign_scheme, + "jwk" => jwk, + "public_key_b64" => Base.encode64(pub), + "status" => "active", + "valid_from" => "2026-09-20T00:00:00Z", + "kid_scheme" => "rfc7638" + } + + body = %{ + "scheme" => foreign_scheme, + "seq" => 1, + "chain_scope" => "other", + "prev_hash" => nil, + "kind" => "decision", + "subject" => %{}, + "decision" => %{"outcome" => "allow"}, + "fingerprint" => nil, + "at" => "2026-09-20T00:00:00Z", + "key_id" => foreign_id + } + + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.receipt_type(foreign_scheme), payload) + + foreign_export = %{ + "receipts" => [ + %{ + "chain_scope" => "other", + "seq" => 1, + "prev_hash" => nil, + "receipt_hash" => Envelope.hash(bytes), + "scheme" => foreign_scheme, + "kind" => "decision", + "signed_payload" => payload, + "signature_b64" => Base.encode64(sig_fun.(bytes)), + "key_id" => foreign_id, + "meta" => %{} + } + ], + "checkpoints" => [], + "registry" => [foreign_row] + } + + if foreign_impl.available?(), + do: assert({:ok, %{receipts: 1}} = Verifier.verify(foreign_export)) + + assert {:error, 1, {:scheme_not_allowed, 1, ^foreign_scheme}} = + Verifier.verify(foreign_export, schemes: [here_scheme]) + end + + test "coverage: a query receipt no checkpoint covers is 1 unless the caller waives coverage", %{ + scope: scope + } do + export = build(scope, 8) + assert {:ok, _} = Verifier.verify(export) + uncovered = Map.put(export, "checkpoints", []) + assert {:error, 1, {:query_receipts_uncovered, [1, 3, 5, 7]}} = Verifier.verify(uncovered) + assert {:ok, _} = Verifier.verify(uncovered, require_coverage: false) + end +end diff --git a/test/trinity/repo_config_test.exs b/test/trinity/repo_config_test.exs index d5ba04b..8d3ad7a 100644 --- a/test/trinity/repo_config_test.exs +++ b/test/trinity/repo_config_test.exs @@ -44,10 +44,29 @@ defmodule Trinity.RepoConfigTest do end end - describe "the receipts repo slot" do - test "is declared, uses the same adapter, and is not running" do + describe "the receipts repo" do + # Slice 010 asserted the slot was declared and not running; slice 024 starts it. The + # 010 assertion is superseded here, not kept beside a contradicting one. + test "is running on the same adapter, in its own file, with synchronous full and one connection" do assert Trinity.Repo.Receipts.__adapter__() == Trinity.Repo.__adapter__() - assert Process.whereis(Trinity.Repo.Receipts) == nil + assert is_pid(Process.whereis(Trinity.Repo.Receipts)) + assert Trinity.Repo.Receipts.config()[:database] != Trinity.Repo.config()[:database] + assert Trinity.Repo.Receipts.config()[:pool_size] == 1 + assert %{rows: [["wal"]]} = Trinity.Repo.Receipts.query!("PRAGMA journal_mode") + # 2 is FULL. + assert %{rows: [[2]]} = Trinity.Repo.Receipts.query!("PRAGMA synchronous") + end + + test "its migrations are its own: the receipts tables exist there and not in the primary" do + assert %{rows: [[1]]} = + Trinity.Repo.Receipts.query!( + "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'receipts'" + ) + + assert %{rows: [[0]]} = + Trinity.Repo.query!( + "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'receipts'" + ) end end end diff --git a/test/trinity/sessions/units_test.exs b/test/trinity/sessions/units_test.exs index c72b3f0..887a6b1 100644 --- a/test/trinity/sessions/units_test.exs +++ b/test/trinity/sessions/units_test.exs @@ -53,7 +53,9 @@ defmodule Trinity.Sessions.UnitsTest do describe "CorePolicy.hash/0" do test "is a 64-hex sha-256 over the named modules, stable across calls" do - assert CorePolicy.modules() == [ + # Slice 012's five lead the list; slice 024 appends the modules that decide whether an + # effect happens (test/trinity/effects/boot_receipt_test.exs asserts those by name). + assert Enum.take(CorePolicy.modules(), 5) == [ Trinity.Sessions.Session, Caps, Trinity.Sessions.ToolRunner, diff --git a/test/trinity/tools/catalog_census_test.exs b/test/trinity/tools/catalog_census_test.exs index c6fdb09..58d9454 100644 --- a/test/trinity/tools/catalog_census_test.exs +++ b/test/trinity/tools/catalog_census_test.exs @@ -3,7 +3,7 @@ defmodule Trinity.Tools.CatalogCensusTest do @moduledoc """ Slice 020 AC8: the effect catalog is derived from the tree, and no path other than the - module attribute in `Trinity.Effects.Catalog` admits a `:catalog` tool. + module attribute in `Trinity.Tools.Catalog` admits a `:catalog` tool. The population is every module loaded from this application's `.beam` files (and the test support ones) that implements `Trinity.Tools.Tool`: derived, not listed by hand. The @@ -13,9 +13,9 @@ defmodule Trinity.Tools.CatalogCensusTest do """ use ExUnit.Case, async: false - alias Trinity.Effects.Catalog alias Trinity.TestTools.{CatalogClaimer, CatalogClaimerCore} alias Trinity.Tools + alias Trinity.Tools.Catalog alias Trinity.Tools.Registry defp tool_modules do diff --git a/test/trinity/tools/units_test.exs b/test/trinity/tools/units_test.exs index db24547..5308bb4 100644 --- a/test/trinity/tools/units_test.exs +++ b/test/trinity/tools/units_test.exs @@ -53,7 +53,12 @@ defmodule Trinity.Tools.UnitsTest do Code.ensure_loaded!(Tools.Runner) assert function_exported?(Tools.Runner, :run, 2) assert function_exported?(Tools.Runner, :run_all, 2) - assert Trinity.Sessions.ToolRunner.impl() == Tools.Runner + # Slice 024: the implementation in force is the membrane's runner, which delegates the + # lookup, validation and concurrency to Tools.Runner and supplies the executor. + assert Trinity.Sessions.ToolRunner.impl() == Trinity.Effects.Runner + Code.ensure_loaded!(Trinity.Effects.Runner) + assert function_exported?(Trinity.Effects.Runner, :run, 2) + assert function_exported?(Trinity.Effects.Runner, :run_all, 2) end describe "surface_diff/1" do diff --git a/test/trinity_web/live/receipts_live_test.exs b/test/trinity_web/live/receipts_live_test.exs new file mode 100644 index 0000000..0bc3344 --- /dev/null +++ b/test/trinity_web/live/receipts_live_test.exs @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.ReceiptsLiveTest do + @moduledoc "Slice 024: the receipts pages read the chain and verify it; they write nothing." + use TrinityWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Trinity.Effects + alias Trinity.Permissions + alias Trinity.Receipts + alias Trinity.Tools.Context + + @note_args %{"path" => "/home/me/notes/a.md", "text" => "hi"} + + setup do + session = Trinity.Factory.session!() + scope = Receipts.session_scope(session.id) + {:ok, rule} = Permissions.put_rule(%{tool: "write_note", pattern: "*", decision: "allow"}) + + on_exit(fn -> + Permissions.revoke_rule(rule.id) + Receipts.stop_writer(scope) + end) + + {:ok, session: session, scope: scope} + end + + test "a session's chain: the rows in order, signed or checkpointed, and verify runs to exit 0", + %{conn: conn, session: session} do + ctx = %Context{session_id: session.id, caller: session.id} + + [{_, {:ok, _, _}}, {_, {:ok, _, _}}] = + Effects.Runner.run_all( + [ + %{id: "c1", name: "echo", args: %{"text" => "hi"}}, + %{id: "c2", name: "write_note", args: @note_args} + ], + ctx + ) + + {:ok, view, html} = live(conn, ~p"/s/#{session.id}/receipts") + assert html =~ "session:" <> session.id + assert html =~ "5 receipts" + assert has_element?(view, "#receipt-1") + assert has_element?(view, "#receipt-5") + assert html =~ "checkpointed" + assert html =~ "signed" + + html = view |> element("#verify") |> render_click() + assert html =~ "verified: 5 receipts, 0 checkpoints, exit 0" + assert has_element?(view, "#verify-outcome") + end + + test "an empty scope says so", %{conn: conn, session: session} do + {:ok, _view, html} = live(conn, ~p"/s/#{session.id}/receipts") + assert html =~ "Nothing receipted in this scope yet." + end + + test "the chat links to its receipts", %{conn: conn, session: session} do + {:ok, view, _html} = live(conn, ~p"/s/#{session.id}") + assert has_element?(view, "#receipts-link") + end + + test "the boot page shows this run's boot receipt with the authority, the signer and the policy hash, and verifies", + %{conn: conn} do + {:ok, view, html} = live(conn, ~p"/receipts/boot") + boot = Receipts.boot_receipt() + assert has_element?(view, "#boot-receipt") + assert html =~ "Trinity.Authority.Local" + assert html =~ boot.subject["signer"]["key_id"] + assert html =~ boot.meta["core_policy_hash"] + assert html =~ boot.receipt_hash + html = view |> element("#verify") |> render_click() + assert html =~ "verified: " + assert html =~ "exit 0" + end +end