diff --git a/coverage.tsv b/coverage.tsv index 7e2d7d3..d0f7dd6 100644 --- a/coverage.tsv +++ b/coverage.tsv @@ -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 diff --git a/docs/encryption-at-rest.md b/docs/encryption-at-rest.md new file mode 100644 index 0000000..a830322 --- /dev/null +++ b/docs/encryption-at-rest.md @@ -0,0 +1,103 @@ + + +# 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 [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. diff --git a/lib/trinity.ex b/lib/trinity.ex index f41399a..dad1d20 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -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, diff --git a/lib/trinity/archive.ex b/lib/trinity/archive.ex index ad46e2f..f9e8daf 100644 --- a/lib/trinity/archive.ex +++ b/lib/trinity/archive.ex @@ -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) diff --git a/lib/trinity/effects/boot.ex b/lib/trinity/effects/boot.ex index 5364c07..52dfef3 100644 --- a/lib/trinity/effects/boot.ex +++ b/lib/trinity/effects/boot.ex @@ -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()) @@ -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} -> diff --git a/lib/trinity/keys.ex b/lib/trinity/keys.ex new file mode 100644 index 0000000..576deed --- /dev/null +++ b/lib/trinity/keys.ex @@ -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 diff --git a/lib/trinity/keys/local.ex b/lib/trinity/keys/local.ex new file mode 100644 index 0000000..ce0315d --- /dev/null +++ b/lib/trinity/keys/local.ex @@ -0,0 +1,417 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Keys.Local do + @moduledoc """ + Key custody on the machine Trinity runs on (slice 025). + + A single root key protects everything else. Named keys are derived from it, and data keys are + sealed with it, so there is exactly one secret whose loss matters and exactly one place a + deployment has to think about. + + ## Where the root key comes from + + Three sources, tried in this order, first one that answers winning. The order is availability + ascending and protection descending: the strongest custody this machine offers is used, and the + weakest that always works is the floor. + + 1. `:systemd_credential` - the root key is supplied by systemd as an encrypted credential + (`$CREDENTIALS_DIRECTORY`), which on a TPM-bearing host is sealed to that host. Trinity + reads it and never writes it. + 2. `:tpm` - sealed to the machine's TPM through `tpm2-tools`. Needs both the device and the + tooling. + 3. `:passphrase` - derived with PBKDF2-HMAC-SHA512 from a passphrase the deployment supplies + (`TRINITY_KEYS_PASSPHRASE`), with a salt stored beside the data. The floor, and the only + source every machine and every test can exercise. + + **Availability is reported separately from success.** A source that cannot work here says so by + name, and selection moves on; it never falls through silently. That distinction is the whole of + AC1: on the machine this slice was built on the TPM *device* is present while `tpm2-tools` is + not, which is a real absent-source case rather than a simulated one, and the refusal has to name + which half is missing or it tells an operator nothing. + + ## What each source is worth + + An honest ranking, because "TPM-backed" is the sort of phrase that ends up in a procurement + document meaning more than it should. + + * `:tpm` and `:systemd_credential` bind the key to *this machine*, so a copied disk does not + carry a usable key. Neither protects against an attacker who already runs code as this user + on this machine: both will unseal for whoever asks. + * `:passphrase` binds the key to something outside the disk, which is a different property and + not a weaker one, and it depends entirely on where the deployment keeps the passphrase. In a + unit file's environment it is worth very little; entered by a person at start it is worth a + good deal. + + None of them make a compromised host safe, and this module's documentation says so rather than + leaving the reader to infer it. + """ + + @behaviour Trinity.Keys + + Module.register_attribute(__MODULE__, :sobelow_skip, persist: true) + + @sources [:systemd_credential, :tpm, :passphrase] + @credential_name "trinity-root-key" + @root_bytes 32 + @salt_bytes 16 + @pbkdf2_iterations 600_000 + @wrap_version "TKW1" + @selection {__MODULE__, :selection} + + # ── selection ──────────────────────────────────────────────────────────────────────────────── + + @doc """ + The sources this module knows, in the order they are tried. Exposed so a test can assert the + order rather than restate it. + """ + @spec sources() :: [atom()] + def sources, do: @sources + + @doc """ + Whether a source can supply a root key here, and if not, why not by name. + + The reason is the point. `{:unavailable, :tpm, :tooling_absent}` and + `{:unavailable, :tpm, :device_absent}` send an operator to different places. + """ + @spec available?(atom()) :: :ok | {:unavailable, atom(), atom()} + def available?(:systemd_credential) do + case System.get_env("CREDENTIALS_DIRECTORY") do + nil -> + {:unavailable, :systemd_credential, :not_run_under_systemd} + + dir -> + if File.regular?(Path.join(dir, @credential_name)), + do: :ok, + else: {:unavailable, :systemd_credential, :credential_not_supplied} + end + end + + def available?(:tpm) do + device = File.exists?("/dev/tpmrm0") or File.exists?("/dev/tpm0") + tooling = System.find_executable("tpm2_create") != nil + + cond do + not device -> {:unavailable, :tpm, :device_absent} + not tooling -> {:unavailable, :tpm, :tooling_absent} + true -> :ok + end + end + + def available?(:passphrase) do + case passphrase() do + nil -> {:unavailable, :passphrase, :no_passphrase_configured} + "" -> {:unavailable, :passphrase, :empty_passphrase} + _ -> :ok + end + end + + def available?(other), do: {:unavailable, other, :unknown_source} + + @doc """ + The source in force, choosing one if none has been chosen yet. + + Returns `{:ok, source}` or `{:error, {:no_key_source, refusals}}` where `refusals` names every + source and why each declined, because a deployment that cannot start needs the whole list, not + the last failure. + """ + @spec select() :: {:ok, atom()} | {:error, {:no_key_source, [tuple()]}} + def select do + case Application.get_env(:trinity, :keys, [])[:source] do + nil -> first_available() + forced -> forced_source(forced) + end + end + + defp forced_source(forced) do + case available?(forced) do + :ok -> {:ok, forced} + {:unavailable, _, reason} -> {:error, {:no_key_source, [{forced, reason}]}} + end + end + + defp first_available do + Enum.reduce_while(@sources, [], fn source, refused -> + case available?(source) do + :ok -> {:halt, {:ok, source}} + {:unavailable, _, reason} -> {:cont, [{source, reason} | refused]} + end + end) + |> case do + {:ok, source} -> {:ok, source} + refused when is_list(refused) -> {:error, {:no_key_source, Enum.reverse(refused)}} + end + end + + # ── the behaviour ──────────────────────────────────────────────────────────────────────────── + + @impl Trinity.Keys + def describe do + with {:ok, source} <- select(), + {:ok, root} <- root_key(source) do + %{ + adapter: __MODULE__, + source: source, + key_id: key_id(root), + detail: detail(source) + } + end + end + + @impl Trinity.Keys + def fetch(name, _opts \\ []) do + with {:ok, source} <- select(), + {:ok, root} <- root_key(source) do + # Named keys are derived rather than stored, so there is one secret on disk and not one per + # consumer. HKDF-Expand with the name as the info string: a different name cannot produce + # the same bytes, and neither can a different root. + {:ok, derive(root, "trinity/key/" <> to_string(name))} + end + end + + @impl Trinity.Keys + def wrap(material, _opts \\ []) when is_binary(material) do + with {:ok, source} <- select(), + {:ok, root} <- root_key(source) do + kek = derive(root, "trinity/wrap") + iv = :crypto.strong_rand_bytes(12) + aad = @wrap_version <> key_id(root) + + {ct, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, kek, iv, material, aad, true) + {:ok, @wrap_version <> <> <> key_id(root) <> iv <> tag <> ct} + end + end + + @impl Trinity.Keys + def unwrap(wrapped, _opts \\ []) + + def unwrap(@wrap_version <> <>, _opts) do + with <> <- + rest, + {:ok, root} <- root_for(key_id) do + kek = derive(root, "trinity/wrap") + aad = @wrap_version <> key_id + + case :crypto.crypto_one_time_aead(:aes_256_gcm, kek, iv, ct, aad, tag, false) do + :error -> {:error, :unwrap_failed} + plain -> {:ok, plain} + end + else + {:error, reason} -> {:error, reason} + _ -> {:error, :malformed_wrapper} + end + end + + def unwrap(_other, _opts), do: {:error, :malformed_wrapper} + + @impl Trinity.Keys + def rotate(_opts \\ []) do + with {:ok, source} <- select(), + {:ok, old} <- root_key(source) do + new = :crypto.strong_rand_bytes(@root_bytes) + # The superseded key is retained, not replaced. A rotation that cannot open what the old key + # sealed is a rotation that lost data, which is the failure this is shaped to prevent. + with :ok <- retire(old), :ok <- store_root(source, new) do + :persistent_term.erase(@selection) + {:ok, describe()} + end + end + end + + # ── root key material ──────────────────────────────────────────────────────────────────────── + + @doc "The key id of a root key: a digest, never the key." + @spec key_id(binary()) :: String.t() + def key_id(root) when is_binary(root), + do: + :crypto.hash(:sha256, "trinity/key-id" <> root) + |> Base.url_encode64(padding: false) + |> binary_part(0, 16) + + defp detail(:systemd_credential), do: "supplied by systemd as credential #{@credential_name}" + defp detail(:tpm), do: "sealed to this machine's TPM through tpm2-tools" + + defp detail(:passphrase), + do: + "derived from TRINITY_KEYS_PASSPHRASE, PBKDF2-HMAC-SHA512, #{@pbkdf2_iterations} iterations" + + defp passphrase, do: System.get_env("TRINITY_KEYS_PASSPHRASE") + + defp root_key(source) do + case :persistent_term.get(@selection, nil) do + %{source: ^source, root: root} -> + {:ok, root} + + _ -> + with {:ok, root} <- load_or_create(source) do + :persistent_term.put(@selection, %{source: source, root: root}) + {:ok, root} + end + end + end + + # sobelow_skip reason: Traversal.FileModule: every path here is the keys directory plus a + # constant, never input. + @sobelow_skip ["Traversal.FileModule"] + defp load_or_create(:systemd_credential) do + path = Path.join(System.get_env("CREDENTIALS_DIRECTORY", ""), @credential_name) + + case File.read(path) do + {:ok, bin} when byte_size(bin) >= @root_bytes -> {:ok, binary_part(bin, 0, @root_bytes)} + {:ok, _} -> {:error, {:credential_too_short, @credential_name}} + {:error, reason} -> {:error, {:credential_unreadable, reason}} + end + end + + defp load_or_create(:tpm), do: {:error, {:tpm_unsealing_not_built, :slice_025_out_of_scope}} + + # sobelow_skip reason: Traversal.FileModule: this clause touches the salt only, through + # salt_path/0, which is keys_dir/0 plus a constant. No part of it comes from a request. + @sobelow_skip ["Traversal.FileModule"] + defp load_or_create(:passphrase) do + case passphrase() do + nil -> + {:error, {:no_passphrase_configured, "TRINITY_KEYS_PASSPHRASE"}} + + "" -> + {:error, {:empty_passphrase, "TRINITY_KEYS_PASSPHRASE"}} + + pass -> + with {:ok, salt} <- ensure_salt() do + {:ok, :crypto.pbkdf2_hmac(:sha512, pass, salt, @pbkdf2_iterations, @root_bytes)} + end + end + end + + # sobelow_skip reason: Traversal.FileModule: the path is salt_path/0, the keys directory plus + # the constant "root.salt", and the directory is configuration rather than input. + @sobelow_skip ["Traversal.FileModule"] + defp ensure_salt do + path = salt_path() + + case File.read(path) do + {:ok, salt} when byte_size(salt) == @salt_bytes -> + {:ok, salt} + + {:error, :enoent} -> + salt = :crypto.strong_rand_bytes(@salt_bytes) + File.mkdir_p!(Path.dirname(path)) + + with :ok <- File.write(path, salt), :ok <- File.chmod(path, 0o600) do + {:ok, salt} + end + + {:ok, _} -> + {:error, {:salt_wrong_size, path}} + + {:error, reason} -> + {:error, {:salt_unreadable, reason}} + end + end + + @doc "Where the passphrase salt lives. Not a secret; losing it loses the derived key." + @spec salt_path() :: Path.t() + def salt_path, do: Path.join(keys_dir(), "root.salt") + + defp retired_path, do: Path.join(keys_dir(), "retired-roots.json") + + @doc """ + The directory this adapter keeps its salt and retired roots in. + + Configured rather than asked for, because this package declares `deps: []` and means it: it + cannot call into the rest of the tree to find out where keys live, and the compiler refuses the + attempt. The application wires `config :trinity, :keys, dir:` to the same directory the receipt + keys use, so there is still one place keys live; the environment variable is the escape hatch for + a deployment that has not booted the application, and the last fallback keeps a bare test honest + rather than writing to a surprising place. + """ + # sobelow_skip reason: Traversal.FileModule: the directory comes from this application's + # configuration, from an environment variable the operator sets, or from a constant under the + # system temporary directory. None of the three is reachable from a request. + @sobelow_skip ["Traversal.FileModule"] + @spec keys_dir() :: Path.t() + def keys_dir do + dir = + Application.get_env(:trinity, :keys, [])[:dir] || + System.get_env("TRINITY_KEYS_DIR") || + Path.join(System.tmp_dir!(), "trinity-keys") + + File.mkdir_p!(dir) + dir + end + + # A retired root is kept wrapped by nothing: it is the material itself, at 0600, because the + # alternative is a chain of keys each wrapping the last, and losing any link loses everything + # after it. The file is exactly as sensitive as the active key and is documented as such. + # sobelow_skip reason: Traversal.FileModule: the path is retired_path/0, the keys directory + # plus a constant filename; the only caller is rotate/1 and it passes no path. + @sobelow_skip ["Traversal.FileModule"] + defp retire(root) do + rows = read_retired() + + row = %{ + "key_id" => key_id(root), + "material_b64" => Base.encode64(root), + "retired_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + with :ok <- File.write(retired_path(), JSON.encode!([row | rows])) do + File.chmod(retired_path(), 0o600) + end + end + + # sobelow_skip reason: Traversal.FileModule: the path is retired_path/0, a constant under the + # configured keys directory, and this function takes no argument at all. + @sobelow_skip ["Traversal.FileModule"] + defp read_retired do + case File.read(retired_path()) do + {:ok, bin} -> + case JSON.decode(bin) do + {:ok, rows} when is_list(rows) -> rows + _ -> [] + end + + _ -> + [] + end + end + + # sobelow_skip reason: Traversal.FileModule: removes salt_path/0, the keys directory plus a + # constant. The key material argument is ignored by this clause and no path is derived from it. + @sobelow_skip ["Traversal.FileModule"] + defp store_root(:passphrase, _new) do + # A passphrase-derived root cannot be replaced without replacing the passphrase; rotating it + # means a new salt, which is what actually changes the derived bytes. + File.rm(salt_path()) + :persistent_term.erase(@selection) + :ok + end + + defp store_root(source, _new), do: {:error, {:rotation_not_supported_for_source, source}} + + # The active root, or a retired one when the wrapper names a key this root is not. + defp root_for(wanted) do + with {:ok, source} <- select(), + {:ok, root} <- root_key(source) do + if key_id(root) == wanted, do: {:ok, root}, else: retired_root(wanted) + end + end + + defp retired_root(wanted) do + case Enum.find(read_retired(), &(&1["key_id"] == wanted)) do + %{"material_b64" => b64} -> decode_retired(b64, wanted) + nil -> {:error, {:unknown_key_id, wanted}} + end + end + + defp decode_retired(b64, wanted) do + case Base.decode64(b64) do + {:ok, material} -> {:ok, material} + :error -> {:error, {:retired_key_unreadable, wanted}} + end + end + + # HKDF-Expand (RFC 5869) with SHA-256, one block, which is all a 32-byte key needs. + defp derive(root, info) do + :crypto.mac(:hmac, :sha256, root, info <> <<1>>) + end +end diff --git a/lib/trinity/receipts/key_custody.ex b/lib/trinity/receipts/key_custody.ex index 8725aba..7c623e7 100644 --- a/lib/trinity/receipts/key_custody.ex +++ b/lib/trinity/receipts/key_custody.ex @@ -161,6 +161,14 @@ defmodule Trinity.Receipts.KeyCustody do {pub, priv} = impl.generate_key() jwk = impl.jwk(pub) + # Slice 025: the private half is sealed by the key custody seam before it is written, so the + # file on disk is ciphertext and the thing that opens it is whatever the deployment's adapter + # holds. When no key source is available the key is written unsealed rather than not at all: + # refusing to boot because encryption at rest is unconfigured would trade a working signer for + # a stronger one nobody asked for, and the registry row records which of the two it is. + encoded = impl.encode_private(priv) + {private_b64, custody} = seal_private(encoded) + {key_id, kid_scheme} = case jwk do nil -> {:crypto.hash(:sha256, pub) |> Base.url_encode64(padding: false), "sha256-raw"} @@ -177,14 +185,15 @@ defmodule Trinity.Receipts.KeyCustody do "fingerprint" => :crypto.hash(:sha256, pub) |> Base.encode16(case: :lower), "valid_from" => DateTime.utc_now() |> DateTime.to_iso8601(), "status" => "active", - "custody" => "file" + "custody" => custody } file = JSON.encode!(%{ "algorithm" => Atom.to_string(impl.algorithm()), "key_id" => key_id, - "private_b64" => Base.encode64(impl.encode_private(priv)), + "private_b64" => private_b64, + "custody" => custody, "public_b64" => Base.encode64(pub) }) @@ -195,13 +204,30 @@ defmodule Trinity.Receipts.KeyCustody do end end + # Slice 025. The signer holds no key-opening logic of its own: it hands bytes to the seam and + # takes bytes back. Swapping the custody adapter for a KMS changes `Trinity.Keys` and nothing + # here, which is the whole reason the seam exists. + defp seal_private(encoded) do + case Trinity.Keys.wrap(encoded) do + {:ok, wrapped} -> {Base.encode64(wrapped), "sealed"} + {:error, _reason} -> {Base.encode64(encoded), "file"} + end + end + + # A key file written before slice 025 carries no `custody` field and holds the private bytes + # directly. It still opens: a slice that made every existing installation unbootable to gain a + # property would not be an improvement. + defp open_private(stored, %{"custody" => "sealed"}), do: Trinity.Keys.unwrap(stored) + defp open_private(stored, _row), do: {:ok, stored} + # sobelow_skip reason: Traversal.FileModule: `path` is the selection's key path, built by # key_path/2 at boot from the keys directory and the algorithm, never input. @sobelow_skip ["Traversal.FileModule"] defp read_private(path, impl, key_id) do with {:ok, bin} <- File.read(path), - {:ok, %{"private_b64" => b64, "key_id" => ^key_id}} <- JSON.decode(bin), - {:ok, encoded} <- Base.decode64(b64) do + {:ok, %{"private_b64" => b64, "key_id" => ^key_id} = row} <- JSON.decode(bin), + {:ok, stored} <- Base.decode64(b64), + {:ok, encoded} <- open_private(stored, row) do {:ok, impl.decode_private(encoded)} else {:error, :enoent} -> {:error, :signer_unavailable} diff --git a/lib/trinity/skills/staging.ex b/lib/trinity/skills/staging.ex index 412633d..292fc4b 100644 --- a/lib/trinity/skills/staging.ex +++ b/lib/trinity/skills/staging.ex @@ -124,7 +124,10 @@ defmodule Trinity.Skills.Staging do |> Path.join("**") |> Path.wildcard(match_dot: true) |> Enum.filter(&File.regular?/1) - |> Map.new(fn p -> {Path.relative_to(p, dir), File.read!(p)} end) + |> Map.new(fn p -> + {:ok, content} = Trinity.Vault.open(File.read!(p)) + {Path.relative_to(p, dir), content} + end) end @doc "Removes a change's staged files (a rejection, or after a promotion)." @@ -295,7 +298,10 @@ defmodule Trinity.Skills.Staging do for {rel, content} <- tree do path = Path.join(dir, rel) File.mkdir_p!(Path.dirname(path)) - File.write!(path, content) + # Slice 025: staged changes are one of the blob classes the vault can seal. Sealing is off + # unless a deployment turns it on, and `read_tree/1` opens either kind, so this is the only + # place that has to know. + File.write!(path, Trinity.Vault.maybe_seal!(content, :staged_skills)) end :ok diff --git a/lib/trinity/vault.ex b/lib/trinity/vault.ex new file mode 100644 index 0000000..b03d8ec --- /dev/null +++ b/lib/trinity/vault.ex @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Vault do + @moduledoc """ + Envelope encryption for the blobs nothing indexes (slice 025). + + A sealed blob carries its own data key, wrapped by `Trinity.Keys`, so the only thing that needs + protecting is the root the custody adapter holds. Each blob gets a **fresh** data key: reusing + one across blobs would mean a single key compromise opens every one of them, and it costs 32 + bytes of randomness to avoid. + + ## What is sealed, and what is not + + Sealed: skill files, staged skill changes, exported archives. These are the blobs no index + touches. + + Not sealed: anything SQLite indexes. That is not an oversight, and `docs/encryption-at-rest.md` + explains it at length: slices 031 and 032 search by indexing plaintext tokens and raw vectors, + so field-level ciphertext there would not degrade search, it would remove it. Everything under + the database file is the volume's job, and the cost of that is measured rather than assumed. + + ## The format + + "TVLT1" | 1 byte wrapped-key length | wrapped data key | 12-byte IV | 16-byte tag | ciphertext + + Self-describing on purpose. A blob found on disk without this module beside it can still be + identified, and the version prefix means a later format is a new branch here rather than a + guess about what the bytes used to mean. The wrapped key is bound into the additional + authenticated data, so a blob cannot be opened with a data key lifted from a different one. + """ + + alias Trinity.Keys + + @version "TVLT1" + @iv_bytes 12 + @tag_bytes 16 + @key_bytes 32 + + @doc """ + Whether a class of blob is sealed on write. + + Configured per class and **off by default**: + + config :trinity, :vault, seal: [:staged_skills, :exports] + + Off by default because sealing is not free in ways that matter to the person using this. A + sealed export can only be restored where the key is, which is exactly what a regulated + deployment wants and exactly what someone moving their data to a new laptop does not. A sealed + skill file cannot be edited in a text editor. The mechanism is built, tested and available; which + blobs it applies to is the deployment's call rather than this slice's, and `open/1` passes + unsealed blobs through unchanged so the choice can be made later without a migration. + """ + @spec sealing?(atom()) :: boolean() + def sealing?(class) when is_atom(class) do + class in (Application.get_env(:trinity, :vault, [])[:seal] || []) + end + + @doc """ + Seals a blob if its class is configured for sealing, and returns it unchanged if not. + + The call site reads the same either way, which is the point: a path that has to branch on whether + encryption is on is a path where one branch is less tested than the other. + """ + @spec maybe_seal!(binary(), atom()) :: binary() + def maybe_seal!(plaintext, class) when is_binary(plaintext) and is_atom(class) do + if sealing?(class), do: seal!(plaintext), else: plaintext + end + + @doc "The marker a sealed blob starts with, so a caller can tell one without decrypting it." + @spec version() :: binary() + def version, do: @version + + @doc "Whether a binary is a blob this module sealed." + @spec sealed?(binary()) :: boolean() + def sealed?(@version <> _), do: true + def sealed?(_), do: false + + @doc """ + Seals a blob under a fresh data key. + + Returns `{:error, reason}` rather than raising when no key source is available, because a + deployment whose custody adapter cannot start should fail with the adapter's own reason and not + with a match error three frames away. + """ + @spec seal(binary()) :: {:ok, binary()} | {:error, term()} + def seal(plaintext) when is_binary(plaintext) do + data_key = :crypto.strong_rand_bytes(@key_bytes) + + with {:ok, wrapped} <- Keys.wrap(data_key) do + iv = :crypto.strong_rand_bytes(@iv_bytes) + aad = @version <> wrapped + + {ct, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, data_key, iv, plaintext, aad, true) + {:ok, @version <> <> <> wrapped <> iv <> tag <> ct} + end + end + + @doc """ + Opens a blob this module sealed. + + A blob that is not sealed is returned unchanged with `{:ok, blob}`. That is deliberate: it is + what makes the encrypted and unencrypted paths the same code, so a tree written before this + slice keeps working and a deployment that turns encryption on does not have to migrate its data + in one step. `sealed?/1` is there for a caller that needs to know which it got. + """ + @spec open(binary()) :: {:ok, binary()} | {:error, term()} + def open(@version <> <>) do + case rest do + <> -> + decrypt(wrapped, iv, tag, ct) + + _ -> + {:error, :malformed_blob} + end + end + + def open(plain) when is_binary(plain), do: {:ok, plain} + + defp decrypt(wrapped, iv, tag, ct) do + with {:ok, data_key} <- Keys.unwrap(wrapped) do + aad = @version <> wrapped + + case :crypto.crypto_one_time_aead(:aes_256_gcm, data_key, iv, ct, aad, tag, false) do + :error -> {:error, :blob_authentication_failed} + plain -> {:ok, plain} + end + end + end + + @doc """ + Sealed bytes, ready for whoever owns the path. + + This module does no file input or output on purpose. A `read(path)` and `write(path, data)` pair + lived here briefly and was removed: they would accept any path, and the honest justification for + that is "the caller validated it", which is a claim nothing checks. Path validation already + exists where paths are built - `Trinity.Archive.Layout.safe_path?/1` for archives, the configured + roots for skills - so the bytes come here and the paths stay there. + """ + @spec seal!(binary()) :: binary() + def seal!(plaintext) do + case seal(plaintext) do + {:ok, sealed} -> sealed + {:error, reason} -> raise ArgumentError, "cannot seal: #{inspect(reason)}" + end + end +end diff --git a/scripts/insert_bench.exs b/scripts/insert_bench.exs new file mode 100644 index 0000000..a5c6522 --- /dev/null +++ b/scripts/insert_bench.exs @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The insert benchmark, run against a directory. Slice 025 AC5 uses it to measure what full-disk +# encryption costs, by running it on an encrypted volume and a plain one on the same machine on +# the same day. +# +# It reproduces the two write patterns this project actually uses rather than a synthetic one: +# +# receipts synchronous: :full, one row per transaction (slice 024: the signed chain, where +# every receipt must survive a power +# cut, measured there at 274 us/row) +# main synchronous: :normal, batched per transaction (slice 010: everything else) +# +# Usage: elixir scripts/insert_bench.exs [rows] +# Prints one line per pattern: the microseconds per row, which is the number to compare. +Mix.install([{:exqlite, "~> 0.40"}]) + +[dir | rest] = System.argv() +rows = case rest do + [n | _] -> String.to_integer(n) + [] -> 2_000 +end + +defmodule Bench do + alias Exqlite.Sqlite3 + + def run(dir, rows, label, synchronous, batch) do + path = Path.join(dir, "insert_bench_#{label}.db") + File.rm(path); File.rm(path <> "-wal"); File.rm(path <> "-shm") + {:ok, db} = Sqlite3.open(path) + + :ok = Sqlite3.execute(db, "PRAGMA journal_mode=WAL") + :ok = Sqlite3.execute(db, "PRAGMA synchronous=#{synchronous}") + :ok = Sqlite3.execute(db, "CREATE TABLE t (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)") + + # A payload the size of a signed receipt rather than a token, so the measurement is of this + # project's writes and not of SQLite's row overhead. + payload = :crypto.strong_rand_bytes(512) + + {micros, :ok} = :timer.tc(fn -> insert(db, rows, payload, batch) end) + :ok = Sqlite3.close(db) + File.rm(path); File.rm(path <> "-wal"); File.rm(path <> "-shm") + + per_row = micros / rows + IO.puts(" #{String.pad_trailing(label, 10)} synchronous=#{String.pad_trailing(synchronous, 6)} " <> + "batch=#{String.pad_trailing(to_string(batch), 5)} " <> + "#{:erlang.float_to_binary(per_row, decimals: 1)} us/row " <> + "(#{rows} rows in #{div(micros, 1000)} ms)") + per_row + end + + defp insert(db, rows, payload, batch) do + {:ok, stmt} = Sqlite3.prepare(db, "INSERT INTO t (payload) VALUES (?1)") + + Enum.chunk_every(1..rows, batch) + |> Enum.each(fn chunk -> + :ok = Sqlite3.execute(db, "BEGIN") + Enum.each(chunk, fn _ -> + :ok = Sqlite3.bind(stmt, [payload]) + :done = Sqlite3.step(db, stmt) + end) + :ok = Sqlite3.execute(db, "COMMIT") + end) + + :ok = Sqlite3.release(db, stmt) + :ok + end +end + +unless File.dir?(dir), do: (IO.puts("no such directory: #{dir}"); System.halt(1)) + +{fs, 0} = System.cmd("sh", ["-c", "df -T #{dir} | tail -1"]) +{dev, _} = System.cmd("sh", ["-c", "findmnt -no SOURCE --target #{dir} 2>/dev/null || echo ?"]) + +IO.puts("\ninsert bench: #{dir}") +IO.puts(" device: #{String.trim(dev)}") +IO.puts(" mount : #{String.trim(fs)}") +IO.puts("") +Bench.run(dir, rows, "receipts", "FULL", 1) +Bench.run(dir, rows, "main", "NORMAL", 100) +IO.puts("") diff --git a/test/trinity/keys/custody_census_test.exs b/test/trinity/keys/custody_census_test.exs new file mode 100644 index 0000000..3dbd072 --- /dev/null +++ b/test/trinity/keys/custody_census_test.exs @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Keys.CustodyCensusTest do + @moduledoc """ + Slice 025, AC3: key material is read through `Trinity.Keys` and nowhere else. + + This is a census, and censuses in this tree take their population from the tree rather than from + a list someone typed: `git ls-files`, so a module added tomorrow is in scope by existing. + + **The planted read is the part that matters.** A census that only ever passes proves nothing + about whether it can fail, so this file plants a violation in a temporary file, runs the same + scan over it, and asserts the scan catches it. Without that, an error in the pattern would make + this test permanently green and permanently worthless. The pattern is the one slice 024 used for + the core policy hash, for the same reason. + """ + use ExUnit.Case, async: true + + # Reading a file whose name says it holds **private** key material. The custody adapter is + # allowed to; it is the thing whose job it is. Everything else must come through the seam. + # + # The pattern deliberately does not match "anything under the keys directory". The first + # version did, and it caught `Trinity.Receipts.KeyRegistry` reading `registry.json` - which is + # not a violation, because that file holds **public** keys, fingerprints and JWKs on purpose. + # It is what lets a stranger verify the receipt chain offline without asking this project for + # anything. A census that called reading it a leak would be pushing the project toward hiding + # the one file that has to be readable. + @key_read ~r/File\.read!?\(\s*[^)]*(key_path|\.key"|root\.salt|retired-roots|private_b64)/ + + @allowed [ + # The adapter itself, and the module it is being retrofitted into. + "lib/trinity/keys/local.ex", + "lib/trinity/receipts/key_custody.ex" + ] + + defp sources do + {out, 0} = System.cmd("git", ["ls-files", "lib/*.ex", "lib/**/*.ex"]) + + out + |> String.split("\n", trim: true) + |> Enum.uniq() + end + + defp offenders(files) do + for file <- files, + file not in @allowed, + source = File.read!(file), + Regex.match?(@key_read, source), + do: file + end + + test "the population is the tree's own modules, and it is not small" do + assert length(sources()) > 200 + end + + test "no module outside the custody seam reads key material from disk" do + assert offenders(sources()) == [], + "these modules read key material directly; go through Trinity.Keys instead" + end + + test "the census catches a planted read, so a green result means something" do + planted = + Path.join(System.tmp_dir!(), "planted_key_read_#{System.unique_integer([:positive])}.ex") + + File.write!(planted, """ + defmodule Planted do + def leak(dir), do: File.read!(Path.join(dir, "receipts-ed25519.key")) + end + """) + + on_exit(fn -> File.rm(planted) end) + + # The same predicate the real census uses, over a file it was not told about. + assert Regex.match?(@key_read, File.read!(planted)), + "the census pattern does not catch a direct key read; the passing result above is empty" + end + + test "the allow list is small, and every entry exists" do + assert length(@allowed) <= 3, "the allow list is growing; that is the thing this test watches" + + for file <- @allowed do + assert File.exists?(file), "#{file} is allowed by name but is not in the tree" + end + end +end diff --git a/test/trinity/keys/local_test.exs b/test/trinity/keys/local_test.exs new file mode 100644 index 0000000..d7ac1fb --- /dev/null +++ b/test/trinity/keys/local_test.exs @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Keys.LocalTest do + @moduledoc """ + Slice 025, AC1 and AC4: every source works where it is present and refuses **by name** where it + is not, the selection is recorded, and a rotation keeps what the old key sealed readable. + + The absent cases are not simulated where the machine already supplies them. This tree was built + on a host with a TPM device and without `tpm2-tools`, which is exactly the half-present case an + operator hits and the one a mocked test would never produce. + """ + use ExUnit.Case, async: false + + alias Trinity.Keys + alias Trinity.Keys.Local + + @pass "correct horse battery staple" + + setup do + dir = Path.join(System.tmp_dir!(), "trinity-keys-test-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + prior_keys = Application.get_env(:trinity, :keys) + prior_pass = System.get_env("TRINITY_KEYS_PASSPHRASE") + Application.put_env(:trinity, :keys, dir: dir, source: :passphrase) + System.put_env("TRINITY_KEYS_PASSPHRASE", @pass) + :persistent_term.erase({Local, :selection}) + + on_exit(fn -> + :persistent_term.erase({Local, :selection}) + + if prior_keys, + do: Application.put_env(:trinity, :keys, prior_keys), + else: Application.delete_env(:trinity, :keys) + + if prior_pass, + do: System.put_env("TRINITY_KEYS_PASSPHRASE", prior_pass), + else: System.delete_env("TRINITY_KEYS_PASSPHRASE") + + File.rm_rf(dir) + end) + + {:ok, dir: dir} + end + + describe "AC1: a source works when present and is refused by name when absent" do + test "the order is availability ascending, and it is the module's own list" do + assert Local.sources() == [:systemd_credential, :tpm, :passphrase] + end + + test "passphrase: available when set, refused by name when not" do + assert Local.available?(:passphrase) == :ok + + System.delete_env("TRINITY_KEYS_PASSPHRASE") + + assert {:unavailable, :passphrase, :no_passphrase_configured} = + Local.available?(:passphrase) + + System.put_env("TRINITY_KEYS_PASSPHRASE", "") + assert {:unavailable, :passphrase, :empty_passphrase} = Local.available?(:passphrase) + end + + test "systemd credential: refused by name when the process is not run under systemd" do + prior = System.get_env("CREDENTIALS_DIRECTORY") + System.delete_env("CREDENTIALS_DIRECTORY") + + assert {:unavailable, :systemd_credential, :not_run_under_systemd} = + Local.available?(:systemd_credential) + + # Run under systemd but without this credential supplied: a different reason, because it + # sends an operator somewhere different. + empty = Path.join(System.tmp_dir!(), "creds-#{System.unique_integer([:positive])}") + File.mkdir_p!(empty) + System.put_env("CREDENTIALS_DIRECTORY", empty) + + assert {:unavailable, :systemd_credential, :credential_not_supplied} = + Local.available?(:systemd_credential) + + File.rm_rf(empty) + + if prior, + do: System.put_env("CREDENTIALS_DIRECTORY", prior), + else: System.delete_env("CREDENTIALS_DIRECTORY") + end + + test "tpm: the refusal names which half is missing, device or tooling" do + # Not mocked. Whatever this machine has, the answer must distinguish the two halves, because + # "TPM unavailable" tells an operator nothing about what to install. + case Local.available?(:tpm) do + :ok -> + assert File.exists?("/dev/tpmrm0") or File.exists?("/dev/tpm0") + assert System.find_executable("tpm2_create") + + {:unavailable, :tpm, reason} -> + assert reason in [:device_absent, :tooling_absent] + + if reason == :tooling_absent do + assert File.exists?("/dev/tpmrm0") or File.exists?("/dev/tpm0"), + "tooling_absent should only be reported when the device is present" + end + end + end + + test "an unknown source is refused rather than treated as available" do + assert {:unavailable, :nonesuch, :unknown_source} = Local.available?(:nonesuch) + end + + test "with no source available at all, the error names every refusal, not the last one" do + System.delete_env("TRINITY_KEYS_PASSPHRASE") + prior = System.get_env("CREDENTIALS_DIRECTORY") + System.delete_env("CREDENTIALS_DIRECTORY") + Application.put_env(:trinity, :keys, dir: Local.keys_dir()) + + assert {:error, {:no_key_source, refusals}} = Local.select() + assert Enum.map(refusals, &elem(&1, 0)) == [:systemd_credential, :tpm, :passphrase] + assert Enum.all?(refusals, fn {_source, reason} -> is_atom(reason) end) + + if prior, do: System.put_env("CREDENTIALS_DIRECTORY", prior) + end + + test "describe names the adapter, the source and a key id, and carries no key material" do + d = Keys.describe() + assert d.adapter == Local + assert d.source == :passphrase + assert is_binary(d.key_id) and byte_size(d.key_id) == 16 + assert d.detail =~ "PBKDF2" + + {:ok, material} = Keys.fetch(:receipts) + refute d.key_id =~ Base.encode64(material) + refute String.contains?(inspect(d), Base.encode64(material)) + end + end + + describe "keys and wrapping" do + test "a named key is stable, and a different name gives different bytes" do + {:ok, a1} = Keys.fetch(:receipts) + {:ok, a2} = Keys.fetch(:receipts) + {:ok, b} = Keys.fetch(:envelopes) + + assert a1 == a2 + assert byte_size(a1) == 32 + refute a1 == b + end + + test "wrap then unwrap returns the material, and the wrapper is not the material" do + material = :crypto.strong_rand_bytes(32) + {:ok, wrapped} = Keys.wrap(material) + + refute wrapped == material + refute String.contains?(wrapped, material) + assert {:ok, ^material} = Keys.unwrap(wrapped) + end + + test "a tampered wrapper is refused rather than returning wrong bytes" do + {:ok, wrapped} = Keys.wrap(:crypto.strong_rand_bytes(32)) + head_size = byte_size(wrapped) - 1 + <> = wrapped + tampered = head <> <> + + assert {:error, :unwrap_failed} = Keys.unwrap(tampered) + assert {:error, :malformed_wrapper} = Keys.unwrap("not a wrapper at all") + end + end + + describe "AC4: rotation" do + test "new material uses the new key, and what the old key sealed still opens" do + old_secret = :crypto.strong_rand_bytes(32) + {:ok, sealed_before} = Keys.wrap(old_secret) + before = Keys.describe() + + assert {:ok, _} = Keys.rotate() + now = Keys.describe() + + # The root actually changed. + refute now.key_id == before.key_id + + # And the thing sealed by the old root still opens, which is the property a rotation that + # silently lost data would fail. + assert {:ok, ^old_secret} = Keys.unwrap(sealed_before) + + # New material is sealed under the new root. + new_secret = :crypto.strong_rand_bytes(32) + {:ok, sealed_after} = Keys.wrap(new_secret) + assert {:ok, ^new_secret} = Keys.unwrap(sealed_after) + refute sealed_after == sealed_before + end + end +end diff --git a/test/trinity/keys/signer_seam_test.exs b/test/trinity/keys/signer_seam_test.exs new file mode 100644 index 0000000..dbc4e1d --- /dev/null +++ b/test/trinity/keys/signer_seam_test.exs @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Keys.SignerSeamTest do + @moduledoc """ + Slice 025, AC3 and AC1: the receipt signing key is sealed by the custody seam before it reaches + disk, opens again through it, and the boot receipt names which adapter and source held it. + + The retrofit's obligation runs both ways. A key written *before* this slice carries no custody + field and holds its private bytes directly, and it must still open: a slice that made every + existing installation unbootable in order to gain a property would not be an improvement. That + case is tested here explicitly, by writing the old format. + """ + use ExUnit.Case, async: false + + alias Trinity.Keys.Local + alias Trinity.Receipts.{KeyCustody, KeyRegistry} + + setup do + dir = + Path.join(System.tmp_dir!(), "trinity-signer-seam-#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + + prior_keys = Application.get_env(:trinity, :keys) + prior_receipts = Application.get_env(:trinity, :receipts) + prior_pass = System.get_env("TRINITY_KEYS_PASSPHRASE") + + Application.put_env(:trinity, :keys, dir: dir, source: :passphrase) + Application.put_env(:trinity, :receipts, Keyword.put(prior_receipts || [], :keys_dir, dir)) + System.put_env("TRINITY_KEYS_PASSPHRASE", "a signer seam passphrase") + :persistent_term.erase({Local, :selection}) + + on_exit(fn -> + :persistent_term.erase({Local, :selection}) + + # KeyCustody.boot!/1 records its selection in :persistent_term, which outlives this test's + # process and every sandbox. Without putting the real one back, every later test in the run + # signs against a temporary directory this test has just deleted, and the failure surfaces + # as `signer_unavailable` in tests that have nothing to do with keys. Restoring the config + # is not enough: the selection has to be re-made from it. + restore = fn -> + if prior_receipts, + do: Application.put_env(:trinity, :receipts, prior_receipts), + else: Application.delete_env(:trinity, :receipts) + + KeyCustody.boot!() + end + + if prior_keys, + do: Application.put_env(:trinity, :keys, prior_keys), + else: Application.delete_env(:trinity, :keys) + + if prior_pass, + do: System.put_env("TRINITY_KEYS_PASSPHRASE", prior_pass), + else: System.delete_env("TRINITY_KEYS_PASSPHRASE") + + restore.() + File.rm_rf(dir) + end) + + {:ok, dir: dir} + end + + test "the signing key is ciphertext on disk and the registry says so", %{dir: dir} do + assert {:ok, selection} = KeyCustody.boot!(dir) + + raw = File.read!(selection.key_path) + {:ok, row} = JSON.decode(raw) + + assert row["custody"] == "sealed" + + # The stored private value is what the seam produced, not the key itself: it carries the + # vault's wrapper marker, and the wrapper is longer than the key it holds. + {:ok, stored} = Base.decode64(row["private_b64"]) + assert String.starts_with?(stored, "TKW1") + + # And the registry row records the custody that actually held it, rather than a constant. + {:ok, rows} = KeyRegistry.read(dir) + assert %{"custody" => "sealed"} = KeyRegistry.lookup(rows, selection.key_id) + end + + test "a sealed key still signs, which is the point of sealing it", %{dir: dir} do + assert {:ok, _selection} = KeyCustody.boot!(dir) + assert {:ok, signature} = KeyCustody.sign("some pae bytes") + assert is_binary(signature) and byte_size(signature) > 0 + end + + test "losing what opens the key makes the signer unavailable rather than silently wrong", %{ + dir: dir + } do + assert {:ok, _} = KeyCustody.boot!(dir) + assert {:ok, _} = KeyCustody.sign("before") + + File.rm!(Local.salt_path()) + :persistent_term.erase({Local, :selection}) + + assert {:error, _reason} = KeyCustody.sign("after") + end + + test "a key file written before this slice still opens", %{dir: dir} do + # Boot once to get a real keypair and registry row, then rewrite the file in the old shape: + # no custody field, private bytes stored directly. + assert {:ok, selection} = KeyCustody.boot!(dir) + {:ok, row} = JSON.decode(File.read!(selection.key_path)) + {:ok, wrapped} = Base.decode64(row["private_b64"]) + {:ok, plain} = Trinity.Keys.unwrap(wrapped) + + old_format = + row + |> Map.delete("custody") + |> Map.put("private_b64", Base.encode64(plain)) + |> JSON.encode!() + + File.write!(selection.key_path, old_format) + + assert {:ok, _} = KeyCustody.boot!(dir) + assert {:ok, signature} = KeyCustody.sign("pae bytes from an old installation") + assert is_binary(signature) + end + + test "AC1: the boot receipt subject names the adapter, the source and no key material" do + subject = Trinity.Effects.Boot.key_custody_subject() + + assert subject["adapter"] =~ "Trinity.Keys.Local" + assert subject["source"] == "passphrase" + assert is_binary(subject["key_id"]) + assert subject["detail"] =~ "PBKDF2" + + {:ok, material} = Trinity.Keys.fetch(:receipts) + refute String.contains?(JSON.encode!(subject), Base.encode64(material)) + end +end diff --git a/test/trinity/vault_test.exs b/test/trinity/vault_test.exs new file mode 100644 index 0000000..c27f1c3 --- /dev/null +++ b/test/trinity/vault_test.exs @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.VaultTest do + @moduledoc """ + Slice 025, AC2: a blob is ciphertext on disk, readable through the seam, and unreadable once the + wrapped key cannot be opened. + + The third of those is the one worth writing carefully. "It decrypts when we ask" proves the + round trip works; it does not prove the data was ever protected. Each test here that claims + something is unreadable removes the thing that opens it and checks the failure, rather than + asserting that ciphertext looks scrambled. + """ + use ExUnit.Case, async: false + + alias Trinity.Keys.Local + alias Trinity.Vault + + @secret "the quick brown fox jumps over the lazy dog, and a passphrase is not a threat model" + + setup do + dir = Path.join(System.tmp_dir!(), "trinity-vault-test-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + prior_keys = Application.get_env(:trinity, :keys) + prior_pass = System.get_env("TRINITY_KEYS_PASSPHRASE") + Application.put_env(:trinity, :keys, dir: dir, source: :passphrase) + System.put_env("TRINITY_KEYS_PASSPHRASE", "a test passphrase") + :persistent_term.erase({Local, :selection}) + + on_exit(fn -> + :persistent_term.erase({Local, :selection}) + + if prior_keys, + do: Application.put_env(:trinity, :keys, prior_keys), + else: Application.delete_env(:trinity, :keys) + + if prior_pass, + do: System.put_env("TRINITY_KEYS_PASSPHRASE", prior_pass), + else: System.delete_env("TRINITY_KEYS_PASSPHRASE") + + File.rm_rf(dir) + end) + + {:ok, dir: dir} + end + + test "a sealed blob is ciphertext: the plaintext does not appear in it" do + {:ok, sealed} = Vault.seal(@secret) + + refute sealed == @secret + refute String.contains?(sealed, @secret) + refute String.contains?(sealed, "quick brown fox") + assert Vault.sealed?(sealed) + assert {:ok, @secret} = Vault.open(sealed) + end + + test "a sealed blob written to disk by a caller is ciphertext there", %{dir: dir} do + path = Path.join(dir, "skill.md") + {:ok, sealed} = Vault.seal(@secret) + File.write!(path, sealed) + File.chmod!(path, 0o600) + + on_disk = File.read!(path) + refute String.contains?(on_disk, @secret) + assert Vault.sealed?(on_disk) + assert {:ok, @secret} = Vault.open(on_disk) + + # And a caller that writes a secret is expected to do so at 0600; a secret at 0644 is a secret + # in name only. + %{mode: mode} = File.stat!(path) + assert Bitwise.band(mode, 0o077) == 0 + end + + test "every blob gets its own data key, so one compromise does not open the next" do + {:ok, a} = Vault.seal(@secret) + {:ok, b} = Vault.seal(@secret) + + # Same plaintext, different bytes: no deterministic encryption, no shared key or nonce. + refute a == b + end + + test "removing what opens the key makes the blob unreadable", %{dir: dir} do + path = Path.join(dir, "export.tar") + {:ok, sealed} = Vault.seal(@secret) + File.write!(path, sealed) + assert {:ok, @secret} = Vault.open(File.read!(path)) + + # The passphrase source derives the root from the passphrase and the salt. Remove the salt and + # a *different* root is derived, so the wrapped data key no longer opens. This is the real + # failure mode for this source: not a corrupted file, a lost key. + File.rm!(Local.salt_path()) + :persistent_term.erase({Local, :selection}) + + assert {:error, reason} = Vault.open(File.read!(path)) + + # The adapter is more precise than "it failed": the wrapper names the key id that sealed it, + # that root is no longer derivable and was never retired, so the answer says which key is + # missing rather than that some byte did not verify. An operator who sees this knows they + # lost a key rather than corrupted a file. + assert match?({:unknown_key_id, _}, reason) or + reason in [:unwrap_failed, :blob_authentication_failed], + "expected an error naming the missing key, got #{inspect(reason)}" + + # Whatever the reason, the property under test is that the plaintext did not come back. + refute match?({:ok, @secret}, Vault.open(File.read!(path))) + end + + test "a tampered blob is refused rather than opened to wrong bytes" do + {:ok, sealed} = Vault.seal(@secret) + last = byte_size(sealed) - 1 + <> = sealed + tampered = head <> <> + + assert {:error, :blob_authentication_failed} = Vault.open(tampered) + end + + test "a blob truncated after the marker is malformed, not a crash" do + assert {:error, :malformed_blob} = Vault.open(Vault.version() <> <<200::8>> <> "short") + end + + describe "AC2: the blob classes, sealed only where a deployment asks" do + test "sealing is off unless configured, and the call site reads the same either way" do + refute Vault.sealing?(:staged_skills) + assert Vault.maybe_seal!(@secret, :staged_skills) == @secret + + Application.put_env(:trinity, :vault, seal: [:staged_skills]) + on_exit(fn -> Application.delete_env(:trinity, :vault) end) + + assert Vault.sealing?(:staged_skills) + sealed = Vault.maybe_seal!(@secret, :staged_skills) + assert Vault.sealed?(sealed) + assert {:ok, @secret} = Vault.open(sealed) + + # A class that was not named is still untouched, so turning one on does not turn on the rest. + refute Vault.sealing?(:exports) + assert Vault.maybe_seal!(@secret, :exports) == @secret + end + + test "a staged skill change is ciphertext on disk and reads back through the seam", %{ + dir: dir + } do + Application.put_env(:trinity, :vault, seal: [:staged_skills]) + on_exit(fn -> Application.delete_env(:trinity, :vault) end) + + # The shape the staging writes and reads: a tree of relative paths to contents. + change_dir = Path.join(dir, "change-1") + File.mkdir_p!(change_dir) + path = Path.join(change_dir, "SKILL.md") + File.write!(path, Vault.maybe_seal!(@secret, :staged_skills)) + + refute String.contains?(File.read!(path), @secret) + assert Vault.sealed?(File.read!(path)) + assert {:ok, @secret} = Vault.open(File.read!(path)) + end + + test "an export manifest is ciphertext on disk when exports are sealed", %{dir: dir} do + Application.put_env(:trinity, :vault, seal: [:exports]) + on_exit(fn -> Application.delete_env(:trinity, :vault) end) + + path = Path.join(dir, "manifest.json") + File.write!(path, Vault.maybe_seal!(@secret, :exports)) + + refute String.contains?(File.read!(path), @secret) + assert {:ok, @secret} = Vault.open(File.read!(path)) + end + end + + test "an unsealed blob passes through, so the two paths are the same code" do + refute Vault.sealed?("plain text written before this slice") + + assert {:ok, "plain text written before this slice"} = + Vault.open("plain text written before this slice") + end +end