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