Skip to main content

lib/kryptex/encrypted_field.ex

defmodule Kryptex.EncryptedField do
  @moduledoc """
  Parameterized Ecto type that encrypts values on `dump/3` and decrypts on `load/3`.

  Use it directly:

      field :email, Kryptex.EncryptedField, source_type: :string

  Or through `Kryptex.Schema.encrypted_field/3`.
  """

  @behaviour Ecto.ParameterizedType

  alias Kryptex.Cipher

  @impl true
  def type(_params), do: :binary

  @impl true
  def init(opts) do
    source_type = Keyword.get(opts, :source_type, :string)

    unless valid_source_type?(source_type) do
      raise ArgumentError,
            "expected :source_type to be an Ecto.Type primitive/module, got: #{inspect(source_type)}"
    end

    %{source_type: source_type}
  end

  @impl true
  def cast(value, %{source_type: source_type}) do
    Ecto.Type.cast(source_type, value)
  end

  @impl true
  def load(ciphertext, _loader, %{source_type: source_type}) when is_binary(ciphertext) do
    with {:ok, serialized} <- Cipher.decrypt(ciphertext),
         {:ok, raw_term} <- safe_binary_to_term(serialized),
         {:ok, loaded} <- Ecto.Type.load(source_type, raw_term) do
      {:ok, loaded}
    else
      _ -> :error
    end
  end

  def load(_, _, _), do: :error

  @impl true
  def dump(value, dumper, %{source_type: source_type}) do
    with {:ok, dumped} <- Ecto.Type.dump(source_type, value, dumper),
         serialized <- :erlang.term_to_binary(dumped),
         {:ok, ciphertext} <- Cipher.encrypt(serialized) do
      {:ok, ciphertext}
    else
      _ -> :error
    end
  end

  @impl true
  def embed_as(_format, _params), do: :self

  @impl true
  def equal?(left, right, _params), do: left == right

  defp safe_binary_to_term(serialized) do
    {:ok, :erlang.binary_to_term(serialized, [:safe])}
  rescue
    _ -> :error
  end

  defp valid_source_type?(type) when is_atom(type), do: true
  defp valid_source_type?(_), do: false
end