lib/growth_book/decryption_utils.ex

defmodule GrowthBook.DecryptionUtils do
  @moduledoc """
  Utilities for decrypting encrypted features from GrowthBook API.
  Uses AES-CBC with PKCS7 padding.
  """

  require Logger

  @doc """
  Decrypts an encrypted payload using the provided decryption key.

  The payload should be in the format "iv.ciphertext" where both parts are base64 encoded.
  Returns {:ok, decrypted_string} on success or {:error, reason} on failure.
  """
  @spec decrypt(binary() | String.t(), binary() | String.t()) ::
          {:ok, binary()} | {:error, String.t()}
  def decrypt(payload, decryption_key) when is_binary(payload) and is_binary(decryption_key) do
    with {:ok, {iv, cipher_text}} <- split_payload(payload),
         {:ok, decoded_iv} <- decode_base64(iv),
         {:ok, decoded_cipher} <- decode_base64(cipher_text),
         {:ok, key} <- create_key(decryption_key),
         {:ok, plain_text} <- do_decrypt(decoded_cipher, key, decoded_iv),
         {:ok, valid_text} <- validate_utf8(plain_text) do
      {:ok, valid_text}
    else
      {:error, reason} ->
        Logger.error("Decryption failed: #{reason}")
        {:error, reason}
    end
  end

  # Ensure we're always working with binaries
  def decrypt(payload, decryption_key) when is_binary(payload) do
    decrypt(payload, to_string(decryption_key))
  end

  def decrypt(payload, decryption_key) when is_binary(decryption_key) do
    decrypt(to_string(payload), decryption_key)
  end

  def decrypt(payload, decryption_key) do
    decrypt(to_string(payload), to_string(decryption_key))
  end

  @spec split_payload(binary()) :: {:ok, {binary(), binary()}} | {:error, String.t()}
  defp split_payload(payload) do
    case String.split(payload, ".", parts: 2) do
      [iv, cipher_text] -> {:ok, {iv, cipher_text}}
      _ -> {:error, "Invalid payload format"}
    end
  end

  @spec decode_base64(binary()) :: {:ok, binary()} | {:error, String.t()}
  defp decode_base64(string) do
    try do
      {:ok, Base.decode64!(string)}
    rescue
      _ -> {:error, "Invalid base64 encoding"}
    end
  end

  @spec create_key(binary()) :: {:ok, binary()} | {:error, String.t()}
  defp create_key(decryption_key) do
    try do
      # Direct Base64 decoding without converting to char list
      {:ok, Base.decode64!(decryption_key)}
    rescue
      e in ArgumentError ->
        Logger.error("Invalid decryption key: #{Exception.message(e)}")
        {:error, "Invalid decryption key"}

      _ ->
        {:error, "Invalid decryption key"}
    end
  end

  @spec do_decrypt(binary(), binary(), binary()) :: {:ok, binary()} | {:error, String.t()}
  defp do_decrypt(cipher_text, key, iv) do
    try do
      plain_text = :crypto.crypto_one_time(:aes_cbc, key, iv, cipher_text, false)
      # Remove PKCS7 padding
      unpadded = unpad_pkcs7(plain_text)
      {:ok, unpadded}
    rescue
      e ->
        Logger.error("Decryption failed: #{Exception.message(e)}")
        {:error, "Decryption failed"}
    end
  end

  @spec validate_utf8(binary()) :: {:ok, binary()} | {:error, String.t()}
  defp validate_utf8(text) do
    if String.valid?(text) do
      {:ok, text}
    else
      {:error, "Invalid UTF-8 encoding"}
    end
  end

  @spec unpad_pkcs7(binary()) :: binary()
  defp unpad_pkcs7(data) do
    # Get the last byte which contains the padding length
    padding_length = :binary.last(data)

    # Remove the padding
    binary_part(data, 0, byte_size(data) - padding_length)
  end
end