lib/kvasir/encryption/aes.ex

defmodule Kvasir.Encryption.AES do
  @default_bits 128

  defmacro encrypt(data, runtime?, opts) do
    {key, cipher, _aead?} = configure!(runtime?, opts)

    quote do
      iv = :crypto.strong_rand_bytes(16)

      {:ok,
       <<0>> <>
         iv <> :crypto.crypto_one_time(unquote(cipher), unquote(key), iv, unquote(data), false)}
    end
  end

  defmacro decrypt(data, runtime?, opts) do
    {key, cipher, _aead?} = configure!(runtime?, opts)

    quote do
      case unquote(data) do
        <<0, iv::binary-size(16), data::binary>> ->
          try do
            {:ok, :crypto.crypto_one_time(unquote(cipher), unquote(key), iv, data, true)}
          rescue
            _ -> {:error, :invalid_encryption}
          end

        _ ->
          {:error, :invalid_encryption_payload}
      end
    end
  end

  require Logger

  @spec configure!(runtime? :: boolean, Keyword.t()) :: {binary, atom, boolean}
  defp configure!(runtime?, opts) do
    bits = Keyword.get(opts, :bits, @default_bits)
    aead? = Keyword.get(opts, :aead, false)
    key_setting = opts[:key]
    key = if runtime?, do: fetch_key!(key_setting, bits), else: fetch_key(key_setting, bits)

    check_key!(key, bits)
    if aead?, do: raise(CompileError, description: "AEAD currently not supported.")

    mode =
      case Keyword.get(opts, :mode, if(aead?, do: :gcm, else: :ctr)) do
        :gcm when not aead? ->
          raise(CompileError, description: "`:gcm` mode only supported with AEAD enabled.")

        m when aead? ->
          raise(CompileError, description: "#{inspect(m)} mode only supported with AEAD disabled.")

        :cbc ->
          Logger.warn(fn -> "Recommend `:ctr` over `:cbc`." end)
          :cbc

        m ->
          m
      end

    {key, :"aes_#{bits}_#{mode}", aead?}
  end

  @spec fetch_key(term, pos_integer) :: binary
  defp fetch_key(key, bits)
  defp fetch_key(nil, bits), do: fetch_key!(:generate, bits)
  defp fetch_key({:system, var}, bits), do: var |> System.get_env() |> fetch_key(bits)
  defp fetch_key(key, bits), do: fetch_key!(key, bits)

  @spec fetch_key!(term, pos_integer) :: binary | no_return
  defp fetch_key!(key, bits)
  defp fetch_key!(nil, _bits), do: raise("No encryption key set.")
  defp fetch_key!({:system, var}, bits), do: var |> System.get_env() |> fetch_key!(bits)

  defp fetch_key!(:generate, bits) do
    with nil <- Application.get_env(:kvasir, __MODULE__) do
      key = :crypto.strong_rand_bytes(div(bits, 8))
      Application.put_env(:kvasir, __MODULE__, key)

      Logger.warn(fn ->
        """
        Generating AES key for encryption.
        This should not be done for production.

        Generated key: #{Base.encode64(key)}
        """
      end)

      key
    end
  end

  defp fetch_key!(data, _bits) do
    case Base.decode64(data) do
      {:ok, key} -> key
      :error -> data
    end
  end

  @spec check_key!(binary, pos_integer) :: :ok | no_return
  defp check_key!(key, bits) do
    cond do
      is_nil(key) -> raise "No encryption key set."
      byte_size(key) != bits / 8 -> raise "Encryption key wrong size."
      :ok -> :ok
    end
  end
end