lib/branca.ex

defmodule Branca do
  @moduledoc """
  Branca allows you to generate and verify encrypted API tokens (IETF
  XChaCha20-Poly1305 AEAD). [Branca specification](https://github.com/tuupola/branca-spec)
  defines the external format and encryption scheme of the token to help
  interoperability between userland implementations. Branca is closely based
  on [Fernet](https://github.com/fernet/spec/blob/master/Spec.md).

  Payload in Branca token is an arbitrary sequence of bytes. This means
  payload can be for example a JSON object, plain text string or even binary
  data serialized by [MessagePack](http://msgpack.org/) or [Protocol Buffers](https://developers.google.com/protocol-buffers/).

  This library expects you the set the 32 byte secret key in `config/config.exs`:
      config :branca, key: "supersecretkeyyoushouldnotcommit"
  """
  alias Salty.Aead.Xchacha20poly1305Ietf, as: Xchacha20
  alias Branca.Token, as: Token

  import DateTime, only: [utc_now: 0, to_unix: 1]

  @version 0xBA
  @alphabet "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
  @base62 BaseX.prepare_module("Base62", @alphabet, 127)
  @key Application.get_env(:branca, :key)

  @doc """
  Returns base62 encoded encrypted token with given payload.

  By default token will use current timestamp and generated random nonce. This
  is what you almost always want to use.

      iex> token = Branca.encode("Hello world!")
      {:ok, "875GH233T7IYrxtgXxlQBYiFobZMQdHAT51vChKsAIYCFxZtL1evV54vYqLyZtQ0ekPHt8kJHQp0a"}

  Optionally you can pass `timestamp` and `nonce`. You could for example opt-out
  from sending `timestamp` by setting it to `0`. Clock skew can be adjusted by setting
  the timestamp few seconds to future.

      iex> token = Branca.encode("Hello world!", timestamp: 123206400)
      {:ok, "875GH233T7IYrxtgXxlQBYiFobZMQdHAT51vChKsAIYCFxZtL1evV54vYqLyZtQ0ekPHt8kJHQp0a"}

  Explicit `nonce` is mostly used for unit testing. If you generate `nonce` yourself
  make sure not to reuse the it between tokens.

      iex> nonce = Salty.Random.buf(24)
      iex> token = Branca.encode("Hello world!", timestamp: 123206400, nonce: nonce)
      {:ok, "87x85fNayA1e3Zd0mv0nJao0QE3oNUGTuj9gVdEcrX4RKMQ7a9VGziHec52jgMWYobXwsc4mrRM0A"}
  """
  def encode(payload, options \\ [])

  def encode(payload, options) when is_list(options) do
    encode(payload, Map.new(options))
  end

  def encode(payload, options) do
    try do
      %Token{payload: payload}
        |> add_timestamp(options)
        |> add_nonce(options)
        |> add_header
        |> seal
        |> base62_encode
    rescue
      _ in ArgumentError -> {:error, :invalid_argument}
    else
      token -> {:ok, token}
    end
  end

  @doc """
  Returns base62 encoded encrypted token with given payload, raises an exception on error.

      iex> token = Branca.encode("Hello world!")
      "875GH233T7IYrxtgXxlQBYiFobZMQdHAT51vChKsAIYCFxZtL1evV54vYqLyZtQ0ekPHt8kJHQp0a"
  """
  def encode!(payload, options \\ []) do
    case encode(payload, options) do
      {:ok, token} -> token
      {:error, reason} -> raise format_error(reason)
    end
  end

  @doc """
  Decrypts and verifies the token returning the payload on success.

      iex> token = Branca.encode("Hello world!");
      iex> Branca.decode(token)
      {:ok, "Hello world!"}

  Optionally you can make sure tokens are valid only `ttl` seconds.

      iex> token = Branca.encode("Hello world!", timestamp: 123206400);
      iex> Branca.decode(token)
      {:ok, "Hello world!"}
      iex> Branca.decode(token, ttl: 60)
      {:error, :expired}
  """
  def decode(token, options \\ [])

  def decode(token, options) when is_list(options) do
    decode(token, Map.new(options))
  end

  def decode(token, %{:ttl => ttl}) when is_integer(ttl) do
    token = explode_token(token)
    {_, payload} = unseal(token)

    future = token.timestamp + ttl

    cond do
      future < unixtime() -> {:error, :expired}
      true -> {:ok, payload}
    end
  end

  def decode(token, %{}) do
    token = explode_token(token)

    cond do
      @version == token.version -> unseal(token)
      true -> {:error, :unknown_version}
    end
  end

  @doc """
  Decrypts and verifies the token returning the payload on success, raises an exception on error.

      iex> token = Branca.encode("Hello world!");
      iex> Branca.decode!(token)
      "Hello world!"
  """
  def decode!(token, options \\ []) do
    case decode(token, options) do
      {:ok, payload} -> payload
      {:error, reason} -> raise format_error(reason)
    end
  end

  defp format_error(:expired), do: "Token is expired."
  defp format_error(:forged), do: "Invalid token."
  defp format_error(:unknown_version), do: "Unknown token version."
  defp format_error(:invalid_argument), do: "Invalid arguments passed to Libsodium."

  defp add_timestamp(token, %{timestamp: timestamp}) when is_integer(timestamp) do
    timestamp = :binary.encode_unsigned(timestamp, :big)
    %Token{token | timestamp: timestamp}
  end

  defp add_timestamp(token, %{}) do
    timestamp =
      utc_now()
      |> to_unix()
      |> :binary.encode_unsigned(:big)

    %Token{token | timestamp: timestamp}
  end

  defp add_nonce(token, %{nonce: nonce}) when is_binary(nonce) do
    cond do
      byte_size(nonce) == Xchacha20.npubbytes() -> %Token{token | nonce: nonce}
      true -> {:error, :invalid_nonce}
    end
    %Token{token | nonce: nonce}
  end

  defp add_nonce(token, %{}) do
    {_, nonce} = Salty.Random.buf(Xchacha20.npubbytes())
    %Token{token | nonce: nonce}
  end

  defp add_header(token) do
    header = <<@version>> <> token.timestamp <> token.nonce
    %Token{token | header: header}
  end

  defp base62_decode(encoded) do
    binary = @base62.decode(encoded)
    %Token{binary: binary}
  end

  defp base62_encode(token) do
    @base62.encode(token.header <> token.ciphertext)
  end

  defp explode_binary(%Token{binary: binary} = token) do
    << header::binary - size(29), data::binary >> = binary
    %Token{token | header: header, data: data}
  end

  defp explode_header(%Token{header: header} = token) do
    << version::8, timestamp::32, nonce::binary - size(24) >> = header
    %Token{token | version: version, timestamp: timestamp, nonce: nonce}
  end

  defp explode_data(%Token{data: data} = token) do
    size = byte_size(data) - 16
    << ciphertext::binary - size(size), tag::binary - size(16) >> = data
    %Token{token | ciphertext: ciphertext, tag: tag}
  end

  defp explode_token(encoded) do
    encoded
      |> base62_decode
      |> explode_binary
      |> explode_header
      |> explode_data
  end

  defp unixtime do
    to_unix(utc_now())
  end

  defp seal(token) do
    {_, ciphertext} = Xchacha20.encrypt(token.payload, token.header, nil, token.nonce, @key)
    %Token{token | ciphertext: ciphertext}
  end

  defp unseal(token) do
    Xchacha20.decrypt_detached(nil, token.ciphertext, token.tag, token.header, token.nonce, @key)
  end
end