lib/openagents/content_vault.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.ContentVault do
  @moduledoc """
  AES-256-GCM sealing for private text that nobody searches.

  The three vaults that came before this one each seal a credential or a
  recording. This one seals *content*: sentences a person wrote or spoke that
  rest in PostgreSQL and that no query reads except whole. Issue #193 asked
  which columns those are, and
  `docs/2026-08-25-encryption-at-rest.md` names every one that is sealed here
  and every one that is not, with the query that keeps it plaintext.

  ## Its own key, and one key for this whole domain

  `VAULT-001` binds the property that rotating one vault's key never unreads
  another vault's records. This vault reads `:content_encryption_key` and
  nothing else — no fallback to the GitHub keyring, no bridge to the recording
  key. Issue #253 is what that bridge costs.

  It is one vault over several columns rather than one vault per column, and
  that is a decision rather than an omission. The separation that carries
  weight is credential from content: a stolen GitHub token key must not open a
  conversation, and a conversation key must not open a token. Splitting the
  content columns from each other would buy a finer rotation blast radius at
  the price of one production secret per column, and every column here is
  readable by the same operator through the same application anyway. Rotating
  this key strands every column it seals, together, which is the cost recorded
  in `VAULT-001`'s rotation posture.

  ## What the seal is bound to

  Every seal carries additional authenticated data naming the column it belongs
  to and the row's own identity, so ciphertext cannot be lifted from one row
  into another, or from one column into another, and still open. `scope/0`
  values are the table and column; `binding` is whatever identifies the row
  immutably — usually its natural key.

  ## What it does not do

  It seals under a key the operator holds, so it defends against a stolen dump,
  a stolen backup, and a stolen disk. It does not defend against the operator,
  and `EXIT-006` keeps `encrypted_at_rest` from claiming otherwise: the
  disclosure stays `false` while any private column rests as plaintext, and
  `operator_reads_source` stays `true` regardless.
  """

  @version 1
  @nonce_bytes 12
  @tag_bytes 16
  # Large enough for the longest column this vault seals: a 20,000-character
  # project note, whose Ecto bound counts graphemes rather than bytes.
  @maximum_content_bytes 131_072
  @aad_prefix "openagents.content.v1:"

  @typedoc "The table and column a seal belongs to."
  @type scope :: String.t()

  @typedoc "The row identity a seal is bound to, in a fixed order."
  @type binding :: [String.t() | integer()]

  @doc "Whether this vault holds its own key."
  @spec configured?() :: boolean()
  def configured?, do: match?({:ok, _key}, key())

  @doc """
  Seals `content` for one column and one row.

  `scope` names the column, `binding` names the row. Both travel as
  authenticated data rather than as ciphertext, so opening a value under the
  wrong column or the wrong row fails instead of succeeding quietly.
  """
  @spec seal(String.t(), scope(), binding()) :: {:ok, binary()} | {:error, atom()}
  def seal(content, scope, binding)
      when is_binary(content) and byte_size(content) in 1..@maximum_content_bytes do
    with {:ok, aad} <- aad(scope, binding),
         {:ok, key} <- key() do
      nonce = :crypto.strong_rand_bytes(@nonce_bytes)

      {ciphertext, tag} =
        :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, content, aad, true)

      {:ok, <<@version, nonce::binary, tag::binary, ciphertext::binary>>}
    end
  end

  def seal(_content, _scope, _binding), do: {:error, :invalid_content}

  @doc "Opens a sealed value, refusing one bound to another column or row."
  @spec open(binary(), scope(), binding()) :: {:ok, String.t()} | {:error, atom()}
  def open(
        <<@version, nonce::binary-size(@nonce_bytes), tag::binary-size(@tag_bytes),
          ciphertext::binary>>,
        scope,
        binding
      ) do
    with {:ok, aad} <- aad(scope, binding),
         {:ok, key} <- key() do
      case :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, ciphertext, aad, tag, false) do
        content when is_binary(content) -> {:ok, content}
        :error -> {:error, :content_unsealable}
      end
    end
  end

  def open(_sealed, _scope, _binding), do: {:error, :content_unsealable}

  @doc """
  The plaintext, or `nil` when there is no seal to open.

  Readers render a missing sentence rather than crashing a page: a rotated key
  strands prior content, which `VAULT-001` records as this vault's rotation
  cost, and a crash there would take a whole timeline or project down with it.
  """
  @spec text(binary() | nil, scope(), binding()) :: String.t() | nil
  def text(nil, _scope, _binding), do: nil

  def text(sealed, scope, binding) when is_binary(sealed) do
    case open(sealed, scope, binding) do
      {:ok, content} -> content
      {:error, _reason} -> nil
    end
  end

  @doc "The largest value this vault seals."
  @spec maximum_content_bytes() :: pos_integer()
  def maximum_content_bytes, do: @maximum_content_bytes

  @doc "How many bytes sealing adds to a value, for a column's own bound."
  @spec overhead_bytes() :: pos_integer()
  def overhead_bytes, do: 1 + @nonce_bytes + @tag_bytes

  defp aad(scope, binding) when is_binary(scope) and is_list(binding) do
    if binding == [] or Enum.any?(binding, &is_nil/1) do
      {:error, :invalid_content_binding}
    else
      {:ok, @aad_prefix <> scope <> ":" <> Enum.map_join(binding, ":", &to_string/1)}
    end
  end

  defp aad(_scope, _binding), do: {:error, :invalid_content_binding}

  defp key do
    with encoded when is_binary(encoded) <-
           Application.get_env(:openagents, :content_encryption_key),
         {:ok, key} when byte_size(key) == 32 <- Base.decode64(encoded) do
      {:ok, key}
    else
      _missing -> {:error, :content_vault_not_configured}
    end
  end
end