Cloak.Ecto

repository·master·Indexed 42 days ago

https://github.com/danielberkompas/cloak_ecto

Provides transparent encryption for Ecto schema fields by leveraging the Cloak library to automatically encrypt data during database writes and decrypt it during reads. It allows the creation of custom Ecto.Type modules, supports key rotation via the `mix cloak.migrate` task, and offers closure wrapping to prevent plaintext leakage in logs and stacktraces.

Tokens
10.5K
Snippets
50
Records
56
Agent score
51%

What's inside Cloak.Ecto

  1. Security considerations for Cloak.Ecto

    master

    When using Cloak.Ecto, keep the following security constraints in mind:

    • Encrypted Data is Not Searchable: Because Cloak uses random Initialization Vectors (IVs) for each ciphertext, the same plaintext will result in different ciphertexts every time. Consequently, you cannot perform direct equality queries (e.g., WHERE encrypted_field = 'value') on these columns. To search encrypted data, you must implement hashed, searchable columns.
    • Runtime Data is Plaintext: Encryption only happens at rest in the database. Once the data is loaded into your Elixir application via Ecto, the values in your structs are in plaintext.
    • No User-Specific Keys via Ecto.Type: Due to the limitations of the Ecto.Type behaviour, you cannot use user-specific encryption keys directly within the schema field definition. If you need user-specific keys, you must use Cloak's ciphers manually within your application logic.
    • Supported Algorithms: Cloak relies on Erlang's :crypto module. Out-of-the-box supported algorithms include AES.GCM and AES.CTR.
  2. Set up local development with Docker

    master

    To develop cloak_ecto locally, use the provided Docker and docker-compose configuration to ensure the correct Elixir and Postgres versions are used.

    • Run tests in Docker: docker-compose run code bin/test
    • Access a Mix terminal: docker-compose run code bash
    # Runs the bin/test script in the context of Docker
    docker-compose run code bin/test
    
    # To access a terminal with mix
    docker-compose run code bash
  3. Configure Vault using environment variables

    master

    If you prefer to load your encryption key from an environment variable (e.g., CLOAK_KEY), implement the init/1 callback in your Vault module to configure the ciphers dynamically.

    defmodule MyApp.Vault do
      use Cloak.Vault, otp_app: :my_app
    
      @impl GenServer
      def init(config) do
        config =
          Keyword.put(config, :ciphers, [
            default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: decode_env!("CLOAK_KEY")}
          ])
    
        {:ok, config}
      end
    
      defp decode_env!(var) do
        var
        |> System.get_env()
        |> Base.decode64!()
      end
    end
  4. Unwrap plaintext values from a closure

    master

    When using an Ecto type with closure: true, you must unwrap the plaintext value by invoking the returned value as an anonymous function (using ()).

    Security Best Practice: When unwrapping values for sensitive operations (like authentication), use Plug.Crypto.prune_args_from_stacktrace/1 in your error handling. This prevents the plaintext value from being captured in the stacktrace if an exception occurs during or after the unwrapping process.

    def basic_auth(req, client) do
      # Unwrap string value from password closure by calling it with .
      HTTPClient.basic_auth(req, client.username, client.password.())
    rescue
      e ->
        # Prune stacktrace to prevent leakage of the unwrapped value in exceptions
        reraise e, Plug.Crypto.prune_args_from_stacktrace(System.stacktrace())
    end
  5. Generate encryption keys via IEx

    master

    The simplest way to generate a strong 256-bit encryption key for Cloak is to use the Elixir interactive shell (IEx) with the :crypto module. This method produces a key encoded in Base64.

    Run the following command in your terminal to start IEx, then execute the pipeline to generate the key:

    $ iex
    iex> 32 |> :crypto.strong_rand_bytes() |> Base.encode64()
    "HXCdm5z61eNgUpnXObJRv94k3JnKSrnfwppyb60nz6w="
  6. Implement encrypted fields in an Ecto Schema

    master

    To encrypt data, follow these steps:

    1. Migration: Add the field as a :binary type in your migration.
    2. Schema: Use your custom encrypted type (e.g., MyApp.Encrypted.Binary) for the field.
    3. Searching (Optional): To make a field queryable, create a mirrored _hash field using Cloak.Ecto.HMAC and update the changeset to populate it.
    # Migration
    create table(:users) do
      add :email, :binary
      add :email_hash, :binary
      timestamps()
    end
    
    # Schema
    defmodule MyApp.Accounts.User do
      use Ecto.Schema
      import Ecto.Changeset
    
      schema "users" do
        field :email, MyApp.Encrypted.Binary
        field :email_hash, MyApp.Hashed.HMAC
        timestamps()
      end
    
      def changeset(struct, attrs \ %{}) do
        struct
        |> cast(attrs, [:email])
        |> put_hashed_fields()
      end
    
      defp put_hashed_fields(changeset) do
        changeset
        |> put_change(:email_hash, get_field(changeset, :email))
      end
    end
  7. Encrypt existing data in an Ecto project

    master

    When introducing Cloak to an existing project, you must migrate unencrypted fields to new encrypted fields. This process involves creating a vault, defining local Ecto types, adding duplicate encrypted fields to your schema, migrating the data, and finally replacing the original fields with the encrypted ones.

    Step 1: Setup Vault and Types

    1. Ensure a Cloak.Vault is configured as per the installation guide.
    2. Create local Ecto types for each field type you wish to encrypt (e.g., MyApp.Encrypted.Binary for strings or MyApp.Encrypted.Map for maps).

    Step 2: Add encrypted_* Fields

    Add new binary columns to your database table to hold the encrypted data. For example, to encrypt :name and :metadata:

    alter table(:users) do
      add :encrypted_name, :binary
      add :encrypted_metadata, :binary
    end

    Update your Ecto schema to include these new fields using your custom encrypted types. During the migration phase, use a temporary helper function in your changeset to keep the new fields in sync with the old ones:

    defmodule MyApp.Accounts.User do
      use Ecto.Schema
      import Ecto.Changeset
    
      schema "users" do
        field :name, :string
        field :encrypted_name, MyApp.Encrypted.Binary
        field :metadata, :map
        field :encrypted_metadata, MyApp.Encrypted.Map
      end
    
      def changeset(struct, attrs \ %{}) do
        struct
        |> cast(attrs, [:name, :metadata])
        |> put_encrypted_fields()
      end
    
      # Temporary function to ensure changes are copied to new fields
      defp put_encrypted_fields(changeset) do
        changeset
        |> put_change(:encrypted_name, get_field(changeset, :name))
        |> put_change(:encrypted_metadata, get_field(changeset, :metadata))
      end
    end

    Step 3: Copy Data

    Run a script in a Mix task or IEx to migrate existing records to the new fields:

    MypApp.Accounts.User
    |> MyApp.Repo.all()
    |> Enum.map(fn user ->
      user
      |> Ecto.Changeset.change(%{
           encrypted_name: user.name,
           encrypted_metadata: user.metadata
         })
      |> MyApp.Repo.update!()
    end)

    Step 4: Remove Original Fields

    Once data is verified, create a migration to remove the unencrypted columns and rename the encrypted columns back to the original names:

    alter table(:users) do
      remove :name
      remove :metadata
    end
    
    rename table(:users), :encrypted_name, to: :name
    rename table(:users), :encrypted_metadata, to: :metadata

    Finally, clean up your schema by removing the temporary put_encrypted_fields/1 function and updating the field types to the encrypted versions:

    defmodule MyApp.Accounts.User do
      use Ecto.Schema
      import Ecto.Changeset
    
      schema "users" do
        field :name, MyApp.Encrypted.Binary
        field :metadata, MyApp.Encrypted.Map
      end
    
      def changeset(struct, attrs \ %{}) do
        struct
        |> cast(attrs, [:name, :metadata])
      end
    end
    # Full migration workflow is provided in the content summary above.
  8. Create and configure a Cloak.Vault

    master

    A Cloak.Vault manages your ciphers and keys. You must define a module using Cloak.Vault and add it to your application's supervision tree.

    Note: The :key must be the raw binary, so you must decode your Base64 key using Base.decode64!().

    # 1. Define the Vault
    defmodule MyApp.Vault do
      use Cloak.Vault, otp_app: :my_app
    end
    
    # 2. Configure the Vault in config/config.exs
    config :my_app, MyApp.Vault,
      ciphers: [
        default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: Base.decode64!("your-key-here")}
      ]
    
    # 3. Add to supervision tree in application.ex
    children = [
      MyApp.Vault
    ]
  9. Encrypt Ecto schema fields with Cloak.Ecto

    master

    Cloak.Ecto allows you to create custom Ecto.Type modules that automatically encrypt data before it is written to the database and decrypt it when it is read.

    To use it, follow these steps:

    1. Define an encrypted type module by using Cloak.Ecto.Binary (or other available types) and specifying your vault.
    2. Use that type in your Ecto schema definition.

    When you insert a record, the value is stored in the database as an encrypted binary blob. When you fetch the record, Ecto automatically decrypts the blob back into the original plaintext value.

    # 1. Define the encrypted type
    defmodule MyApp.Encrypted.Binary do
      use Cloak.Ecto.Binary, vault: MyApp.Vault
    end
    
    # 2. Use it in your schema
    defmodule MyApp.EctoSchema do
      use Ecto.Schema
    
      schema "table_name" do
        field :encrypted_field, MyApp.Encrypted.Binary
      end
    end
    
    # Usage example:
    # Inserting plaintext results in an encrypted blob in the DB
    Repo.insert!(%MyApp.EctoSchema{encrypted_field: "plaintext"})
    
    # Fetching results in the original plaintext
    Repo.get(MyApp.EctoSchema, 1)
    # => %MyApp.EctoSchema{encrypted_field: "plaintext"}