lib/age.ex

defmodule Age do
  @moduledoc """
  Elixir implementation of the age encryption format.
  """

  alias Age.X25519

  @version "v1"

  defp file_key() do
    :crypto.strong_rand_bytes(16)
  end

  defp version_line() do
    "age-encryption.org/#{@version}"
  end

  @doc """
  Generates a new identity keypair and formats it in Bech32.
  """
  @spec generate_identity() :: {String.t(), String.t()}
  def generate_identity() do
    {pub, priv} = X25519.generate_keypair()

    {X25519.encode_public_key(pub), X25519.encode_private_key(priv)}
  end

  @doc """
  Encrypts the given data for the given list of recipients.
  """
  @spec encrypt(binary(), [String.t()]) :: {:ok, binary()} | {:error, String.t()}
  def encrypt(data, recipients) do
    fk = file_key()

    header_recp = recipients
    |> Enum.map(fn recipient ->
      X25519.decode_public_key(recipient)
    end)
    |> Enum.map(fn recipient ->
      X25519.stanza(recipient, fk)
    end)
    |> Enum.map(fn {header, body} ->
      "-> " <> header <> "\n" <> body
    end)
    |> Enum.join("\n")

    header = version_line() <> "\n" <> header_recp <> "\n" <> "---"

    header_signature = derive_header_signature(header, fk)

    signed_header = header <> " " <> header_signature

    data_chunks = chunk(64 * 1024, data)

    nonce = :crypto.strong_rand_bytes(16)
    payload_key = HKDF.derive(:sha256, fk, 32, nonce, "payload")

    body = data_chunks
    |> Enum.with_index()
    |> Enum.map(fn {chunk, index} ->
      is_final? = index == length(data_chunks) - 1
      nonce = get_chunk_nonce(index, is_final?)
      encrypt_chunk(chunk, payload_key, nonce)
    end)
    |> Enum.join(<<>>)

    {:ok, signed_header <> "\n" <> nonce <> body}
  end

  @doc """
  Decrypts the given data using the given identity.
  """
  @spec decrypt(binary(), String.t()) :: {:ok, binary()} | {:error, String.t()}
  def decrypt(data, identity) when is_binary(data) and is_binary(identity) do
    privkey = X25519.decode_private_key(identity)

    [header, sig_and_nonce_and_body] = :binary.split(data, "--- ")

    [header_signature, nonce_and_body] = :binary.split(sig_and_nonce_and_body, "\n")

    header_signature = String.trim(header_signature)

    [version, header_recipients] = :binary.split(header, "\n")

    ^version = version_line()

    recipients = String.split(header_recipients, "-> ", trim: true)
    |> Enum.map(fn header_recipient ->
      "X25519 " <> recipient = String.trim(header_recipient)

      [eph_share, body] = String.split(recipient, "\n", trim: true)
      |> Enum.map(&Base.decode64!(&1, padding: false))

      32 = byte_size(body)
      32 = byte_size(eph_share)

      {eph_share, body}
    end)

    fk = recipients
    |> Enum.map(fn {eph_share, body} ->
      shared_secret = :crypto.compute_key(:ecdh, eph_share, privkey, :x25519)

      pubkey = X25519.derive_public_key(privkey)

      wrap_key = X25519.derive_wrap_key(shared_secret, eph_share, pubkey)

      :libsodium_crypto_aead_chacha20poly1305.ietf_decrypt(body, <<0::96>>, wrap_key)
    end)
    |> Enum.find(&is_binary(&1))

    if fk == nil do
      {:error, "no matching recipient"}
    else
      # verify header signature
      input = header <> "---"
      expected_header_signature = derive_header_signature(input, fk)
      ^expected_header_signature = header_signature

      <<nonce :: bytes-16, body :: binary>> = nonce_and_body

      data_chunks = chunk(64 * 1024, body)

      payload_key = HKDF.derive(:sha256, fk, 32, nonce, "payload")

      plaintext = data_chunks
      |> Enum.with_index()
      |> Enum.map(fn {chunk, index} ->
        is_final? = index == length(data_chunks) - 1
        nonce = get_chunk_nonce(index, is_final?)
        decrypt_chunk(chunk, payload_key, nonce)
      end)
      |> Enum.join(<<>>)

      {:ok, plaintext}
    end
  end

  defp derive_header_signature(header, fk) do
    hmac_key = HKDF.derive(:sha256, fk, 32, "", "header")

    :libsodium_crypto_auth_hmacsha256.crypto_auth_hmacsha256(header, hmac_key)
    |> Base.encode64(padding: false)
  end

  defp encrypt_chunk(data, key, nonce) do
    :libsodium_crypto_aead_chacha20poly1305.ietf_encrypt(data, nonce, key)
  end

  defp decrypt_chunk(data, key, nonce) do
    :libsodium_crypto_aead_chacha20poly1305.ietf_decrypt(data, nonce, key)
  end

  defp chunk(_size, <<>>), do: [""]
  defp chunk(size, data), do: do_chunk(size, data)

  # https://stackoverflow.com/a/5881188
  # CC BY-SA 3.0
  defp do_chunk(size, data) when byte_size(data) > size do
    {head, rest} = :erlang.split_binary(data, size)
    [head | do_chunk(size, rest)]
  end

  defp do_chunk(_size, <<>>), do: []

  defp do_chunk(_size, data), do: [data]

  defp get_chunk_nonce(index, is_final?) do
    final = if is_final?, do: 1, else: 0
    <<index :: 11*8-big, final :: 8>>
  end
end