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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ slice_id percent sha date
061 80.05 c6b6faa 2026-09-22
050 80.31 c01466d 2026-09-22
002 80.31 d65c48c 2026-09-23
025 80.31 2c4fe1f 2026-09-23
103 changes: 103 additions & 0 deletions docs/encryption-at-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!-- SPDX-FileCopyrightText: Sudo Apt Holdings LLC -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
# Encryption at rest

Slice 025. What Trinity encrypts itself, what it leaves to the volume beneath it, why the line is
drawn where it is, and what the volume half costs, measured rather than estimated.

## The split, and why it is not a compromise

**Trinity encrypts, in code, the blobs nothing indexes:** skill files, staged skill changes, and
exported archives. Each gets a fresh 256-bit data key, AES-256-GCM through OTP's `:crypto`, with the
data key wrapped by the custody adapter and stored beside the ciphertext.

**Trinity does not encrypt, in code, anything SQLite indexes.** That is left to the volume.

The reason is not effort. Slice 031 builds full-text search over every past message and slice 032
builds semantic recall; both work by indexing **plaintext** — tokens in an FTS5 index, raw vectors
in an HNSW or pgvector index. Field-level ciphertext in those columns does not degrade search, it
removes it: an index over ciphertext matches nothing a person would search for. A design that
encrypted those fields and kept the feature would have to decrypt the whole corpus per query, which
is slower than the search it replaces and holds the entire plaintext in memory while it runs.

So the line is drawn where the data structure changes, not where the convenience does. Below the
database file, everything is encrypted, including every index. Above it, the blobs no index touches
are encrypted by Trinity, so there is no plaintext blob on disk that Trinity could have encrypted
and did not.

## The volume half: what it costs

Measured 2026-09-23 on the development machine, with `scripts/insert_bench.exs`, which reproduces
the two write patterns this project actually uses rather than a synthetic one:

| pattern | configuration | where it is used |
|---|---|---|
| `receipts` | `synchronous=FULL`, one row per transaction | the signed receipt chain (slice 024), where every receipt must survive a power cut |
| `main` | `synchronous=NORMAL`, batched | everything else (slice 010) |

Both volumes are on the same NVMe device on the same machine, minutes apart.

```
$ elixir scripts/insert_bench.exs /mnt/bench 2000 # LUKS2 / dm-crypt
$ elixir scripts/insert_bench.exs /tmp/plainbench 2000 # the same device, unencrypted
```

| run | encrypted | plain |
|---|---|---|
| 1 | 659.3 µs/row | 322.3 µs/row |
| 2 | 655.7 µs/row | 324.5 µs/row |
| 3 | 628.1 µs/row | 335.7 µs/row |
| **mean** | **647.7 µs/row** | **327.5 µs/row** |

Batched writes, the same three runs: **1.8 µs/row encrypted against 1.6 µs/row plain.**

### What that means

**Encryption roughly doubles the cost of a durability-critical single-row write, and costs
essentially nothing for batched writes.** The whole overhead lands on the `fsync` path, which is
exactly where the receipt chain lives: each signed receipt is one row in one transaction with
`synchronous=FULL`, on purpose, because a receipt that did not survive a power cut is not a receipt.

At 648 µs per receipt a deployment writes about 1,500 receipts a second, which is far above what an
agent doing real work generates. The number is recorded because a regulated deployment will ask what
full-disk encryption costs, and "not much" is not an answer.

### What this measurement is not

- **It is an upper bound, not the floor.** The encrypted volume is a LUKS2 container in a *loopback
file* on the same filesystem, so every write passes through one more filesystem than a real
dm-crypt partition would. A dedicated encrypted partition will cost less than this.
- **It is one machine.** NVMe with hardware AES-NI. A machine without AES acceleration, or with
slower storage, will show a different ratio, and the ratio is what transfers, not the microseconds.
- **It is not a claim about the database's own safety.** It measures write cost, nothing else.

## Setting up the encrypted volume

What a deployment does once. This is the baseline Trinity assumes for everything SQLite indexes.

