lib/openagents/voice/recording_vault.ex

58e6347eeb72 · 2 KB

defmodule OpenAgents.Voice.RecordingVault do
  @moduledoc """
  AES-256-GCM sealing for call audio at rest.

  Same construction as `OpenAgents.Accounts.TokenVault`, deliberately with its own key
  and its own additional authenticated data: a credential-scoped secret must not
  be able to open a recording, and a recording key must not be able to open a
  GitHub token. The AAD also binds a chunk to its recording and sequence, so a
  sealed slice cannot be moved to another recording or reordered inside one
  without failing to open.
  """

  @nonce_bytes 12
  @tag_bytes 16
  @version 2
  @legacy_version 1

  @spec configured?() :: boolean()
  def configured?, do: match?({:ok, _key}, key())

  @spec seal(binary(), Ecto.UUID.t(), pos_integer()) :: {:ok, binary()} | {:error, atom()}
  def seal(plaintext, recording_id, sequence)
      when is_binary(plaintext) and byte_size(plaintext) > 0 and is_binary(recording_id) and
             is_integer(sequence) and sequence > 0 do
    with {:ok, key} <- key() do
      nonce = :crypto.strong_rand_bytes(@nonce_bytes)
      aad = aad(@version, recording_id, sequence)

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

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

  def seal(_plaintext, _recording_id, _sequence), do: {:error, :invalid_chunk}

  @spec open(binary(), Ecto.UUID.t(), pos_integer()) :: {:ok, binary()} | {:error, atom()}
  def open(
        <<version, nonce::binary-size(@nonce_bytes), tag::binary-size(@tag_bytes),
          ciphertext::binary>>,
        recording_id,
        sequence
      )
      when version in [@legacy_version, @version] and is_binary(recording_id) and
             is_integer(sequence) and sequence > 0 do
    with {:ok, key} <- key() do
      case :crypto.crypto_one_time_aead(
             :aes_256_gcm,
             key,
             nonce,
             ciphertext,
             aad(version, recording_id, sequence),
             tag,
             false
           ) do
        plaintext when is_binary(plaintext) -> {:ok, plaintext}
        :error -> {:error, :chunk_unsealable}
      end
    end
  end

  def open(_sealed, _recording_id, _sequence), do: {:error, :chunk_unsealable}

  defp aad(@version, recording_id, sequence),
    do: "openagents.voice_recording_chunk.v2:#{recording_id}:#{sequence}"

  defp aad(@legacy_version, recording_id, sequence),
    do: "sarah.voice_recording_chunk.v1:#{recording_id}:#{sequence}"

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