lib/aino/session/encrypted_cookie.ex

defmodule Aino.Session.EncryptedCookie do
  @moduledoc """
  Session implementation using cookies as the storage
  """

  alias Aino.Token
  alias Aino.Session.AES

  defstruct [:key, :salt]

  @doc """
  Parse encrypted session data from cookies.

  Can only be used with `Aino.Middleware.cookies/1` and `Aino.Session.config/2` having run before.

  Requires the :key set in the :config map to be exactly 256 bits (32 bytes).

  Adds the following keys to the token `[:session]`
  """
  def decode(config, token) do
    case token.cookies["_aino_session"] do
      encrypted when is_binary(encrypted) ->
        case AES.decrypt(encrypted, config.key) do
          :error -> Map.put(token, :session, %{})
          data -> parse_session(token, data)
        end

      _ ->
        Map.put(token, :session, %{})
    end
  end

  defp parse_session(token, data) do
    case Jason.decode(data) do
      {:ok, session} ->
        Map.put(token, :session, session)

      {:error, _} ->
        Map.put(token, :session, %{})
    end
  end

  @doc """
  Response will be returned with one new `Set-Cookie` headers with the session data as a JSON encoded, encrypted, base64 encoded string
  """
  def encode(config, token) do
    case is_map(token.session) do
      true ->
        session = Map.put(token.session, "t", DateTime.utc_now())

        case Jason.encode(session) do
          {:ok, data} ->
            encrypted = AES.encrypt(data, config.key)

            Token.response_header(
              token,
              "Set-Cookie",
              "_aino_session=#{encrypted}; HttpOnly; Path=/"
            )

          :error ->
            token
        end

      false ->
        token
    end
  end

  defimpl Aino.Session.Storage do
    alias Aino.Session.EncryptedCookie

    def decode(config, token) do
      EncryptedCookie.decode(config, token)
    end

    def encode(config, token) do
      EncryptedCookie.encode(config, token)
    end
  end
end

defmodule Aino.Session.AES do
  @moduledoc false

  # Uses AES 256 GCM to encrypt and decrypt session data.

  require Logger

  @aad "aino-session-crypto-module"

  @doc false
  def encrypt(data, key) do
    iv = :crypto.strong_rand_bytes(16)
    {encrypted, tag} = :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, data, @aad, true)

    Enum.map_join([encrypted, iv, tag], ".", &Base.encode64(&1, padding: false))
  rescue
    e ->
      reraise e, filter_stacktrace(__STACKTRACE__)
  end

  @doc false
  def decrypt(blob, key) do
    with [_, _, _] = encoded <- String.split(blob, "."),
         [{:ok, encrypted_data}, {:ok, iv}, {:ok, tag}] <-
           Enum.map(encoded, &Base.decode64(&1, padding: false)),
         {:ok, decrypted_data} <- do_decrypt(key, encrypted_data, iv, tag) do
      decrypted_data
    else
      _ -> :error
    end
  end

  defp filter_stacktrace(stacktrace) do
    Enum.reverse(do_filter_stacktrace(stacktrace, []))
  end

  defp do_filter_stacktrace([], acc), do: acc

  defp do_filter_stacktrace([item | rest], acc) do
    new_item =
      case item do
        {mod, fun, args, info} when is_list(args) ->
          filtered_args =
            args
            |> Enum.with_index()
            |> Enum.map(fn
              {_, 1} -> "filtered"
              {value, _} -> value
            end)

          {mod, fun, filtered_args, info}

        _ ->
          item
      end

    do_filter_stacktrace(rest, [new_item | acc])
  end

  defp do_decrypt(key, encrypted_data, iv, tag) do
    result = :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, encrypted_data, @aad, tag, false)

    case result do
      :err -> :error
      _ -> {:ok, result}
    end
  rescue
    e -> reraise e, filter_stacktrace(__STACKTRACE__)
  end
end