Encrypted

Hex.pmHexDocsCILicense

Authenticated, non-deterministic encryption for binary Ecto fields.

Encrypted is an Ecto.ParameterizedType backed by a PostgreSQL bytea column. It encrypts every non-null value with AES-256-GCM, a fresh 96-bit IV, and a per-field key. Authentication binds the ciphertext to its table and storage column. Moving a ciphertext to another table or column therefore makes the load fail.

The package has one cipher and one envelope format. It uses OTP :crypto for all cryptographic operations and has no third-party cryptography dependency. It starts no process and stores no keys in ETS.

The package does not support deterministic encryption or encrypted queries. Use ecto_blind_search beside an encrypted field when the application must search it.

Installation

Add ecto_encrypted to mix.exs:

def deps do
[
{:ecto_encrypted, "~> 0.1"}
]
end

Generate a dedicated master key:

Encrypted.generate_key()

The result contains 64 lowercase hexadecimal digits. A configured key may instead contain the corresponding 32 raw bytes.

Runtime configuration

Configure a map of key IDs to master-key sources and select one active ID:

# config/runtime.exs
config :ecto_encrypted,
keys: %{
1 => {:system, "FIELD_ENCRYPTION_KEY_V1"},
2 => {:system, "FIELD_ENCRYPTION_KEY_V2"}
},
active_key_id: 2

Key IDs are integers from 0 through 255. Encryption always uses the active ID. Decryption reads the ID from the envelope and resolves that configured key. Keep an old key configured until every ciphertext has been rotated away from its ID.

Each key source may be:

The type resolves keys when it encrypts or decrypts data. It does not resolve a key while compiling the schema. A missing or malformed key raises Encrypted.Error, and the error does not include key material.

Applications may declare :keys and :active_key_id on one field when that field needs a separate ring:

field :medical_note, Encrypted,
keys: %{
7 => {:system, "MEDICAL_NOTE_KEY"}
},
active_key_id: 7,
redact: true

Prefer runtime application configuration for the common ring. A literal key in a schema becomes part of compiled application code.

Schema and migration

Store encrypted fields in nullable bytea columns:

def change do
alter table(:users) do
add :email, :binary, null: true
end
end

Declare the parameterized type and select its redaction behavior. Use redact: true for the normal secure behavior:

defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, Encrypted, redact: true
timestamps()
end
def changeset(user, attributes) do
cast(user, attributes, [:email])
end
end

Each encrypted field must set redact: true or redact: false. A schema-wide redaction policy does not replace this field option. This rule gives the same behavior across all supported Ecto versions. Schema compilation fails when the field omits the option.

Ecto holds plaintext in the loaded struct and changeset. redact: true keeps the field out of the schema's derived Inspect implementation, but it does not redact application logs that interpolate the plaintext directly. redact: false explicitly allows the plaintext in derived Inspect output.

nil passes through as nil. The type never encrypts nil. Empty binaries are encrypted and produce a valid envelope with an empty ciphertext segment. Non-binary values fail the Ecto cast.

Embedded schemas are not supported. Their JSON representation cannot carry arbitrary envelope bytes without a separate binary-encoding contract. Declaring an encrypted field inside embedded_schema raises while the schema compiles. Calling Ecto.Type.embedded_dump/3 for this type also raises. Store the encrypted value in a database :binary field.

Every dump generates a new random IV. Dumping the same plaintext twice therefore produces different bytes. Equality predicates over the encrypted column cannot find a plaintext value.

Compose with ecto_blind_search

Encryption and querying use separate columns and separate packages. The changeset computes the blind index from plaintext before Ecto dumps the encrypted field:

defmodule MyApp.User do
use Ecto.Schema
use EctoBlindSearch
import Ecto.Changeset
schema "users" do
field :email, Encrypted, redact: true
blind_index :email,
normalize: &__MODULE__.normalize_email/1
timestamps()
end
def changeset(user, attributes) do
user
|> cast(attributes, [:email])
|> validate_required([:email])
|> put_blind_indexes()
|> blind_unique_constraint(:email)
end
def normalize_email(email) do
email
|> String.trim()
|> String.downcase()
|> String.normalize(:nfc)
end
end

The matching migration uses one bytea ciphertext column and one blind-index column:

def change do
alter table(:users) do
add :email, :binary, null: true
add :email_bidx, :string, null: true
end
create unique_index(:users, [:email_bidx])
end

Query through the blind index:

Repo.get_by(MyApp.User, MyApp.User.blind_index(:email, "person@example.com"))

Use different master keys for encryption and blind indexes. The two packages also use different derivation domains, so a mistakenly shared master key does not produce the same field key. Domain separation does not make intentional key reuse acceptable. Separate master keys let operators rotate a leaked blind-index key without re-encrypting data, or rotate an encryption key without rebuilding indexes.

Envelope format

Every stored value uses the v1 binary envelope:

<<version::8, key_id::8, iv::binary-size(12),
tag::binary-size(16), ciphertext::binary>>
OffsetSizeValue
01 byteversion, currently 1
11 bytekey ID, 0..255
212 bytesrandom GCM IV
1416 bytesGCM authentication tag
30remaining bytesciphertext

The version byte is always first. Any future change to the cipher, key-ID width, tag, IV, derivation, or packing requires a new version. Unknown versions fail; the loader does not guess.

The GCM associated data consists of the version and key-ID bytes followed by a length-prefixed packing of:

["ecto_encrypted/aad/v1", table, storage_field]