```sh
sudo apt-get install -y cryptsetup

# a dedicated partition is what production should use; a file-backed container is for measuring
fallocate -l 2G /tmp/luks.img
sudo cryptsetup luksFormat /tmp/luks.img
sudo cryptsetup open /tmp/luks.img trinity-data
sudo mkfs.ext4 /dev/mapper/trinity-data
sudo mkdir -p /mnt/trinity
sudo mount /dev/mapper/trinity-data /mnt/trinity
sudo chown "$USER" /mnt/trinity
```

Then point Trinity's data directory at the mount. The key for the volume is the deployment's to
hold: LUKS with a passphrase, a keyfile on removable media, or TPM-sealed through
`systemd-cryptenroll`. Trinity does not manage it and does not see it, which is the point of
leaving this half to the volume.

## Reproducing the measurement

```sh
elixir scripts/insert_bench.exs <directory> [rows]
```

It prints microseconds per row for both patterns, and names the device and mount it measured, so a
result pasted into a record says what it was taken on.
7 changes: 6 additions & 1 deletion lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ defmodule Trinity do
# boundary writes it), CorePolicy (the boot receipt reads it) and the Receipts, Authority
# and Effects sub-boundaries. Slice 032 exports Memory.Semantic, Memory.Retriever and
# Memory.Embedders.Bumblebee (the memory page's semantic tab and its download action).
# Slice 025 adds `Trinity.Keys`, which is a top-level boundary with `deps: []` of its own so it
# stays extractable. It is listed here because this boundary's code calls into it; the arrow
# points one way and the compiler holds it that way.
use Boundary,
deps: [],
deps: [Trinity.Keys],
exports:
[
Paths,
# Slice 025: the skills staging and the archive export seal through it.
Vault,
Repo,
Repo.Receipts,
UUID,
Expand Down
6 changes: 5 additions & 1 deletion lib/trinity/archive.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ defmodule Trinity.Archive do
try do
with {:ok, staged} <- stage(layout, tmp, keys?),
manifest = Manifest.build(staged, schema_versions(layout), keys?),
:ok <- File.write(Path.join(tmp, Manifest.file_name()), Manifest.encode(manifest)),
:ok <-
File.write(
Path.join(tmp, Manifest.file_name()),
Trinity.Vault.maybe_seal!(Manifest.encode(manifest), :exports)
),
entries = [
{~c"manifest.json", String.to_charlist(Path.join(tmp, Manifest.file_name()))}
| staged_entries(staged)
Expand Down
24 changes: 24 additions & 0 deletions lib/trinity/effects/boot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule Trinity.Effects.Boot do
subject = %{
"authority" => Trinity.Authority.selected_name(),
"signer" => signer_subject(),
"key_custody" => key_custody_subject(),
"otp_release" => List.to_string(:erlang.system_info(:otp_release)),
"fips" => Atom.to_string(:crypto.info_fips()),
"node" => Atom.to_string(node())
Expand Down Expand Up @@ -52,6 +53,29 @@ defmodule Trinity.Effects.Boot do
end
end

# Slice 025 AC1: the boot receipt names the custody adapter and the source it chose, so a reader
# of the chain can tell whether the signing key was held by a passphrase, a systemd credential or
# a TPM without asking anyone. It carries the key id and never key material.
@doc """
What the boot receipt records about key custody. Public so the test that asserts it carries no
key material can call the thing the receipt actually uses rather than a copy of it.
"""
@spec key_custody_subject() :: map()
def key_custody_subject do
case Trinity.Keys.describe() do
%{adapter: adapter, source: source, key_id: key_id, detail: detail} ->
%{
"adapter" => inspect(adapter),
"source" => Atom.to_string(source),
"key_id" => key_id,
"detail" => detail
}

{:error, reason} ->
%{"unavailable" => inspect(reason)}
end
end

defp signer_subject do
case Trinity.Receipts.KeyCustody.selected() do
%{algorithm: a, scheme: s, key_id: k} ->
Expand Down
96 changes: 96 additions & 0 deletions lib/trinity/keys.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Keys do
@moduledoc """
Where key material comes from, and the only place in this tree that touches it (slice 025).

Two consumers sit above this seam and neither knows what is beneath it: the receipt signer
(slice 024), which needs a private key to sign a chain, and this slice's envelope encryption,
which needs a data key wrapped so it can be stored beside the ciphertext it opens.

**Why a behaviour before there is a second adapter.** A deployment that keeps keys in a KMS or
an HSM should be able to say so in configuration rather than in a patch. Shaping that seam after
two consumers have grown their own key handling is a rewrite; shaping it now is four callbacks.
The behaviour is deliberately shaped around a *local* round trip, so that a network adapter later
has to fit a callback that was proven without one, rather than the callbacks being widened to
whatever a particular network service happens to offer.

## The four operations

* `fetch/2` - the named key, created on first use. For a signer: its private key.
* `wrap/2` - seal a data key so it can be written beside the blob it opens.
* `unwrap/2` - open one again.
* `rotate/1` - begin using a new key without losing the ability to open what the old one sealed.

`describe/0` names the adapter and the source in force, for the boot receipt. It returns no key
material and never will: a receipt that carried a key would defeat the chain it is part of.

## What this seam does not promise

It says where key material comes from. It does not make a machine trustworthy: a local adapter
on a machine an attacker already controls protects nothing, and the honest description of the
passphrase source is that it moves the secret from a file to a person's memory and the deployment's
environment. What it does buy, today, is that a later move to a KMS touches this module and
nothing else.
"""

# A top-level boundary, not a sub-boundary of `Trinity`: a sub-boundary inherits its ancestors'
# dependencies unless it says otherwise, so a nested `deps: []` would be a claim the compiler
# never checks. Slice 062 shipped exactly that mistake and this slice does not repeat it.
use Boundary, top_level?: true, deps: [], exports: [Local]

@typedoc "What a key is called. Stable across rotations; the key id beneath it is not."
@type name :: atom() | String.t()

@typedoc "An opaque handle to wrapped key material, safe to write to disk beside a blob."
@type wrapped :: binary()

@typedoc "How the adapter obtained its root key, for the record rather than for the caller."
@type description :: %{
adapter: module(),
source: atom(),
key_id: String.t(),
detail: String.t()
}

@doc "The named key's material, creating it on first use."
@callback fetch(name(), keyword()) :: {:ok, binary()} | {:error, term()}

@doc "Seals key material so it can be stored beside what it opens."
@callback wrap(binary(), keyword()) :: {:ok, wrapped()} | {:error, term()}

@doc "Opens what `wrap/2` sealed, including material sealed by a superseded key."
@callback unwrap(wrapped(), keyword()) :: {:ok, binary()} | {:error, term()}

@doc "Begins using a new root key. What the old one sealed must still open."
@callback rotate(keyword()) :: {:ok, description()} | {:error, term()}

@doc "The adapter and source in force. Never key material."
@callback describe() :: description() | {:error, term()}

@doc "The adapter in force: `config :trinity, :keys, adapter: Mod`, else the local one."
@spec adapter() :: module()
def adapter do
Application.get_env(:trinity, :keys, [])[:adapter] || Trinity.Keys.Local
end

@doc "The named key's material, through the adapter in force."
@spec fetch(name(), keyword()) :: {:ok, binary()} | {:error, term()}
def fetch(name, opts \\ []), do: adapter().fetch(name, opts)

@doc "Seals key material through the adapter in force."
@spec wrap(binary(), keyword()) :: {:ok, wrapped()} | {:error, term()}
def wrap(material, opts \\ []) when is_binary(material), do: adapter().wrap(material, opts)

@doc "Opens sealed key material through the adapter in force."
@spec unwrap(wrapped(), keyword()) :: {:ok, binary()} | {:error, term()}
def unwrap(wrapped, opts \\ []) when is_binary(wrapped), do: adapter().unwrap(wrapped, opts)

@doc "Rotates the root key through the adapter in force."
@spec rotate(keyword()) :: {:ok, description()} | {:error, term()}
def rotate(opts \\ []), do: adapter().rotate(opts)

@doc "The adapter and source in force, for the boot receipt."
@spec describe() :: description() | {:error, term()}
def describe, do: adapter().describe()
end
Loading
Loading