# Kryptex
<img width="100%" height="100%" alt="cryptex" src="https://github.com/user-attachments/assets/e5ec532e-69b6-4e3a-83ec-2417ca26c52b" />
<br />
<br />
`Kryptex` is an Elixir package for field-level encryption in Phoenix/Ecto apps.
- `AES-256-GCM` for authenticated encryption
- random IV per encryption call
- key id embedded in ciphertext for key rotation safety
- encryption/decryption performed through an Ecto custom type
- a **`Kryptex.Plug`** you can mount in a Phoenix endpoint (or router pipeline) so misconfigured keys fail at request time and the active encryption key id is available on `conn.assigns`
## Table of contents
- [Why this approach](#why-this-approach)
- [Install](#install)
- [Quickstart](#quickstart)
- [Configure keys](#configure-keys)
- [Key rotation example](#key-rotation-example)
- [Step 1: Add a new key and switch writes to it](#step-1-add-a-new-key-and-switch-writes-to-it)
- [Step 2: Re-encrypt old rows in the background (optional but recommended)](#step-2-re-encrypt-old-rows-in-the-background-optional-but-recommended)
- [Step 3: Retire old keys only after verification](#step-3-retire-old-keys-only-after-verification)
- [Configure your own model fields](#configure-your-own-model-fields)
- [Postgres support out of the box](#postgres-support-out-of-the-box)
- [Phoenix Plug (`Kryptex.Plug`)](#phoenix-plug-kryptexplug)
- [Add the plug to a Phoenix app (endpoint)](#add-the-plug-to-a-phoenix-app-endpoint)
- [Add the plug to a router pipeline (optional)](#add-the-plug-to-a-router-pipeline-optional)
- [Reading the assign in plugs, controllers, or LiveView](#reading-the-assign-in-plugs-controllers-or-liveview)
- [Development](#development)
- [Security notes](#security-notes)
- [License](#license)
## Why this approach
In this package, encryption happens in custom Ecto type callbacks (`dump/load`), and builds on top of that using `Ecto.ParameterizedType` so each schema can configure encrypted fields directly.
## Install
Add dependency:
```elixir
defp deps do
[
{:kryptex, "~> 0.1.0"}
]
end
```
Then run:
```bash
mix deps.get
```
## Quickstart
This walkthrough encrypts `email`, `full_name`, and `metadata` on a `users` table. Complete [Install](#install) first, then follow the steps below.
### Configure keys
Add a data-encryption key (DEK) to your environment, then wire the keyring in `config/runtime.exs` (recommended):
```bash
# generate a 32-byte key (base64) and export it, e.g. in .env or your deploy secrets
export KRYPTEX_DEK_1="$(mix run -e 'IO.puts(:crypto.strong_rand_bytes(32) |> Base.encode64())')"
```
```elixir
config :kryptex,
keys: [
%{id: 1, key: System.fetch_env!("KRYPTEX_DEK_1")}
],
default_key_id: 1
```
Keys can be either base64-encoded 32-byte values or raw 32-byte binaries. Generate one in an IEx session:
```elixir
:crypto.strong_rand_bytes(32) |> Base.encode64()
```
For multiple keys and rotation, see [Key rotation example](#key-rotation-example).
### Migration
Encrypted values are stored as `bytea` in PostgreSQL. Use `Kryptex.PostgresMigration` when creating the table:
```elixir
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
use Kryptex.PostgresMigration
def change do
create table(:users) do
add_encrypted :email, null: false
add_encrypted :full_name
add_encrypted :metadata
timestamps()
end
end
end
```
Run the migration:
```bash
mix ecto.migrate
```
### Schema
Declare encrypted fields on your schema with `Kryptex.Schema`:
```elixir
defmodule MyApp.Accounts.User do
use Ecto.Schema
use Kryptex.Schema
schema "users" do
encrypted_field :email, :string
encrypted_field :full_name, :string
encrypted_field :metadata, :map
end
end
```
At runtime, Ecto encrypts on `dump` and decrypts on `load`—your application code works with normal Elixir values:
```elixir
user =
%MyApp.Accounts.User{}
|> Ecto.Changeset.change(%{
email: "alice@example.com",
full_name: "Alice",
metadata: %{"plan" => "pro"}
})
|> MyApp.Repo.insert!()
MyApp.Repo.get!(MyApp.Accounts.User, user.id).email
# => "alice@example.com"
```
### Phoenix plug (optional)
In a Phoenix app, mount `Kryptex.Plug` on the endpoint so misconfigured keys fail at request time and `conn.assigns.kryptex_key_id` reflects the active write key:
```elixir
# lib/my_app_web/endpoint.ex
plug Kryptex.Plug
```
See [Phoenix Plug (`Kryptex.Plug`)](#phoenix-plug-kryptexplug) for router pipelines and reading the assign.
## Key rotation example
Kryptex writes new ciphertext with `default_key_id`, and reads old ciphertext
with the embedded `key_id` inside each stored payload.
That means rotation is usually a 3-step rollout:
### Step 1: Add a new key and switch writes to it
Current config:
```elixir
config :kryptex,
keys: [
%{id: 1, key: System.fetch_env!("KRYPTEX_DEK_1")},
%{id: 2, key: System.fetch_env!("KRYPTEX_DEK_2")}
],
default_key_id: 2
```
Rotate by adding key `3`, then switch the default:
```elixir
config :kryptex,
keys: [
%{id: 1, key: System.fetch_env!("KRYPTEX_DEK_1")},
%{id: 2, key: System.fetch_env!("KRYPTEX_DEK_2")},
%{id: 3, key: System.fetch_env!("KRYPTEX_DEK_3")}
],
default_key_id: 3
```
After deploy:
- new inserts/updates are encrypted with key `3`
- existing rows encrypted with keys `1` and `2` still decrypt correctly
### Step 2: Re-encrypt old rows in the background (optional but recommended)
If you want all rows on the latest key, run a backfill job that:
1. loads records in batches,
2. rewrites encrypted fields with the same logical values,
3. persists records so Kryptex re-dumps with the new `default_key_id`.
Pseudo-flow:
```elixir
for user <- MyApp.Repo.stream(MyApp.Accounts.User) do
user
|> Ecto.Changeset.change(%{
email: user.email,
full_name: user.full_name,
metadata: user.metadata
})
|> MyApp.Repo.update!()
end
```
This keeps plaintext unchanged while forcing re-encryption with key `3`.
### Step 3: Retire old keys only after verification
Before removing old keys:
- verify backfill completion
- verify no payloads remain for older key ids (from logs/DB sampling)
- verify reads in production for a safe window
Then remove the old key(s) from config:
```elixir
config :kryptex,
keys: [
%{id: 3, key: System.fetch_env!("KRYPTEX_DEK_3")}
],
default_key_id: 3
```
Important: if any row still has `key_id` 1 or 2, removing those keys will make
those rows undecryptable.
## Configure your own model fields
Any schema can choose which fields are encrypted (see [Quickstart](#quickstart) for a full example).
```elixir
defmodule MyApp.Accounts.User do
use Ecto.Schema
use Kryptex.Schema
schema "users" do
encrypted_field :email, :string
encrypted_field :full_name, :string
encrypted_field :metadata, :map
end
end
```
You can also skip the macro and declare directly:
```elixir
field :email, Kryptex.EncryptedField, source_type: :string
```
## Postgres support out of the box
Encrypted data is stored as `:binary` in Ecto, which maps to `bytea` in PostgreSQL. The [Quickstart](#quickstart) migration example is the usual starting point; details below:
Migration example:
```elixir
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
use Kryptex.PostgresMigration
def change do
create table(:users) do
add_encrypted :email, null: false
add_encrypted :full_name
add_encrypted :metadata
timestamps()
end
end
end
```
## Phoenix Plug (`Kryptex.Plug`)
Field encryption is handled by **`Kryptex.EncryptedField`** in your Ecto schemas (see [Quickstart](#quickstart)).
**`Kryptex.Plug`** is optional but useful in Phoenix: on each request it resolves the keyring (so missing or invalid `:kryptex` config surfaces immediately) and sets **`conn.assigns.kryptex_key_id`** to the id of the key used for **new** ciphertext (`default_key_id`).
It is a plain **Plug** (`Plug.Conn`), so it works anywhere in the Plug stack; in Phoenix the usual place is your **endpoint**, early in the plug chain.
### Add the plug to a Phoenix app (endpoint)
In `lib/my_app_web/endpoint.ex`, after the early instrumentation plugs and before parsers/session/router is a common choice:
```elixir
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ... existing plugs, e.g. RequestId, Telemetry ...
plug Kryptex.Plug
# ... Plug.Parsers, Plug.Session, MyAppWeb.Router, etc. ...
end
```
Ensure **`config :kryptex, ...`** is loaded in `config/runtime.exs` (or equivalent) in all environments where the endpoint runs, so `Kryptex.Keyring` can read keys at runtime.
### Add the plug to a router pipeline (optional)
If you only want the check on certain scopes (e.g. browser or API), you can plug it in a **pipeline** in `lib/my_app_web/router.ex` instead of (or in addition to) the endpoint. Putting it on the endpoint is usually simpler so every request sees the same keyring state.
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug Kryptex.Plug
# ... fetch_session, protect_from_forgery, etc.
end
scope "/", MyAppWeb do
pipe_through :browser
# ...
end
end
```
### Reading the assign in plugs, controllers, or LiveView
After the plug runs, **`conn.assigns.kryptex_key_id`** is the integer key id used as “current” for encryption (same as `Kryptex.Keyring.current_key_id/0`). You can use it for logging, debugging, or passing metadata to telemetry.
## Development
Run tests:
```bash
mix test
```
Run Credo:
```bash
mix credo --strict
```
Generate docs:
```bash
mix docs
```
## Security notes
- Keep keys outside source control (environment variables or KMS).
- Rotate keys by appending a new key id and switching `default_key_id`.
- Existing rows remain decryptable because key id is preserved in payload.
- The GCM additional authenticated data (AAD) string is versioned with the library name; if you change it in a fork, existing ciphertext will not decrypt until you re-encrypt.
## License
MIT