The header bytes are included so changing the key ID also fails authentication. The packing starts with a little-endian 32-bit item count. Each item then has a little-endian 64-bit byte length followed by its bytes. Delimiter characters inside a table or field name therefore cannot make two contexts ambiguous.

FORMAT.md defines the byte-level contract, key derivation, and pinned regression vector.

Table and column renames

The default context uses schema.__schema__(:source) and the field's database source. Renaming either value changes the derived field key and associated data. Existing ciphertext then fails authentication.

Preserve the old context during a rename:

schema "renamed_users" do
field :renamed_email, Encrypted,
key_context: {"users", "email"},
redact: true
end

key_context must name the table and storage column that originally produced the ciphertext. It does not rename a database object.

Rotate encryption keys

Rotation changes the active write key and re-encrypts existing rows in place. Deploy in this order:

  1. Add the new key ID to the runtime key ring. Keep every old key present.

  2. Change active_key_id to the new ID and deploy that configuration to every writer.

  3. Confirm that no old application instance can still write with the old active ID.

  4. Re-encrypt the field:

    Encrypted.Rotate.run(
    MyApp.Repo,
    MyApp.User,
    :email,
    batch_size: 100
    )
  5. Run the same command again. A return value of 0 confirms that the scan found no row using an old key at that time.

  6. Remove the old key only after every field and every deployment scope that used its ID has completed the second scan.

The runner requires one primary key. It reads rows in primary-key order and uses one transaction per batch. Each update includes the primary key and the old ciphertext bytes in its WHERE clause. If another writer changes the field after the runner reads it, the bytes no longer match and the runner leaves the newer value intact. The next run examines that row again.

Scope a run with a query that contains only where clauses:

query = from(user in MyApp.User, where: user.tenant_id == ^tenant_id)
Encrypted.Rotate.run(
MyApp.Repo,
MyApp.User,
:email,
query: query,
batch_size: 50,
timeout: 30_000
)

The runner rejects joins, ordering, grouping, limits, offsets, explicit locks, and other structural expressions because they can change keyset pagination or the set of rows updated.

Failure semantics

Authentication failures are exceptions, not absent values. The type never turns tampering into nil and never skips a damaged row.

Encrypted.Error.reason identifies the failure:

ReasonMeaning and action
:authentication_failedThe ciphertext, context, or key does not match. Restore trusted bytes and verify the configured context and key.
:malformed_envelopeThe stored value is shorter than the v1 header. Restore the original ciphertext.
{:unknown_version, version}The running code does not support the stored version. Deploy compatible code or restore a compatible value.
{:unknown_key_id, id}The key ring does not contain the envelope's ID. Restore that key before loading or rotating the field.
:invalid_keyA resolved key is not 32 raw bytes or 64 hexadecimal digits. Correct the runtime secret.

ArgumentError reports programmer misuse such as an unknown type option, invalid key ID, unsupported rotation query shape, or invalid batch size.

Errors never include plaintext, key material, or a complete ciphertext. They may include a key ID or environment-variable name because operators need those identifiers to restore configuration.

Threat model

The package protects plaintext when an attacker obtains a database copy but not the master keys. AES-GCM also detects changes to the envelope, table context, or column context before returning plaintext.

Context binding prevents a database writer from moving a ciphertext to another table or column and obtaining a valid load. It does not bind the ciphertext to a row identity. An attacker may move a ciphertext between rows in the same column. The package also does not detect deletion or replay of an older valid ciphertext in the same column.

The envelope exposes the plaintext length because the ciphertext has the same length. It also exposes the format version and key ID. Database indexes, access logs, queries, and row relationships can expose additional metadata outside this type.

An attacker who obtains both the database and a master key can derive the affected per-field keys and decrypt those fields. An attacker who controls the running application can observe plaintext before encryption or after load. Application authorization, host security, backups, and log handling remain separate controls.

Random IVs make repeated plaintexts produce different ciphertexts. The package does not provide deterministic equality, frequency-hiding guarantees beyond that randomization, streaming encryption, or large-binary chunking.

Version 1 uses the random-IV construction from NIST SP 800-38D, Sections 8.2.2 and 8.3. NIST limits authenticated-encryption invocations to 2^32 for one derived field key across all application instances. The package does not count invocations. Rotate to new master-key material before one field reaches that ceiling. Assigning a new key ID to the same master-key bytes does not reset it. Per-field derivation gives each table and storage column an independent limit.

The package does not compress plaintext before encryption. Compression mixes attacker-controlled and secret input into a length side channel, as seen in CRIME-style attacks. Compress data only in a separate design that accounts for that leakage and defines its own envelope version.

Migrating from cloak_ecto

The v1 envelope cannot read a Cloak ciphertext. Migrate with two columns:

  1. Add a nullable bytea column for Encrypted.
  2. Deploy code that writes both the Cloak field and the new field.
  3. Backfill in batches by loading plaintext through the existing Cloak type and writing that plaintext through Encrypted.
  4. Compare row counts and application reads, then switch all reads to the new field.
  5. Stop writing the Cloak column. Remove it only after rollback no longer depends on it.

Encrypted.Rotate cannot perform this migration because it only reads this package's versioned envelope. A future adapter may add a read-only Cloak format reader, but v1 does not contain one.

Non-goals

Version 1 deliberately excludes:

Use a key resolver function or MFA when an application retrieves key bytes from an external secret service. The package itself does not own that service's lifecycle, credentials, retries, or cache.

Package name

The Hex package and OTP application use ecto_encrypted, which identifies the Ecto integration. The public type uses Encrypted, which keeps schema field declarations and error references short.