lib/paseto/v1.ex

defmodule Paseto.V1 do
  @moduledoc """
  The Version1 implementation of the Paseto protocol.

  More information about the implementation can be found here:
  1.) https://github.com/paragonie/paseto/blob/master/docs/01-Protocol-Versions/Version1.md
  """

  @behaviour Paseto.VersionBehaviour

  alias Paseto.Token
  alias Paseto.Utils
  alias Paseto.Utils.Crypto, as: PasetoCrypto

  import Paseto.Utils, only: [b64_decode: 1, b64_decode!: 1]

  @required_keys [:version, :purpose, :payload]
  @all_keys @required_keys ++ [:footer]

  @enforce_keys @all_keys
  defstruct @all_keys

  @header_public "v1.public."
  @header_local "v1.local."

  @hash_algo :sha384

  @nonce_size 32
  @mac_size 48
  @signature_size 256

  @doc """
  Takes a token and will decrypt/verify the signature and return the token in a more digestable manner
  """
  @spec from_token(Token.t()) :: %__MODULE__{}
  def from_token(token) do
    %__MODULE__{
      version: token.version,
      purpose: token.purpose,
      payload: token.payload,
      footer: token.footer
    }
  end

  @doc """
  Handles encrypting the payload and returning a valid token

  # Examples:
      iex> Paseto.V1.encrypt("This is a test message", "Test Key")
      "v1.local.3qbJND5q6IbF7cZxxWjmSTaVyMo2M3LaEDJ8StdFXw8PTUo55YIyy2BhIaAN6m-IdbGmdwM_ud1IpOyrz3CysNIkjBjab7NLRPbksV-XIsWYRFX6r7z2jsIfH-8emAv_BVtXi9lY"
  """
  @spec encrypt(String.t(), String.t(), String.t(), binary | nil) ::
          String.t() | {:error, String.t()}
  def encrypt(data, key, footer \\ "", n \\ nil) do
    aead_encrypt(data, key, footer, n || :crypto.strong_rand_bytes(@nonce_size))
  end

  @doc """
  Handles decrypting a token given the correct key

  # Examples:
      iex> token = Paseto.V1.encrypt("This is a test message", "Test Key")
      iex> token
      "v1.local.3qbJND5q6IbF7cZxxWjmSTaVyMo2M3LaEDJ8StdFXw8PTUo55YIyy2BhIaAN6m-IdbGmdwM_ud1IpOyrz3CysNIkjBjab7NLRPbksV-XIsWYRFX6r7z2jsIfH-8emAv_BVtXi9lY"
      iex> Paseto.V1.decrypt(token, "Test Key")
      "{:ok, "This is a test message"}"
  """
  @spec decrypt(String.t(), String.t(), String.t() | nil) ::
          {:ok, String.t()} | {:error, String.t()}
  def decrypt(data, key, footer \\ "") do
    aead_decrypt(data, @header_local, key, footer)
  end

  @doc """
  Handles signing the token for public use.

  # Examples:
      iex> {public_key, secret_key} = :crypto.generate_key(:rsa, {2048, 65_537})
      iex> Paseto.V1.sign("This is a test message!", secret_key)
      "v1.public.VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSGswqHiZVv31r99PZphr2hqJQe81Qc_7XkxHyVb_7-xORKp-VFJdEiqfINgLnwxo8n1pkIDH4_9UfhpEyS1ivgxfYe-55INfV-OyzSpHMbuGA0xviIln0fdn98QljGwh3uDFduXnfaWeBYA6nE0JingWEvVG-V8L12IdFh1rq9ZWLleFVsn719Iz8BqsasmFAICLRpnToL7X1syHdZ6PjhBnStCM5GHHzCwbdvj64P5QqxvtUzTfXBBeC-IKu_HVxIxY9VaN3d3KQotBZ1J6W1oJ4cX0JvUR4pIaq3eKfOKdoR5fUkyjS0mP9GjjoJcW8oiKKqb3dAaCHZW9he2iZNn"
  """
  @spec sign(String.t(), String.t(), String.t()) :: String.t() | {:error, String.t()}
  def sign(data, public_key, footer \\ "") do
    m2 = Utils.pre_auth_encode([@header_public, data, footer])

    signature =
      :crypto.sign(:rsa, @hash_algo, m2, public_key, [
        {:rsa_padding, :rsa_pkcs1_pss_padding},
        {:rsa_mgf1_md, @hash_algo}
      ])

    Utils.b64_encode_token(@header_public, data <> signature, footer)
  rescue
    _ -> {:error, "Signing failure."}
  end

  @doc """
  Handles verifying the signature belongs to the provided key.

  # Examples:
      iex> {public_key, secret_key} = :crypto.generate_key(:rsa, {2048, 65_537})
      iex> token = Paseto.V1.sign("This is a test message!", secret_key)
      "v1.public.VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSGswqHiZVv31r99PZphr2hqJQe81Qc_7XkxHyVb_7-xORKp-VFJdEiqfINgLnwxo8n1pkIDH4_9UfhpEyS1ivgxfYe-55INfV-OyzSpHMbuGA0xviIln0fdn98QljGwh3uDFduXnfaWeBYA6nE0JingWEvVG-V8L12IdFh1rq9ZWLleFVsn719Iz8BqsasmFAICLRpnToL7X1syHdZ6PjhBnStCM5GHHzCwbdvj64P5QqxvtUzTfXBBeC-IKu_HVxIxY9VaN3d3KQotBZ1J6W1oJ4cX0JvUR4pIaq3eKfOKdoR5fUkyjS0mP9GjjoJcW8oiKKqb3dAaCHZW9he2iZNn"
      iex> [version, purpose, payload] = String.split(token, ".")
      iex> V1.verify(version <> "." <> purpose <> ".", payload, public_key)
      "{:ok, "This is a test message!"}"
  """
  @spec verify(
          String.t(),
          [binary()],
          String.t() | nil
        ) :: {:ok, binary} | {:error, binary()}
  def verify(signed_message, [_exp, mod] = public_key, footer \\ "")
      when byte_size(mod) == 256 do
    with {:ok, decoded} <- valid_b64?(:decode, signed_message),
         {:ok, decoded_footer} <- b64_decode(footer) do
      message_size = byte_size(decoded) - @signature_size

      <<
        message::binary-size(message_size),
        signature::binary-size(@signature_size)
      >> = decoded

      m2 = Utils.pre_auth_encode([@header_public, message, decoded_footer])

      case :crypto.verify(
             :rsa,
             @hash_algo,
             m2,
             signature,
             public_key,
             [
               {:rsa_padding, :rsa_pkcs1_pss_padding},
               {:rsa_mgf1_md, @hash_algo}
             ]
           ) do
        true -> {:ok, message}
        false -> {:error, "Failed to verify signature."}
      end
    else
      :error -> {:error, "Failed to decode token during verification."}
      err -> {:error, "Token verification failed due to #{inspect(err)}"}
    end
  end

  @spec get_claims_from_signed_message(signed_message :: String.t()) :: String.t()
  defp get_claims_from_signed_message(signed_message) do
    case valid_b64?(:decode, signed_message) do
      {:ok, decoded} ->
        message_size = byte_size(decoded) - @signature_size

        <<
          message::binary-size(message_size),
          _signature::binary-size(@signature_size)
        >> = decoded

        message

      {:error, _reason} = err ->
        err
    end
  end

  @doc """
  Allows looking at the claims without having verified them.
  """
  @spec peek(token :: String.t()) :: String.t()
  def peek(token) do
    {:ok, %Paseto.Token{payload: payload}} = Utils.parse_token(token)

    get_claims_from_signed_message(payload)
  end

  ##############################
  # Internal Private Functions #
  ##############################

  @spec aead_encrypt(String.t(), String.t(), String.t() | nil, binary) :: String.t()
  defp aead_encrypt(plaintext, key, footer, n) do
    nonce = get_nonce(plaintext, n)
    <<leftmost::binary-16, rightmost::binary-16>> = nonce
    ek = HKDF.derive(@hash_algo, key, 32, leftmost, "paseto-encryption-key")
    ak = HKDF.derive(@hash_algo, key, 32, leftmost, "paseto-auth-key-for-aead")

    ciphertext = PasetoCrypto.aes_256_ctr_encrypt(ek, plaintext, rightmost)

    pre_auth_hash =
      [@header_local, nonce, ciphertext, footer]
      |> Utils.pre_auth_encode()
      |> (&PasetoCrypto.hmac_sha384(ak, &1)).()

    Utils.b64_encode_token(@header_local, nonce <> ciphertext <> pre_auth_hash, footer)
  end

  @spec aead_decrypt(String.t(), String.t(), String.t(), String.t() | nil) ::
          {:ok, String.t()} | {:error, String.t()}
  defp aead_decrypt(message, header, key, footer) do
    expected_len = String.length(header)
    given_header = String.slice(message, 0..(expected_len - 1))

    decoded =
      case b64_decode(message) do
        {:ok, decoded_value} ->
          decoded_value

        :error ->
          {:error, "Failed to decode header #{given_header} during decryption"}
      end

    length = byte_size(decoded)
    ciphertext_len = length - @nonce_size - @mac_size
    footer = b64_decode!(footer)

    <<
      nonce::binary-size(@nonce_size),
      ciphertext::binary-size(ciphertext_len),
      mac::binary-48
    >> = decoded

    <<leftmost::binary-16, rightmost::binary-16>> = nonce

    ek = HKDF.derive(@hash_algo, key, 32, leftmost, "paseto-encryption-key")
    ak = HKDF.derive(@hash_algo, key, 32, leftmost, "paseto-auth-key-for-aead")

    calc =
      [header, nonce, ciphertext, footer]
      |> Utils.pre_auth_encode()
      |> (&PasetoCrypto.hmac_sha384(ak, &1)).()

    if calc == mac do
      {:ok, PasetoCrypto.aes_256_ctr_decrypt(ek, ciphertext, rightmost)}
    else
      {:error, "Calculated hmac didn't match hmac from token."}
    end
  end

  @spec get_nonce(String.t(), String.t()) :: binary
  defp get_nonce(m, n) do
    PasetoCrypto.hmac_sha384(n, m, 32)
  end

  @spec valid_b64?(atom(), binary) :: {:ok, binary} | {:error, String.t()}
  defp valid_b64?(:decode, input) do
    case b64_decode(input) do
      {:ok, _decoded} = retval -> retval
      _ -> {:error, "Invalid payload. Payload was not b64 encoded."}
    end
  end
end