From 95b36607a4f291fbf538c2d6c9a309b0a28d2af4 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:40:21 -0400 Subject: [PATCH 01/19] docs(s024): G1 plan with the signing and insert costs measured, and the slice opens ROADMAP row 024 to in_progress. NOTES.md records the signing costs of Ed25519, P-384 and ML-DSA-87 on this machine and on the 003 image (DER sizes, not the table's 96), the exqlite insert costs that set the receipts file to synchronous full and the query checkpoint at N=100 or T=5 s, the signed field set proposed for the R21 review with the default the slice names, the ten-line build plan, the empty manual queue, and four deviations stated before any code. plan_check: PASS. Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- .../NOTES.md | 126 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 slices/024-effect-catalog-authority-modes-receipts/NOTES.md diff --git a/ROADMAP.md b/ROADMAP.md index 9f171cf..771792f 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 | in_progress | | 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/slices/024-effect-catalog-authority-modes-receipts/NOTES.md b/slices/024-effect-catalog-authority-modes-receipts/NOTES.md new file mode 100644 index 0000000..5e15431 --- /dev/null +++ b/slices/024-effect-catalog-authority-modes-receipts/NOTES.md @@ -0,0 +1,126 @@ +# Slice 024: NOTES + +## Measurements before any code + +All on 2026-09-20. Signing through `:crypto.sign/4` over a 512-byte payload, 2,000 iterations after a warm-up, +`:timer.tc`, no Benchee (a dependency added mid-slice needs VERSIONS.md approval, and the measurement does not +need one). + +| algorithm | where | signature bytes | sign µs | verify µs | signs per second | +|---|---|---|---|---|---| +| Ed25519 | this machine, OpenSSL 3.0.13 | 64 | 45.9 | 54.7 | 21,785 | +| ECDSA P-384 SHA-384 | this machine, OpenSSL 3.0.13 | 104 (DER) | 453.6 | 383.8 | 2,204 | +| Ed25519 | the 003 image, OpenSSL 3.5.8, mode off | 64 | 50.7 | 51.3 | 19,713 | +| ECDSA P-384 SHA-384 | the 003 image, mode off | 102 (DER) | 125.6 | 281.5 | 7,961 | +| ML-DSA-87 | the 003 image, mode off | 4,627 | 905.4 | 171.8 | 1,105 | +| ML-DSA-87 | this machine | unavailable (OpenSSL 3.0.13) | | | | +| ML-DSA-87 | the 003 image, mode on | refused: `{error, {"evp.c", 264}, "Can't make context"}` | | | | + +Two corrections to SLICE.md's table, recorded rather than edited: OTP returns ECDSA signatures DER-encoded, 102 +to 104 bytes, not the 96 raw bytes the table names; and ML-DSA-87 is reachable on the 003 image with the mode +off (UBI 9.8 links OpenSSL 3.5.8), so amendment 6's "compile-conditional, OpenSSL 3.5 or later" is testable on the +`fips` leg's image outside the mode, and refused inside it. OTP's calls: `generate_key(:mldsa87, [])`, +`sign(:mldsa87, :none, msg, priv)`, `verify(:mldsa87, :none, msg, sig, pub)`. + +Insert cost, exqlite 0.x through `Exqlite.Sqlite3` on ext4, a receipt-shaped row (400-byte payload, 64-byte +signature, two 32-byte hashes), 3,000 rows, WAL: + +| synchronous | rows per transaction | µs per row | rows per second | +|---|---|---|---| +| NORMAL | 1 | 20.4 | 48,974 | +| NORMAL | 100 | 6.1 | 164,835 | +| FULL | 1 | 273.7 | 3,654 | +| FULL | 100 | 12.4 | 80,530 | +| FULL | 1000 | 7.9 | 126,802 | + +So the receipts file runs `synchronous: :full`, one row per transaction, at a cost of 274 µs per signed receipt, +which is far under any effect rate this tree produces. For query receipts (amendment 5): a checkpoint every +**N = 100** rows or **T = 5 s**, whichever first, and on shutdown. At N = 100, P-384's per-row share of the +signature is under 5 µs on either machine, below the row's own insert cost, which is the number the amendment +asked for; at the measured rates per-row signing would also have been affordable, and the amendment stands as +the owner accepted it (plan decision 6). + +Slice 003 measured on the leg that `eddsa` sign and verify raise `notsup` in the mode and P-384 signs; that is +AC8's premise, proven there. + +## Legal review (R21): the signed field set, as proposed + +SLICE.md says the `signed_payload` field set goes to the owner before the slice starts, and names a default +until cleared. This slice builds the default and nothing more; clearing it or changing it is an owner decision +that lands as a scheme bump, never as an edit to rows already written. + +Signed bytes (RFC 8785 canonical JSON of this map, in this key order after canonicalisation): +`scheme` (`receipt_v2_ed25519` | `receipt_v2_p384` | `receipt_v2_mldsa87`), `seq`, `chain_scope`, `prev_hash` +(hex, or null for the first row of a scope), `kind`, `subject` (a map of references: `session_id`, `call_id`, +`tool`, `approval_id`, `effect`, as the kind needs), `decision` (for decision and effect receipts: the outcome +and its basis, e.g. `allow` by `once`), `fingerprint` (the call's, hex, or null), `at` (RFC 3339 UTC), `key_id`. + +Unsigned metadata (its own column, `meta`): `core_policy_hash`, `canonicalization_version`, `authority` (the +module in force), the tool definition digest. `receipt_hash` is SHA-256 over the signed bytes; `signature` is +the signer's over the signed bytes themselves, so a verifier holding the bytes and the registry needs nothing +else, and `receipt_hash` is derived (the next row's `prev_hash`). + +## G1 plan, 2026-09-20 + +Tree at `e8733c7` on `main` (003 approved); branch `slice/024-effects-authority-receipts`; ROADMAP row 024 to +`in_progress` in this commit. Each line names its test. + +1. `Trinity.Repo.Receipts` started (its own SQLite file `receipts.db` beside the primary, `synchronous: :full`, + pool 1; on Postgres the same database with `migration_source: "receipts_migrations"`), migration for + `receipts` (docs/05 columns, `signed_payload` as the canonical text, plus `meta`) and `receipt_checkpoints` + (`chain_scope`, `seq`, `receipt_hash`, `signature`, `key_id`, `at`). Test: pragmas read back; unique + `(chain_scope, seq)`. +2. `Trinity.Receipts.Signer` behaviour (`algorithm/0`, `sign/2`, `verify/3`) with `Ed25519`, `P384` and, when + `:mldsa87 in :crypto.supports(:public_keys)`, `MLDSA87`; `Trinity.Receipts.KeyCustody`: selects P-384 when + `:crypto.info_fips() == :enabled`, Ed25519 otherwise, generates the key on first run into + `/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. From 9f646ca36f256140af1f33876249fef41cf80289 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 17:45:59 -0400 Subject: [PATCH 02/19] docs(s024): the design checked against DSSE, RFC 8725, RFC 7638, RFC 5848, C2SP and FIPS 186-5; five G1 amendments A DSSE-style PAE with the scheme as payload type (one key will sign two protocols); the verifier's allowed-algorithm set and the one key, one algorithm rule from RFC 8725; key ids as RFC 7638 thumbprints; checkpoints shaped like RFC 5848 signature blocks with their coverage and the boot they belong to; C2SP's unknown-key and consistency rules as the verifier's and the writer's tests. The linear chain stays, with the Merkle upgrade path recorded, and the P-384 selection stays, with FIPS 186-5's approval of EdDSA noted as OTP's limit rather than the standard's. Signed-off-by: Ayla Croft --- .../NOTES.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/slices/024-effect-catalog-authority-modes-receipts/NOTES.md b/slices/024-effect-catalog-authority-modes-receipts/NOTES.md index 5e15431..4ade932 100644 --- a/slices/024-effect-catalog-authority-modes-receipts/NOTES.md +++ b/slices/024-effect-catalog-authority-modes-receipts/NOTES.md @@ -124,3 +124,66 @@ row, because `receipts` is append-only and never updated (docs/05); "the tail ca "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. From b34cd8faaa60536aa8f6e773ed11f88484d27c29 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:00:11 -0400 Subject: [PATCH 03/19] feat(s024): the receipts repo and file, the signer seam, key custody and the registry Trinity.Repo.Receipts starts in every environment with its own file (synchronous full, its own migrations under priv/repo_receipts, its own migrations table on Postgres); the receipts and receipt_checkpoints tables. Trinity.Receipts.Signer with Ed25519, P384 and MLDSA87; Envelope (RFC 8785 body, DSSE PAE with the scheme as payload type); KeyRegistry (append-only rows, the newest per key id in force); KeyCustody (P-384 when info_fips is enabled, Ed25519 otherwise, ML-DSA-87 by configuration where the runtime has it; the key file 0600 under the keys directory, read on every sign; RFC 7638 thumbprints as key ids). Tests: 10 in signer_test.exs, the repo config test's 010 slot assertion superseded. Signed-off-by: Ayla Croft --- config/config.exs | 23 +- config/dev.exs | 5 + config/runtime.exs | 8 + config/test.exs | 14 ++ lib/trinity/application.ex | 2 + lib/trinity/paths.ex | 23 ++ lib/trinity/receipts/envelope.ex | 37 ++++ lib/trinity/receipts/key_custody.ex | 203 ++++++++++++++++++ lib/trinity/receipts/key_registry.ex | 86 ++++++++ lib/trinity/receipts/signer.ex | 85 ++++++++ lib/trinity/receipts/signer/ed25519.ex | 36 ++++ lib/trinity/receipts/signer/mldsa87.ex | 46 ++++ lib/trinity/receipts/signer/p384.ex | 47 ++++ lib/trinity/repo/receipts.ex | 15 +- .../20260920220000_create_receipts.exs | 55 +++++ test/support/data_case.ex | 9 +- test/test_helper.exs | 1 + test/trinity/receipts/signer_test.exs | 122 +++++++++++ test/trinity/repo_config_test.exs | 25 ++- 19 files changed, 830 insertions(+), 12 deletions(-) create mode 100644 lib/trinity/receipts/envelope.ex create mode 100644 lib/trinity/receipts/key_custody.ex create mode 100644 lib/trinity/receipts/key_registry.ex create mode 100644 lib/trinity/receipts/signer.ex create mode 100644 lib/trinity/receipts/signer/ed25519.ex create mode 100644 lib/trinity/receipts/signer/mldsa87.ex create mode 100644 lib/trinity/receipts/signer/p384.ex create mode 100644 priv/repo_receipts/migrations/20260920220000_create_receipts.exs create mode 100644 test/trinity/receipts/signer_test.exs 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/lib/trinity/application.ex b/lib/trinity/application.ex index 26ad11a..f68bf7a 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -29,6 +29,8 @@ defmodule Trinity.Application do # opened no database file; the reason names the holder's OS pid and mode. {Trinity.DataDir.Lock, dir: lock_dir(), mode: mode()}, 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}, 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/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..c88f10a --- /dev/null +++ b/lib/trinity/receipts/key_custody.ex @@ -0,0 +1,203 @@ +# 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." + @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 + + 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 + + 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..6f83c44 --- /dev/null +++ b/lib/trinity/receipts/key_registry.ex @@ -0,0 +1,86 @@ +# 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()} + + @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." + @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. + """ + @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/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/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/priv/repo_receipts/migrations/20260920220000_create_receipts.exs b/priv/repo_receipts/migrations/20260920220000_create_receipts.exs new file mode 100644 index 0000000..dcb0f34 --- /dev/null +++ b/priv/repo_receipts/migrations/20260920220000_create_receipts.exs @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Repo.Receipts.Migrations.CreateReceipts do + @moduledoc """ + Slice 024. `receipts`, the per-scope hash chain (docs/05, ADR-0013), and + `receipt_checkpoints`, the signed coverage blocks over query receipts (RFC 5848's shape: + the boot they belong to, the first and last seq covered, the tail hash, the signature). + + `signed_payload` is the canonical text the signature covers, not a map: a verifier hashes + the stored bytes and needs no canonicaliser of its own. Nothing here is ever updated or + deleted; `receipt_checkpoints` exists so a checkpoint is a row, never a write onto a tail. + """ + use Ecto.Migration + + def change do + create table(:receipts, primary_key: false) do + add :id, :binary_id, primary_key: true + add :chain_scope, :string, null: false + add :seq, :integer, null: false + add :prev_hash, :string + add :receipt_hash, :string, null: false + add :scheme, :string, null: false + add :kind, :string, null: false + add :signed_payload, :text, null: false + add :signature, :binary + add :key_id, :string + add :subject, :map, null: false, default: %{} + add :subject_ref, :string + add :meta, :map, null: false, default: %{} + add :inserted_at, :utc_datetime_usec, null: false + end + + create unique_index(:receipts, [:chain_scope, :seq]) + create unique_index(:receipts, [:receipt_hash]) + create index(:receipts, [:chain_scope, :kind]) + create index(:receipts, [:subject_ref]) + + create table(:receipt_checkpoints, primary_key: false) do + add :id, :binary_id, primary_key: true + add :chain_scope, :string, null: false + add :boot_receipt_hash, :string + add :first_seq, :integer, null: false + add :last_seq, :integer, null: false + add :tail_hash, :string, null: false + add :scheme, :string, null: false + add :signed_payload, :text, null: false + add :signature, :binary, null: false + add :key_id, :string, null: false + add :reason, :string, null: false + add :inserted_at, :utc_datetime_usec, null: false + end + + create unique_index(:receipt_checkpoints, [:chain_scope, :last_seq]) + end +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/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/receipts/signer_test.exs b/test/trinity/receipts/signer_test.exs new file mode 100644 index 0000000..a0ed271 --- /dev/null +++ b/test/trinity/receipts/signer_test.exs @@ -0,0 +1,122 @@ +# 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) + on_exit(fn -> File.rm_rf!(dir) 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, and in FIPS mode it would be P-384" do + assert :crypto.info_fips() != :enabled + assert {:ok, :ed25519} = 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 + assert {:ok, %{algorithm: :ed25519, 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"] == "ed25519" + assert row["scheme"] == "receipt_v2_ed25519" + 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" + assert KeyRegistry.active_for(rows, :ed25519) == 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/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 From 6dd6a86f0a313f0e302e9545107d150bf0ac2e8f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:11:22 -0400 Subject: [PATCH 04/19] feat(s024): the chain writer, checkpoints, the authority behaviour and Local, the boot receipt, the verifier Trinity.Receipts.ChainWriter: one temporary process per scope under Trinity.Receipts.WriterSupervisor, appends as calls, per-row signing for decision, effect, boot and cap, unsigned chained query rows covered by receipt_checkpoints rows (count, time, shutdown, rehydrate), a tail or checkpoint that does not verify stops the writer with the reason. Trinity.Receipts facade (append, list, export, the boot hash), Alarm (:alarm_handler and telemetry), the insert census with a planted bypass in test support. Trinity.Authority (stage, decide, execute, receipt), Local (the one execute/2 caller for effectful tools), Staged, Selection as a supervised child that refuses the boot by name. Trinity.Effects.Boot writes the boot receipt. Trinity.Receipts.Verifier walks an export. The effect catalog moves to Trinity.Tools.Catalog (deviation e: Effects depends on Tools). Suite: 287 passed. Signed-off-by: Ayla Croft --- lib/trinity.ex | 12 +- lib/trinity/application.ex | 8 + lib/trinity/authority.ex | 64 +++ lib/trinity/authority/local.ex | 38 ++ lib/trinity/authority/selection.ex | 72 ++++ lib/trinity/authority/staged.ex | 44 ++ lib/trinity/effects.ex | 12 + lib/trinity/effects/boot.ex | 64 +++ lib/trinity/receipts.ex | 160 ++++++++ lib/trinity/receipts/alarm.ex | 40 ++ lib/trinity/receipts/chain_writer.ex | 422 ++++++++++++++++++++ lib/trinity/receipts/checkpoint.ex | 46 +++ lib/trinity/receipts/receipt.ex | 59 +++ lib/trinity/receipts/supervisor.ex | 37 ++ lib/trinity/receipts/verifier.ex | 222 ++++++++++ lib/trinity/tools.ex | 2 +- lib/trinity/{effects => tools}/catalog.ex | 7 +- lib/trinity/tools/registry.ex | 4 +- lib/trinity/tools/tool.ex | 2 +- mix.exs | 4 +- test/support/receipts/bypass_inserter.ex | 12 + test/support/tools/catalog_claimer_core.ex | 2 +- test/trinity/receipts/chain_writer_test.exs | 295 ++++++++++++++ test/trinity/receipts/signer_test.exs | 8 +- test/trinity/tools/catalog_census_test.exs | 4 +- 25 files changed, 1627 insertions(+), 13 deletions(-) create mode 100644 lib/trinity/authority.ex create mode 100644 lib/trinity/authority/local.ex create mode 100644 lib/trinity/authority/selection.ex create mode 100644 lib/trinity/authority/staged.ex create mode 100644 lib/trinity/effects.ex create mode 100644 lib/trinity/effects/boot.ex create mode 100644 lib/trinity/receipts.ex create mode 100644 lib/trinity/receipts/alarm.ex create mode 100644 lib/trinity/receipts/chain_writer.ex create mode 100644 lib/trinity/receipts/checkpoint.ex create mode 100644 lib/trinity/receipts/receipt.ex create mode 100644 lib/trinity/receipts/supervisor.ex create mode 100644 lib/trinity/receipts/verifier.ex rename lib/trinity/{effects => tools}/catalog.ex (72%) create mode 100644 test/support/receipts/bypass_inserter.ex create mode 100644 test/trinity/receipts/chain_writer_test.exs diff --git a/lib/trinity.ex b/lib/trinity.ex index 9a1eb75..b04accb 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,10 @@ defmodule Trinity do Permissions, Permissions.Approval, Permissions.Rule, - Effects.Catalog, + Effects, + CorePolicy, + Receipts, + 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 f68bf7a..eb2917c 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -28,6 +28,9 @@ 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, @@ -39,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..d1f4d3c --- /dev/null +++ b/lib/trinity/authority.ex @@ -0,0 +1,64 @@ +# 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 + case Enum.find(@callbacks, fn {f, a} -> not function_exported?(module, f, a) end) do + nil -> :ok + missing -> {:error, {:missing_callback, module, missing}} + end + else + {:error, {:not_loaded, module}} + 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..751567b --- /dev/null +++ b/lib/trinity/authority/selection.ex @@ -0,0 +1,72 @@ +# 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 + module = module_from(value) + + case Trinity.Authority.implemented_by?(module) do + :ok -> {:ok, module} + {:error, reason} -> {:error, reason} + 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; an unknown name is an atom + # that no module answers to, which `implemented_by?/1` reports as not loaded. + defp module_from("Elixir." <> _ = value), do: String.to_atom(value) + defp module_from(value), do: String.to_atom("Elixir." <> value) + + 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/effects.ex b/lib/trinity/effects.ex new file mode 100644 index 0000000..f2c7267 --- /dev/null +++ b/lib/trinity/effects.ex @@ -0,0 +1,12 @@ +# 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. Filled in at G1 + line 6; this file opens the boundary so the boot receipt and the catalog have a home. + """ + use Boundary, + deps: [Trinity, Trinity.Tools, Trinity.Permissions, Trinity.Authority, Trinity.Receipts], + exports: [Boot] +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/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..f57116c --- /dev/null +++ b/lib/trinity/receipts/chain_writer.ex @@ -0,0 +1,422 @@ +# 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() 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) + bytes = Envelope.pae(Envelope.receipt_type(scheme), payload) + hash = Envelope.hash(bytes) + + with {:ok, signature} <- sign_if_needed(kind, bytes) do + row = %Receipt{ + chain_scope: state.scope, + seq: seq, + prev_hash: state.prev_hash, + receipt_hash: hash, + scheme: scheme, + kind: kind, + signed_payload: payload, + signature: signature, + key_id: key_id, + subject: Map.get(attrs, :subject, %{}), + subject_ref: Map.get(attrs, :subject_ref), + meta: Map.get(attrs, :meta, %{}), + inserted_at: at + } + + case Repo.insert(row) do + {:ok, receipt} -> + state = %{state | seq: seq, prev_hash: hash} + {:ok, receipt, track_uncovered(receipt, state)} + + {:error, changeset} -> + {:error, {:insert, changeset.errors}} + end + end + 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() 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) + bytes = Envelope.pae(Envelope.checkpoint_type(scheme), payload) + + case KeyCustody.sign(bytes) do + {:ok, signature} -> + 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, + signature: signature, + key_id: key_id, + reason: reason, + inserted_at: at + } + + 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 + + {:error, why} -> + Trinity.Receipts.Alarm.signer_unavailable(why) + {:error, {:signer_unavailable, why}} + end + 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}}, + {:ok, pub} <- + KeyRegistry.public_key(row) |> ok_or({:error, {:key_without_public, key_id}}) do + {:ok, pub} + 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/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/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..e3a47a8 --- /dev/null +++ b/lib/trinity/receipts/verifier.ex @@ -0,0 +1,222 @@ +# 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 + if r["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 + else + :ok + end + end + end + + # 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/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/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/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/mix.exs b/mix.exs index ee54859..50d5f9d 100644 --- a/mix.exs +++ b/mix.exs @@ -64,7 +64,9 @@ defmodule Trinity.MixProject do def application do [ mod: {Trinity.Application, []}, - extra_applications: [:logger, :runtime_tools] + # Slice 024: :sasl for :alarm_handler, the OTP alarm a signer that cannot sign raises + # (Trinity.Receipts.Alarm), so the failure sounds outside the receipt stream. + extra_applications: [:logger, :runtime_tools, :sasl] ] 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/trinity/receipts/chain_writer_test.exs b/test/trinity/receipts/chain_writer_test.exs new file mode 100644 index 0000000..f1dec6a --- /dev/null +++ b/test/trinity/receipts/chain_writer_test.exs @@ -0,0 +1,295 @@ +# 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"] == "receipt_v2_ed25519" + 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} + assert ChainWriter.whereis(scope) == nil + 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 index a0ed271..488f313 100644 --- a/test/trinity/receipts/signer_test.exs +++ b/test/trinity/receipts/signer_test.exs @@ -13,7 +13,13 @@ defmodule Trinity.Receipts.SignerTest do setup do dir = Path.join(System.tmp_dir!(), "trinity-keys-#{System.unique_integer([:positive])}") File.mkdir_p!(dir) - on_exit(fn -> File.rm_rf!(dir) end) + + # `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 diff --git a/test/trinity/tools/catalog_census_test.exs b/test/trinity/tools/catalog_census_test.exs index c6fdb09..602d2b2 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,7 +13,7 @@ defmodule Trinity.Tools.CatalogCensusTest do """ use ExUnit.Case, async: false - alias Trinity.Effects.Catalog + alias Trinity.Tools.Catalog alias Trinity.TestTools.{CatalogClaimer, CatalogClaimerCore} alias Trinity.Tools alias Trinity.Tools.Registry From 689542a0b7481c08b36400065219818117b46633 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:13:59 -0400 Subject: [PATCH 05/19] feat(s024): the verifier's tests, the export and verify tasks, the standalone script verifier_test.exs: 1,000 mixed receipts verify with their checkpoints; a flipped byte in any signed_payload is 1; an unknown key id 5; a compromised key 6; a gap, a wrong prev_hash and a forged signature are 1; the algorithm comes from the registry row (a body claiming P-384 under an Ed25519 key is refused for the family, before any signature check); a P-384 receipt presented to a verifier told to accept only Ed25519 is refused at the scheme string. mix trinity.receipts.export and mix trinity.receipts.verify with the exit vocabulary. bin/verify_receipt.exs: elixir and :crypto only, run from an empty directory by its test, and an agreement test over six variants against the in-app verifier. Signed-off-by: Ayla Croft --- bin/verify_receipt.exs | 183 +++++++++++++ lib/mix/tasks/trinity.receipts.export.ex | 43 +++ lib/mix/tasks/trinity.receipts.verify.ex | 58 ++++ .../receipts/standalone_verifier_test.exs | 126 +++++++++ test/trinity/receipts/verifier_test.exs | 257 ++++++++++++++++++ 5 files changed, 667 insertions(+) create mode 100755 bin/verify_receipt.exs create mode 100644 lib/mix/tasks/trinity.receipts.export.ex create mode 100644 lib/mix/tasks/trinity.receipts.verify.ex create mode 100644 test/trinity/receipts/standalone_verifier_test.exs create mode 100644 test/trinity/receipts/verifier_test.exs 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/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/test/trinity/receipts/standalone_verifier_test.exs b/test/trinity/receipts/standalone_verifier_test.exs new file mode 100644 index 0000000..711b0a7 --- /dev/null +++ b/test/trinity/receipts/standalone_verifier_test.exs @@ -0,0 +1,126 @@ +# 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", "receipt_v2_p384"]) + + {out, 2} = + System.cmd("elixir", [Path.join(dir, "verify_receipt.exs")], + cd: dir, + stderr_to_stdout: true + ) + + assert out =~ "usage" + 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..c005cc5 --- /dev/null +++ b/test/trinity/receipts/verifier_test.exs @@ -0,0 +1,257 @@ +# 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"] + assert row["algorithm"] == "ed25519" + + # Mutant 1 (the registry lookup dropped): a receipt whose scheme names P-384 while its key's + # registry row says Ed25519 must be refused for the family mismatch, before any signature + # is checked; a verifier that read the algorithm from the receipt would try P-384 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", "receipt_v2_p384"), else: r + end) + end) + + assert {:error, 1, {:scheme_family_mismatch, 1, "receipt_v2_p384", "ed25519"}} = + 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", "receipt_v2_p384") + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.receipt_type("receipt_v2_p384"), payload) + + %{ + r + | "scheme" => "receipt_v2_p384", + "signed_payload" => payload, + "receipt_hash" => Envelope.hash(bytes) + } + else + r + end + end) + end) + + assert {:error, 1, {:scheme_family_mismatch, 1, "receipt_v2_p384", "ed25519"}} = + Verifier.verify(rebuilt) + + # Mutant 2 (the scheme check dropped): a P-384 receipt with a P-384 key in the registry, + # presented to a verifier told to accept only the Ed25519 family, is refused at the + # scheme string; with all three schemes allowed it is a valid row on its own. + {pub, priv} = Signer.P384.generate_key() + jwk = Signer.P384.jwk(pub) + p384_id = Signer.thumbprint(jwk) + + p384_row = %{ + "key_id" => p384_id, + "algorithm" => "p384", + "scheme" => "receipt_v2_p384", + "jwk" => jwk, + "public_key_b64" => Base.encode64(pub), + "status" => "active", + "valid_from" => "2026-09-20T00:00:00Z", + "kid_scheme" => "rfc7638" + } + + body = %{ + "scheme" => "receipt_v2_p384", + "seq" => 1, + "chain_scope" => "other", + "prev_hash" => nil, + "kind" => "decision", + "subject" => %{}, + "decision" => %{"outcome" => "allow"}, + "fingerprint" => nil, + "at" => "2026-09-20T00:00:00Z", + "key_id" => p384_id + } + + payload = Envelope.canonical(body) + bytes = Envelope.pae(Envelope.receipt_type("receipt_v2_p384"), payload) + + foreign = %{ + "receipts" => [ + %{ + "chain_scope" => "other", + "seq" => 1, + "prev_hash" => nil, + "receipt_hash" => Envelope.hash(bytes), + "scheme" => "receipt_v2_p384", + "kind" => "decision", + "signed_payload" => payload, + "signature_b64" => Base.encode64(Signer.P384.sign(bytes, priv)), + "key_id" => p384_id, + "meta" => %{} + } + ], + "checkpoints" => [], + "registry" => [p384_row] + } + + assert {:ok, %{receipts: 1}} = Verifier.verify(foreign) + + assert {:error, 1, {:scheme_not_allowed, 1, "receipt_v2_p384"}} = + Verifier.verify(foreign, schemes: ["receipt_v2_ed25519"]) + 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 From 4b356afd2e92aa7e72b7adac4128084491da9504 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:15:00 -0400 Subject: [PATCH 06/19] test(s024): the authority selection: named refusals, the child spec's start failure, the standalone assertion under local Signed-off-by: Ayla Croft --- test/support/authority/adapters.ex | 24 ++++++ test/trinity/authority/selection_test.exs | 92 +++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 test/support/authority/adapters.ex create mode 100644 test/trinity/authority/selection_test.exs 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/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs new file mode 100644 index 0000000..787bce9 --- /dev/null +++ b/test/trinity/authority/selection_test.exs @@ -0,0 +1,92 @@ +# 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 is owned by a database connection + # (the Postgres job's pool; none under SQLite). Listening sockets have no peer. + peers = + for port <- Port.list(), + {:name, ~c"tcp_inet"} <- [Port.info(port, :name)], + {:ok, _} <- [:inet.peername(port)], + {:connected, pid} <- [Port.info(port, :connected)], + do: {port, initial_call(pid)} + + for {port, call} <- peers do + assert call in [{DBConnection.Connection, :init, 1}, {Postgrex.Protocol, :init, 1}], + "an outbound connection not owned by the database: #{inspect(port)} #{inspect(call)}" + end + end + + defp initial_call(pid) do + case Process.info(pid, :dictionary) do + {:dictionary, d} -> Keyword.get(d, :"$initial_call") + _ -> nil + end + end +end From 85c0cdb5ea5e035a6567c5a9b4d5d1f3b173f8b1 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:23:34 -0400 Subject: [PATCH 07/19] fix(s021): a decision that arrives while the tools still run is postponed, not dropped The gate broadcasts the request from inside the runner, before the runner returns, so an owner can decide before the Session has entered approval_wait; the catch-all clause dropped that decision and the Session waited for one that had happened. Seen once by chance at slice 003's close (its NOTES finding 11, session_flow_test.exs:108) and on every run once slice 024's decision receipt widened the window from microseconds to a fsync. In tool_wait the decided event is postponed; gen_statem redelivers it on entering approval_wait. Signed-off-by: Ayla Croft --- lib/trinity/sessions/session.ex | 9 +++++++++ 1 file changed, 9 insertions(+) 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), From 69cb4e411bcaba4ffadba74c6239e6cb1b2a977f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:23:34 -0400 Subject: [PATCH 08/19] feat(s024): the membrane, its runner as the seam in force, the executor on Tools.Runner Trinity.Effects.execute/2 over a Staged effect: denies with a receipt when the decision is not allow, the effect class is not admitted or a catalog tool is not in the catalog, the fingerprint re-derived from the arguments it holds is not the one the decision bound, or an effect receipt already names the session and call id; then the authority stages and decides, the admission receipt is written (a signer that cannot sign denies, with the alarm), the authority executes, and the outcome receipt follows. Trinity.Effects.Runner is the tool runner in force: it decides through Tools.Runner.decide/3, receipts every decision, runs effect: :none calls directly with a query receipt and sends the rest through the membrane. Tools.Runner takes the executor as a function; its own call_tool/3 refuses an effectful entry by name. Context carries call_id. Suite: 300 passed. Signed-off-by: Ayla Croft --- lib/trinity/effects.ex | 130 +++++++++++++++++++++- lib/trinity/effects/runner.ex | 119 ++++++++++++++++++++ lib/trinity/sessions/tool_runner.ex | 9 +- lib/trinity/tools/context.ex | 7 +- lib/trinity/tools/runner.ex | 114 +++++++++++++------ test/trinity/authority/selection_test.exs | 19 +++- test/trinity/tools/units_test.exs | 7 +- 7 files changed, 357 insertions(+), 48 deletions(-) create mode 100644 lib/trinity/effects/runner.ex diff --git a/lib/trinity/effects.ex b/lib/trinity/effects.ex index f2c7267..dd9e67f 100644 --- a/lib/trinity/effects.ex +++ b/lib/trinity/effects.ex @@ -3,10 +3,134 @@ 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. Filled in at G1 - line 6; this file opens the boundary so the boot receipt and the catalog have a home. + `: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] + 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/runner.ex b/lib/trinity/effects/runner.ex new file mode 100644 index 0000000..13ac64d --- /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(%{name: name, effect: effect} = 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) + + with {:ok, _} <- decision_receipt(scope, entry, ctx, decision, fp, reason) do + case {decision, effect} do + {:allow, :none} -> + result = Runner.call_tool(entry, args, ctx) + query_receipt(scope, entry, ctx, result) + result + + {:allow, _} -> + 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 + ) + + {_, _} -> + {:error, reason} + end + else + {:error, why} -> {:error, {:decision_not_receipted, why}} + end + end + + @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/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/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/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/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index 787bce9..e6d184e 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -17,8 +17,11 @@ defmodule Trinity.Authority.SelectionTest do 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") + 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 @@ -35,9 +38,11 @@ defmodule Trinity.Authority.SelectionTest do 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_raise RuntimeError, + ~r/TRINITY_AUTHORITY refused: module Trinity.TestAuthority.Partial does not implement execute\/3/, + fn -> + Selection.boot!() + end assert Selection.selected() == before @@ -63,7 +68,9 @@ defmodule Trinity.Authority.SelectionTest do 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)), + Trinity.Authority in List.flatten( + Keyword.get_values(mod.module_info(:attributes), :behaviour) + ), do: mod assert loaded_adapters == [] 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 From e1461d28d31f258f512fe3c69eb6a19f893a7b57 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:25:05 -0400 Subject: [PATCH 09/19] 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 Signed-off-by: Ayla Croft --- test/support/effects/bypass.ex | 11 ++ test/trinity/effects/census_test.exs | 59 ++++++ test/trinity/effects/membrane_test.exs | 261 +++++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 test/support/effects/bypass.ex create mode 100644 test/trinity/effects/census_test.exs create mode 100644 test/trinity/effects/membrane_test.exs 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/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 From a44d820fbf6868b8fa0fa23a6ff24e953ec5200c Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:25:53 -0400 Subject: [PATCH 10/19] feat(s024): CorePolicy covers the modules that decide; the boot receipt's tests (AC6) Signed-off-by: Ayla Croft --- lib/trinity/core_policy.ex | 27 ++++++++- test/trinity/effects/boot_receipt_test.exs | 69 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 test/trinity/effects/boot_receipt_test.exs diff --git a/lib/trinity/core_policy.ex b/lib/trinity/core_policy.ex index e584954..baeca46 100644 --- a/lib/trinity/core_policy.ex +++ b/lib/trinity/core_policy.ex @@ -8,12 +8,29 @@ 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." @@ -22,8 +39,12 @@ defmodule Trinity.CorePolicy do @doc "SHA-256, hex, over the concatenated 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' object code (the AC6 test plants its own)." + @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 diff --git a/test/trinity/effects/boot_receipt_test.exs b/test/trinity/effects/boot_receipt_test.exs new file mode 100644 index 0000000..27fa31b --- /dev/null +++ b/test/trinity/effects/boot_receipt_test.exs @@ -0,0 +1,69 @@ +# 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" + assert r.subject["signer"]["algorithm"] == "ed25519" + assert r.subject["signer"]["key_id"] == Receipts.KeyCustody.selected().key_id + assert r.subject["fips"] == "not_supported" + 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 From 3d6fc93dfb0d93a5c8b39a95f67c0c109a43ba7a Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:28:24 -0400 Subject: [PATCH 11/19] feat(s024): the receipts pages: a session's chain and the boot receipt, with verify TrinityWeb.ReceiptsLive at /s/:id/receipts and /receipts/boot: the rows in seq order with kind, subject, decision, signed or checkpointed, hash prefix; the checkpoints; a verify button running the verifier over the scope's export with the exit code shown. The chat's bar links to its session's receipts. Four LiveView tests. Signed-off-by: Ayla Croft --- lib/trinity.ex | 4 + lib/trinity_web/live/receipts_live.ex | 262 +++++++++++++++++++ lib/trinity_web/live/session_live/show.ex | 8 + lib/trinity_web/router.ex | 3 + test/trinity/effects/boot_receipt_test.exs | 19 +- test/trinity_web/live/receipts_live_test.exs | 78 ++++++ 6 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 lib/trinity_web/live/receipts_live.ex create mode 100644 test/trinity_web/live/receipts_live_test.exs diff --git a/lib/trinity.ex b/lib/trinity.ex index b04accb..0005393 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -35,6 +35,10 @@ defmodule Trinity do Effects, CorePolicy, Receipts, + Receipts.Receipt, + Receipts.Checkpoint, + Receipts.Verifier, + Receipts.KeyCustody, Authority, Content.Part ] ++ 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")} +
{: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 @@ -60,7 +69,11 @@ defmodule Trinity.Effects.BootReceiptTest do 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) + + on_exit(fn -> + File.rm_rf!(d1) + File.rm_rf!(d2) + end) assert h1 == h1_again assert h1 != h2 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 From 71705454a4cbc611cab918d7e6b1472a2d539b3c Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:37:03 -0400 Subject: [PATCH 12/19] feat(s024): the FIPS half of AC8 for the leg; the policy hash over stripped beams; mode-aware assertions test/fips/receipts_test.exs (tagged :fips): the mode on, Ed25519 unavailable, P-384 selected, the boot receipt naming it, an effect running with P-384 receipts that verify and are refused at the scheme string by an Ed25519-only verifier. CorePolicy hashes beams stripped of debug info and docs: the debug-info chunk renders a large map literal in an order that depends on the compiling VM's atom table, so the boundary test's forced recompile changed the hash mid-suite. The signer, chain writer, boot receipt and 012 unit tests derive their expectations from the selection instead of naming Ed25519. Suite: 317 passed, three runs. Signed-off-by: Ayla Croft --- lib/trinity/core_policy.ex | 15 ++++- test/fips/receipts_test.exs | 63 +++++++++++++++++++++ test/trinity/receipts/chain_writer_test.exs | 6 +- test/trinity/receipts/signer_test.exs | 3 +- test/trinity/sessions/units_test.exs | 4 +- 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 test/fips/receipts_test.exs diff --git a/lib/trinity/core_policy.ex b/lib/trinity/core_policy.ex index baeca46..251d4c8 100644 --- a/lib/trinity/core_policy.ex +++ b/lib/trinity/core_policy.ex @@ -37,17 +37,26 @@ defmodule Trinity.CorePolicy do @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: hash_of(@modules) - @doc "The same digest over any list of loaded modules' object code (the AC6 test plants its own)." + @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/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/trinity/receipts/chain_writer_test.exs b/test/trinity/receipts/chain_writer_test.exs index f1dec6a..27032fe 100644 --- a/test/trinity/receipts/chain_writer_test.exs +++ b/test/trinity/receipts/chain_writer_test.exs @@ -201,7 +201,11 @@ defmodule Trinity.Receipts.ChainWriterTest do ref = Process.monitor(pid) Process.exit(pid, :kill) assert_receive {:DOWN, ^ref, :process, ^pid, :killed} - assert ChainWriter.whereis(scope) == nil + # 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 diff --git a/test/trinity/receipts/signer_test.exs b/test/trinity/receipts/signer_test.exs index 488f313..4020335 100644 --- a/test/trinity/receipts/signer_test.exs +++ b/test/trinity/receipts/signer_test.exs @@ -113,7 +113,8 @@ defmodule Trinity.Receipts.SignerTest do assert {:ok, [^row, ^compromised]} = KeyRegistry.append(dir, compromised) {:ok, rows} = KeyRegistry.read(dir) assert KeyRegistry.lookup(rows, key_id)["status"] == "compromised" - assert KeyRegistry.active_for(rows, :ed25519) == nil + {: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 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, From c583ef3198acc3adf7babfad9a337bf63dfb9026 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:38:23 -0400 Subject: [PATCH 13/19] docs(s024): 01, 05, 07 and ADR-0013 as built Signed-off-by: Ayla Croft --- docs/01-architecture.md | 35 +++++++++++++++++----------- docs/05-data-model.md | 21 ++++++++++++++++- docs/07-security-model.md | 28 ++++++++++++++++++++++ docs/adr/0013-receipt-chain-owner.md | 5 ++++ 4 files changed, 75 insertions(+), 14 deletions(-) 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`. From a0ef284f5115418084f0901c9a2830e15e133682 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:41:34 -0400 Subject: [PATCH 14/19] refactor(s024): credo's seven findings and sobelow's nine Flatter functions in the chain writer, the verifier, the authority and the runner; the authority selection makes no atom from the environment's text (String.to_existing_atom, an absent module reported by its text); inline sobelow skips with their reasons on the key custody and registry file operations, whose paths are the keys directory plus constants. Gate exit 0: 317 passed, credo and sobelow clean, plan_check PASS. Signed-off-by: Ayla Croft --- lib/trinity/authority.ex | 16 +- lib/trinity/authority/selection.ex | 28 ++- lib/trinity/effects/runner.ex | 58 +++--- lib/trinity/receipts/chain_writer.ex | 210 +++++++++++---------- lib/trinity/receipts/key_custody.ex | 9 + lib/trinity/receipts/key_registry.ex | 8 + lib/trinity/receipts/verifier.ex | 18 +- test/trinity/tools/catalog_census_test.exs | 2 +- 8 files changed, 199 insertions(+), 150 deletions(-) diff --git a/lib/trinity/authority.ex b/lib/trinity/authority.ex index d1f4d3c..3ee9d05 100644 --- a/lib/trinity/authority.ex +++ b/lib/trinity/authority.ex @@ -52,13 +52,15 @@ defmodule Trinity.Authority do :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 - case Enum.find(@callbacks, fn {f, a} -> not function_exported?(module, f, a) end) do - nil -> :ok - missing -> {:error, {:missing_callback, module, missing}} - end - else - {:error, {:not_loaded, module}} + 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/selection.ex b/lib/trinity/authority/selection.ex index 751567b..54b9571 100644 --- a/lib/trinity/authority/selection.ex +++ b/lib/trinity/authority/selection.ex @@ -44,11 +44,15 @@ defmodule Trinity.Authority.Selection do def select("local"), do: {:ok, Trinity.Authority.Local} def select(value) when is_binary(value) do - module = module_from(value) + case module_from(value) do + :"Elixir.Trinity.Authority.Unknown" -> + {:error, {:not_loaded, value}} - case Trinity.Authority.implemented_by?(module) do - :ok -> {:ok, module} - {:error, reason} -> {:error, reason} + module -> + case Trinity.Authority.implemented_by?(module) do + :ok -> {:ok, module} + {:error, reason} -> {:error, reason} + end end end @@ -60,11 +64,19 @@ defmodule Trinity.Authority.Selection do @spec env() :: String.t() def env, do: @env - # "Elixir.Foo.Bar" and "Foo.Bar" both name the Elixir module; an unknown name is an atom - # that no module answers to, which `implemented_by?/1` reports as not loaded. - defp module_from("Elixir." <> _ = value), do: String.to_atom(value) - defp module_from(value), do: String.to_atom("Elixir." <> value) + # "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}}), diff --git a/lib/trinity/effects/runner.ex b/lib/trinity/effects/runner.ex index 13ac64d..49026e6 100644 --- a/lib/trinity/effects/runner.ex +++ b/lib/trinity/effects/runner.ex @@ -30,7 +30,7 @@ defmodule Trinity.Effects.Runner do @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(%{name: name, effect: effect} = entry, args, %Context{} = ctx) do + def execute(entry, args, %Context{} = ctx) do {decision, fp, reason} = case Runner.decide(entry, args, ctx) do {:allow, fp} -> {:allow, fp, nil} @@ -40,38 +40,38 @@ defmodule Trinity.Effects.Runner do scope = scope(ctx) - with {:ok, _} <- decision_receipt(scope, entry, ctx, decision, fp, reason) do - case {decision, effect} do - {:allow, :none} -> - result = Runner.call_tool(entry, args, ctx) - query_receipt(scope, entry, ctx, result) - result - - {:allow, _} -> - 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 - ) - - {_, _} -> - {:error, reason} - end - else + 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") diff --git a/lib/trinity/receipts/chain_writer.ex b/lib/trinity/receipts/chain_writer.ex index f57116c..e1e5b32 100644 --- a/lib/trinity/receipts/chain_writer.ex +++ b/lib/trinity/receipts/chain_writer.ex @@ -166,53 +166,59 @@ defmodule Trinity.Receipts.ChainWriter do unless kind in Receipt.kinds(), do: raise(ArgumentError, "unknown receipt kind #{inspect(kind)}") - with {:ok, %{scheme: scheme, key_id: key_id}} <- selection() 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) - bytes = Envelope.pae(Envelope.receipt_type(scheme), payload) - hash = Envelope.hash(bytes) - - with {:ok, signature} <- sign_if_needed(kind, bytes) do - row = %Receipt{ - chain_scope: state.scope, - seq: seq, - prev_hash: state.prev_hash, - receipt_hash: hash, - scheme: scheme, - kind: kind, - signed_payload: payload, - signature: signature, - key_id: key_id, - subject: Map.get(attrs, :subject, %{}), - subject_ref: Map.get(attrs, :subject_ref), - meta: Map.get(attrs, :meta, %{}), - inserted_at: at - } - - case Repo.insert(row) do - {:ok, receipt} -> - state = %{state | seq: seq, prev_hash: hash} - {:ok, receipt, track_uncovered(receipt, state)} - - {:error, changeset} -> - {:error, {:insert, changeset.errors}} - end - end + 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 @@ -273,53 +279,65 @@ defmodule Trinity.Receipts.ChainWriter do 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() 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) - bytes = Envelope.pae(Envelope.checkpoint_type(scheme), payload) + 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 - case KeyCustody.sign(bytes) do - {:ok, signature} -> - 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, - signature: signature, - key_id: key_id, - reason: reason, - inserted_at: at - } - - 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 - - {:error, why} -> - Trinity.Receipts.Alarm.signer_unavailable(why) - {:error, {:signer_unavailable, why}} - 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 @@ -395,10 +413,8 @@ defmodule Trinity.Receipts.ChainWriter do end with {:ok, rows} <- KeyRegistry.read(dir), - %{} = row <- KeyRegistry.lookup(rows, key_id) || {:error, {:unknown_key_id, key_id}}, - {:ok, pub} <- - KeyRegistry.public_key(row) |> ok_or({:error, {:key_without_public, key_id}}) do - {:ok, pub} + %{} = 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 diff --git a/lib/trinity/receipts/key_custody.ex b/lib/trinity/receipts/key_custody.ex index c88f10a..8725aba 100644 --- a/lib/trinity/receipts/key_custody.ex +++ b/lib/trinity/receipts/key_custody.ex @@ -36,6 +36,9 @@ defmodule Trinity.Receipts.KeyCustody do } @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 @@ -151,6 +154,9 @@ defmodule Trinity.Receipts.KeyCustody do 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) @@ -189,6 +195,9 @@ defmodule Trinity.Receipts.KeyCustody do 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), diff --git a/lib/trinity/receipts/key_registry.ex b/lib/trinity/receipts/key_registry.ex index 6f83c44..a822cf2 100644 --- a/lib/trinity/receipts/key_registry.ex +++ b/lib/trinity/receipts/key_registry.ex @@ -16,6 +16,8 @@ defmodule Trinity.Receipts.KeyRegistry do @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." @@ -23,6 +25,9 @@ defmodule Trinity.Receipts.KeyRegistry do 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 @@ -46,6 +51,9 @@ defmodule Trinity.Receipts.KeyRegistry do 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 diff --git a/lib/trinity/receipts/verifier.ex b/lib/trinity/receipts/verifier.ex index e3a47a8..75587a3 100644 --- a/lib/trinity/receipts/verifier.ex +++ b/lib/trinity/receipts/verifier.ex @@ -88,17 +88,19 @@ defmodule Trinity.Receipts.Verifier do 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 - if r["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 - else - :ok - end + 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 diff --git a/test/trinity/tools/catalog_census_test.exs b/test/trinity/tools/catalog_census_test.exs index 602d2b2..58d9454 100644 --- a/test/trinity/tools/catalog_census_test.exs +++ b/test/trinity/tools/catalog_census_test.exs @@ -13,9 +13,9 @@ defmodule Trinity.Tools.CatalogCensusTest do """ use ExUnit.Case, async: false - alias Trinity.Tools.Catalog alias Trinity.TestTools.{CatalogClaimer, CatalogClaimerCore} alias Trinity.Tools + alias Trinity.Tools.Catalog alias Trinity.Tools.Registry defp tool_modules do From 120ff73dcfeb31007f76165afe5dcc94c91fb19b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:45:15 -0400 Subject: [PATCH 15/19] test(s024): mode-aware expectations for the fips leg; the peer assertion names the owner Run 35542450360: the fips leg selects P-384 and the signer, boot and chain tests named Ed25519 (the earlier edit never landed); the peer assertion found a TLS connection on the runners this machine does not open and said nothing about its owner. Signed-off-by: Ayla Croft --- test/trinity/effects/boot_receipt_test.exs | 7 ++++--- test/trinity/receipts/signer_test.exs | 16 ++++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/test/trinity/effects/boot_receipt_test.exs b/test/trinity/effects/boot_receipt_test.exs index 632c8a2..b16ed64 100644 --- a/test/trinity/effects/boot_receipt_test.exs +++ b/test/trinity/effects/boot_receipt_test.exs @@ -18,9 +18,10 @@ defmodule Trinity.Effects.BootReceiptTest do assert is_binary(sig) assert r.subject["authority"] == "Trinity.Authority.Local" - assert r.subject["signer"]["algorithm"] == "ed25519" - assert r.subject["signer"]["key_id"] == Receipts.KeyCustody.selected().key_id - assert r.subject["fips"] == "not_supported" + 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) diff --git a/test/trinity/receipts/signer_test.exs b/test/trinity/receipts/signer_test.exs index 4020335..4425797 100644 --- a/test/trinity/receipts/signer_test.exs +++ b/test/trinity/receipts/signer_test.exs @@ -58,9 +58,9 @@ defmodule Trinity.Receipts.SignerTest do end describe "selection" do - test "outside FIPS mode the default is Ed25519, and in FIPS mode it would be P-384" do - assert :crypto.info_fips() != :enabled - assert {:ok, :ed25519} = KeyCustody.select() + 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 @@ -77,13 +77,17 @@ defmodule Trinity.Receipts.SignerTest do describe "custody" do test "boot generates the key once (0600), appends its registry row, and a second boot reuses it", %{dir: dir} do - assert {:ok, %{algorithm: :ed25519, key_id: key_id, key_path: path}} = KeyCustody.boot!(dir) + {: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"] == "ed25519" - assert row["scheme"] == "receipt_v2_ed25519" + 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"]) From d6a205416e20427a44cdf4ba5094036c7fee42ed Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:48:40 -0400 Subject: [PATCH 16/19] test(s024): the receipts tests derive the chain's family and the foreign one from the selection; the peer assertion describes the owner Signed-off-by: Ayla Croft --- test/trinity/authority/selection_test.exs | 26 ++++-- test/trinity/receipts/chain_writer_test.exs | 2 +- .../receipts/standalone_verifier_test.exs | 10 +- test/trinity/receipts/verifier_test.exs | 93 ++++++++++++------- 4 files changed, 85 insertions(+), 46 deletions(-) diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index e6d184e..01cff08 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -80,20 +80,28 @@ defmodule Trinity.Authority.SelectionTest do peers = for port <- Port.list(), {:name, ~c"tcp_inet"} <- [Port.info(port, :name)], - {:ok, _} <- [:inet.peername(port)], + {:ok, peer} <- [:inet.peername(port)], {:connected, pid} <- [Port.info(port, :connected)], - do: {port, initial_call(pid)} + do: {port, peer, describe(pid)} - for {port, call} <- peers do + for {port, peer, %{initial_call: call} = who} <- peers do assert call in [{DBConnection.Connection, :init, 1}, {Postgrex.Protocol, :init, 1}], - "an outbound connection not owned by the database: #{inspect(port)} #{inspect(call)}" + "an outbound connection not owned by the database: #{inspect(port)} to #{inspect(peer)} owned by #{inspect(who)}" end end - defp initial_call(pid) do - case Process.info(pid, :dictionary) do - {:dictionary, d} -> Keyword.get(d, :"$initial_call") - _ -> nil - 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), + links: Process.info(pid, :links) + } end end diff --git a/test/trinity/receipts/chain_writer_test.exs b/test/trinity/receipts/chain_writer_test.exs index 27032fe..f791e97 100644 --- a/test/trinity/receipts/chain_writer_test.exs +++ b/test/trinity/receipts/chain_writer_test.exs @@ -75,7 +75,7 @@ defmodule Trinity.Receipts.ChainWriterTest do assert Map.keys(body) |> Enum.sort() == ~w(at chain_scope decision fingerprint key_id kind prev_hash scheme seq subject) - assert body["scheme"] == "receipt_v2_ed25519" + assert body["scheme"] == impl.scheme() end test "concurrent appenders to one scope produce one chain: no two rows share a prev_hash", %{ diff --git a/test/trinity/receipts/standalone_verifier_test.exs b/test/trinity/receipts/standalone_verifier_test.exs index 711b0a7..bf63429 100644 --- a/test/trinity/receipts/standalone_verifier_test.exs +++ b/test/trinity/receipts/standalone_verifier_test.exs @@ -85,7 +85,7 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do assert {"compromised key: " <> _, 6} = run_script(dir, compromised) assert {"invalid: {:scheme_not_allowed, " <> _, 1} = - run_script(dir, export, ["--schemes", "receipt_v2_p384"]) + run_script(dir, export, ["--schemes", other_scheme()]) {out, 2} = System.cmd("elixir", [Path.join(dir, "verify_receipt.exs")], @@ -96,6 +96,14 @@ defmodule Trinity.Receipts.StandaloneVerifierTest do 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) diff --git a/test/trinity/receipts/verifier_test.exs b/test/trinity/receipts/verifier_test.exs index c005cc5..3b77b15 100644 --- a/test/trinity/receipts/verifier_test.exs +++ b/test/trinity/receipts/verifier_test.exs @@ -144,21 +144,30 @@ defmodule Trinity.Receipts.VerifierTest do %{scope: scope} do export = build(scope, 4) [row | _] = export["registry"] - assert row["algorithm"] == "ed25519" - - # Mutant 1 (the registry lookup dropped): a receipt whose scheme names P-384 while its key's - # registry row says Ed25519 must be refused for the family mismatch, before any signature - # is checked; a verifier that read the algorithm from the receipt would try P-384 and - # report a signature failure instead. The reason names the refusal, so the two are - # distinguishable. + # 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", "receipt_v2_p384"), else: r + if r["seq"] == 1, do: Map.put(r, "scheme", foreign_scheme), else: r end) end) - assert {:error, 1, {:scheme_family_mismatch, 1, "receipt_v2_p384", "ed25519"}} = + 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 @@ -167,13 +176,13 @@ defmodule Trinity.Receipts.VerifierTest do 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", "receipt_v2_p384") + body = r["signed_payload"] |> JSON.decode!() |> Map.put("scheme", foreign_scheme) payload = Envelope.canonical(body) - bytes = Envelope.pae(Envelope.receipt_type("receipt_v2_p384"), payload) + bytes = Envelope.pae(Envelope.receipt_type(foreign_scheme), payload) %{ r - | "scheme" => "receipt_v2_p384", + | "scheme" => foreign_scheme, "signed_payload" => payload, "receipt_hash" => Envelope.hash(bytes) } @@ -183,20 +192,33 @@ defmodule Trinity.Receipts.VerifierTest do end) end) - assert {:error, 1, {:scheme_family_mismatch, 1, "receipt_v2_p384", "ed25519"}} = + assert {:error, 1, {:scheme_family_mismatch, 1, ^foreign_scheme, ^here_s}} = Verifier.verify(rebuilt) - # Mutant 2 (the scheme check dropped): a P-384 receipt with a P-384 key in the registry, - # presented to a verifier told to accept only the Ed25519 family, is refused at the - # scheme string; with all three schemes allowed it is a valid row on its own. - {pub, priv} = Signer.P384.generate_key() - jwk = Signer.P384.jwk(pub) - p384_id = Signer.thumbprint(jwk) - - p384_row = %{ - "key_id" => p384_id, - "algorithm" => "p384", - "scheme" => "receipt_v2_p384", + # 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", @@ -205,7 +227,7 @@ defmodule Trinity.Receipts.VerifierTest do } body = %{ - "scheme" => "receipt_v2_p384", + "scheme" => foreign_scheme, "seq" => 1, "chain_scope" => "other", "prev_hash" => nil, @@ -214,35 +236,36 @@ defmodule Trinity.Receipts.VerifierTest do "decision" => %{"outcome" => "allow"}, "fingerprint" => nil, "at" => "2026-09-20T00:00:00Z", - "key_id" => p384_id + "key_id" => foreign_id } payload = Envelope.canonical(body) - bytes = Envelope.pae(Envelope.receipt_type("receipt_v2_p384"), payload) + bytes = Envelope.pae(Envelope.receipt_type(foreign_scheme), payload) - foreign = %{ + foreign_export = %{ "receipts" => [ %{ "chain_scope" => "other", "seq" => 1, "prev_hash" => nil, "receipt_hash" => Envelope.hash(bytes), - "scheme" => "receipt_v2_p384", + "scheme" => foreign_scheme, "kind" => "decision", "signed_payload" => payload, - "signature_b64" => Base.encode64(Signer.P384.sign(bytes, priv)), - "key_id" => p384_id, + "signature_b64" => Base.encode64(sig_fun.(bytes)), + "key_id" => foreign_id, "meta" => %{} } ], "checkpoints" => [], - "registry" => [p384_row] + "registry" => [foreign_row] } - assert {:ok, %{receipts: 1}} = Verifier.verify(foreign) + if foreign_impl.available?(), + do: assert({:ok, %{receipts: 1}} = Verifier.verify(foreign_export)) - assert {:error, 1, {:scheme_not_allowed, 1, "receipt_v2_p384"}} = - Verifier.verify(foreign, schemes: ["receipt_v2_ed25519"]) + 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", %{ From f977b84f4227f91a7a3826192de33f6aabe3d310 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:51:02 -0400 Subject: [PATCH 17/19] test(s024): the outbound-connection assertion is scoped to this application's processes Run 35542784455: on the hosted runners Mix's Hex client holds a TLS connection to hex.pm in the suite's VM (Cloudflare addresses, port 443, opened before the application, no Trinity ancestor); this machine's warm cache never opens one. The census asks about Trinity's connections: owners are resolved with :application.get_application/1 and only :trinity's must be the database's. Signed-off-by: Ayla Croft --- test/trinity/authority/selection_test.exs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index 01cff08..9bde01c 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -75,8 +75,13 @@ defmodule Trinity.Authority.SelectionTest do assert loaded_adapters == [] - # Outbound connections: every TCP port with a peer is owned by a database connection - # (the Postgres job's pool; none under SQLite). Listening sockets have no peer. + # 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)], @@ -84,9 +89,11 @@ defmodule Trinity.Authority.SelectionTest do {:connected, pid} <- [Port.info(port, :connected)], do: {port, peer, describe(pid)} - for {port, peer, %{initial_call: call} = who} <- peers do + 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 not owned by the database: #{inspect(port)} to #{inspect(peer)} owned by #{inspect(who)}" + "an outbound connection of this application not owned by the database: #{inspect(port)} to #{inspect(peer)} owned by #{inspect(who)}" end end @@ -101,7 +108,7 @@ defmodule Trinity.Authority.SelectionTest do initial_call: Keyword.get(d, :"$initial_call"), ancestors: Keyword.get(d, :"$ancestors"), registered: Process.info(pid, :registered_name), - links: Process.info(pid, :links) + app: :application.get_application(pid) } end end From 2ab8d2850612f7e9633a13642eebabf4b7d23ee3 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:58:27 -0400 Subject: [PATCH 18/19] feat(s024): complete slice 024 (effect catalog, authority selection, local receipts) PROOF.md with every criterion's evidence by test name against run 35542904049 (gate, postgres and fips green; the FIPS half of AC8 on the leg: P-384 selected, the boot receipt naming it, an effect running with P-384 receipts that verify). NOTES.md: fourteen findings, four more deviations, follow-ups for 002, 026, 061, 090 and 100. ROADMAP row 024 to done; coverage.tsv row 76.55 %. Gate exit 0 here: 317 passed, 17 excluded, plan_check PASS. Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- coverage.tsv | 1 + .../NOTES.md | 70 ++++++ .../PROOF.md | 226 ++++++++++++++++++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 slices/024-effect-catalog-authority-modes-receipts/PROOF.md diff --git a/ROADMAP.md b/ROADMAP.md index 771792f..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 | in_progress | +| 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/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/slices/024-effect-catalog-authority-modes-receipts/NOTES.md b/slices/024-effect-catalog-authority-modes-receipts/NOTES.md index 4ade932..917d062 100644 --- a/slices/024-effect-catalog-authority-modes-receipts/NOTES.md +++ b/slices/024-effect-catalog-authority-modes-receipts/NOTES.md @@ -187,3 +187,73 @@ Read this date, primary sources only; each amendment names the line of the G1 pl 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..e8f0ba3 --- /dev/null +++ b/slices/024-effect-catalog-authority-modes-receipts/PROOF.md @@ -0,0 +1,226 @@ +# 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 +``` From e1ffbbb4cc8a7955ea7e156d3c9d40c520c66b06 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Sun, 20 Sep 2026 18:58:29 -0400 Subject: [PATCH 19/19] docs(s024): PROOF.md names the closing commit's sha Signed-off-by: Ayla Croft --- slices/024-effect-catalog-authority-modes-receipts/PROOF.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/slices/024-effect-catalog-authority-modes-receipts/PROOF.md b/slices/024-effect-catalog-authority-modes-receipts/PROOF.md index e8f0ba3..4ca1810 100644 --- a/slices/024-effect-catalog-authority-modes-receipts/PROOF.md +++ b/slices/024-effect-catalog-authority-modes-receipts/PROOF.md @@ -224,3 +224,7 @@ b34cd8f feat(s024): the receipts repo and file, the signer seam, key custody and 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.