lib/openagents/machines/token_vault.ex

58e6347eeb72 · 5 KB

defmodule OpenAgents.Machines.TokenVault do
  @moduledoc """
  AES-256-GCM sealing for computer tokens awaiting pairing claim.

  One version, one AAD. The retired `sarah.machine_token.v1` AAD is gone rather
  than kept as a legacy entry, because no build seals a version-1 blob and none
  can survive: a sealed token lives on a `machine_pairings` row for at most
  `OpenAgents.Machines` `@pairing_lifetime_seconds`, and both terminal
  transitions — claim and expiry — null `token_ciphertext` on the way out. A
  compatibility branch with an empty population is a name and a claim that
  nothing tests. That bound is enforced by
  `OpenAgents.Machines.expire_elapsed_pairings/0`; before it existed, expiry
  ran only when the CLI polled, so an abandoned pairing held its sealed token
  indefinitely. See `INVARIANTS.md`, CANON-002 and IDENTITY-011.

  The `openagents.machine_token.v2` AAD keeps its `machine` spelling for the
  opposite reason: it is bound into ciphertext this release did not write.

  ## Key independence

  Sealing uses `:machine_token_encryption_key` and nothing else. Until #192
  this vault read `:github_token_encryption_key` — the GitHub vault's active
  key — so the documented GitHub rotation in `docs/github-auth-plan.md` would
  have silently made every outstanding `machine_pairings.token_ciphertext`
  unopenable, and no document recorded the coupling. A missing dedicated key
  is a typed `{:error, :machine_token_vault_not_configured}` at this boundary;
  the vault never borrows another vault's key to seal. See `INVARIANTS.md`,
  VAULT-001.

  Opening tries the dedicated key first and then each key in the GitHub
  keyring — the active `:github_token_encryption_key` plus every entry in
  `:github_token_decryption_keys` — because that keyring is the only key
  material any historical record was sealed under, and because the GitHub
  rotation procedure moves a retired key into that keyring rather than
  deleting it. The fallback covers two bounded populations: pairings sealed by
  the previous release during a deploy, and pairings sealed while
  `config/runtime.exs` bridged `:machine_token_encryption_key` to the GitHub
  key. That bridge is no longer production's configuration — the dedicated
  secret is provisioned — and `OpenAgents.RuntimeConfig.validate/1` refuses a
  staging or production boot whose vault keys are not distinct, so the bridge
  cannot become load-bearing again without failing (#253).

  The fallback deliberately never rewraps. The only reader is
  `OpenAgents.Machines.claim_locked_pairing/1`, which nulls
  `token_ciphertext` in the same transaction as a successful open — a record
  that opens does not survive to be read again, so lazy rewrap-on-read is a
  branch no execution reaches. An eager migration sweep is equally empty
  work: every record sealed under the historical key is claimed or expired
  within one `@pairing_lifetime_seconds` window, which is shorter than any
  operator response to the sweep's outcome.
  """

  @version 2
  @aad "openagents.machine_token.v2"
  @nonce_bytes 12
  @tag_bytes 16
  @maximum_token_bytes 512

  @spec seal(String.t()) :: {:ok, binary()} | {:error, atom()}
  def seal(token) when is_binary(token) and byte_size(token) in 1..@maximum_token_bytes do
    with {:ok, key} <- dedicated_key() do
      nonce = :crypto.strong_rand_bytes(@nonce_bytes)

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

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

  def seal(_token), do: {:error, :invalid_token}

  @spec open(binary()) :: {:ok, String.t()} | {:error, atom()}
  def open(
        <<version, nonce::binary-size(@nonce_bytes), tag::binary-size(@tag_bytes),
          ciphertext::binary>>
      )
      when version == @version do
    with {:ok, key} <- dedicated_key() do
      case decrypt(key, nonce, ciphertext, tag) do
        {:ok, token} -> {:ok, token}
        {:error, :token_unsealable} -> open_with_fallback(key, nonce, ciphertext, tag)
      end
    end
  end

  def open(_sealed), do: {:error, :token_unsealable}

  defp open_with_fallback(dedicated, nonce, ciphertext, tag) do
    dedicated
    |> historical_keys()
    |> Enum.find_value({:error, :token_unsealable}, fn key ->
      case decrypt(key, nonce, ciphertext, tag) do
        {:ok, token} -> {:ok, token}
        {:error, :token_unsealable} -> nil
      end
    end)
  end

  defp decrypt(key, nonce, ciphertext, tag) do
    case :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, ciphertext, @aad, tag, false) do
      token when is_binary(token) -> {:ok, token}
      :error -> {:error, :token_unsealable}
    end
  end

  defp dedicated_key do
    case decode_key(Application.get_env(:openagents, :machine_token_encryption_key)) do
      {:ok, key} -> {:ok, key}
      :error -> {:error, :machine_token_vault_not_configured}
    end
  end

  # The GitHub keyring, decoded and deduplicated, minus the dedicated key that
  # already failed. Decrypt-side only: `seal/1` never sees these.
  defp historical_keys(dedicated) do
    active = Application.get_env(:openagents, :github_token_encryption_key)

    previous =
      :openagents
      |> Application.get_env(:github_token_decryption_keys, %{})
      |> Map.values()

    [active | previous]
    |> Enum.flat_map(fn encoded ->
      case decode_key(encoded) do
        {:ok, key} -> [key]
        :error -> []
      end
    end)
    |> Enum.uniq()
    |> List.delete(dedicated)
  end

  defp decode_key(encoded) when is_binary(encoded) do
    case Base.decode64(encoded) do
      {:ok, key} when byte_size(key) == 32 -> {:ok, key}
      _invalid -> :error
    end
  end

  defp decode_key(_missing), do: :error
end