Current section
Files
Jump to
Current section
Files
README.md
# Encryptor
[](https://github.com/riddler/encryptor/actions/workflows/ci.yml)
[](https://hex.pm/packages/encryptor)
[](https://hex.pm/packages/encryptor)
[](https://hexdocs.pm/encryptor/)
[](https://github.com/riddler/encryptor/blob/main/LICENSE)
> **Pre-1.0.** Until `encryptor` reaches v1.0, its public surface may change
> between minor releases, sometimes drastically: a release may rename modules,
> callbacks, table columns, telemetry events or error vocabulary with no
> compatibility shim. Every such change is recorded in
> [CHANGELOG.md](CHANGELOG.md) under a bold **Breaking** heading that says what
> to do about it. Pinning to an exact minor - `~> X.Y` - is the recommended way
> to consume the package until 1.0.
Ergonomic envelope encryption for Elixir - a vault module, pluggable key
providers, and per-tenant keys - on the
[aws_encryption_sdk](https://hex.pm/packages/aws_encryption_sdk) engine.
## What this is
Application-level encryption in Elixir usually arrives as one of two things: a
thin wrapper over `:crypto` that leaves key management to the caller, or a
full ESDK client whose surface is shaped for the cryptography rather than for
the application. Neither answers the questions a real application asks - which
key does this tenant's data use, how does that key rotate without a migration,
where does the key material actually come from. This package is the layer that
answers them.
- **A vault module is the surface.** `use Encryptor.Vault, otp_app: :my_app`
gives a supervised client, configured from application config and frozen at
start, with `encrypt`/`decrypt`/`rekey`/`derive` entry points a call site
uses without naming a keyring, a client, or a cryptographic materials
manager. Consumers never type the engine's namespace.
- **Key providers are a behaviour.** Where key material comes from - config, a
wrapped-key column, a KMS call - is an adapter behind one contract
(`Encryptor.Provider`), so call sites do not change when the source does.
- **Per-tenant keys and rotation are first-class.** A ciphertext records which
key wrote it, decryption resolves the key it names, and rotation is
re-encryption against a new version rather than a flag day. On a
material-source provider a tenant master key is 32 random bytes rather than a
derivation of the tenant id, so destroying its wrapping destroys the key and a
crypto-shred is honest. On the keyring-backed provider
(`Encryptor.Provider.Kms`) the wrapping key never leaves AWS KMS, so the shred
is `ScheduleKeyDeletion` on the tenant's key and the pending-deletion window
is not a reprieve - ADR-0008 decision 4 states the difference row by row and
the runbook [reproduces its
table](guides/rotation-runbook.md#the-shred-and-the-rotate-per-key-shape).
- **The message format stays the AWS ESDK's.** Ciphertexts are interoperable
with the official ESDKs, so data written from Elixir is readable from Java,
Python, JavaScript, or the AWS CLI, and vice versa.
Raw-keyring usage pulls in no AWS, HTTP, or XML libraries; only KMS-backed
providers bring that stack in.
## The security model in brief
A three-level key hierarchy (ADR-0003):
| Level | What | Where it lives |
|---|---|---|
| 1, root key | one per deployment | your secrets manager; never encrypts application data |
| 2, tenant master key | one per tenant per version | 32 random bytes, wrapped by level 1, stored in your key store |
| 3, data key | one per message | generated by the engine, wrapped by level 2, discarded |
The properties that follow from it, and that this package enforces rather than
documents:
- **Key material arrives through `init/1` and only through `init/1`.** A `use`
option named `:key`, `:keys`, `:root_key`, `:private_key`, `:passphrase` or
`:reference_subkey` fails **compilation**, because by the time a vault
starts, such a secret is already baked into a `.beam` file.
- **The encryption context binds a message to where it was written.** It rides
in the clear, covered by the header authentication tag, and a vault composes
and enforces it (ADR-0004). A vault may require keys - `table`, `column` -
and refuses a write that omits one rather than writing it unbound.
- **Anti-substitution is this package's own property.** The engine's warm
decryption cache can bypass reproduced-context validation
([upstream #96](https://github.com/riddler/aws-encryption-sdk-elixir/issues/96)),
so the comparison is performed here, above the engine.
- **Decrypt failures collapse.** Every message-dependent decrypt failure -
wrong key, failed tag, context mismatch, commitment rejection - returns
`reason: :decrypt_failed`, with the detail in `:engine` for logs only.
Distinguishable decrypt failures are a decryption oracle. Failures that
depend only on caller arguments stay distinct.
- **Nothing key-shaped is ever rendered.** `Exception.message/1` renders the
reason only, never `:engine`, and never the detail of a reason that can hold
key material.
- **Key derivation is HKDF-SHA256, labelled in one place.** `Encryptor.Kdf`
composes every label as `"encryptor/" <> version <> "/" <> purpose`; a call
site cannot spell the namespace by hand.
## Installation
```elixir
def deps do
[
{:encryptor, "== 0.4.0"}
]
end
```
Read the changelog before upgrading. Per the pre-1.0 notice above, the public
surface (modules, callbacks, table columns, telemetry events, error vocabulary)
may change between minor releases with no compatibility shim, and every such
change is recorded under a bold **Breaking** heading saying what to do about
it. **Do not depend on `encryptor 0.1.0`** - that version is a name
reservation published before the implementation existed and holds no code;
0.2.0 is the first release that does.
Requires Elixir ~> 1.18.
## Quickstart
A single-key vault, for an application encrypting its own columns. This is
card processing: one payments application storing card data for its own use.
```elixir
defmodule MyApp.Vault do
use Encryptor.Vault, otp_app: :my_app
@impl true
def init(config) do
key = Base.decode64!(System.fetch_env!("MY_APP_CARD_KEY"))
{:ok,
Keyword.put(config, :provider,
{Encryptor.Provider.Static,
key: key, namespace: "acme_payments", name: "card/v1"})}
end
end
```
```elixir
# config/config.exs
config :my_app, MyApp.Vault,
context_profile: :single,
algorithm_suite_id: 0x0478,
required_context: ["table", "column"],
static_encryption_context: %{"app" => "acme_payments"},
cache: [max_age: 60]
```
Add `MyApp.Vault` to your supervision tree, then:
```elixir
context = %{"table" => "payment_methods", "column" => "number"}
{:ok, ciphertext} = MyApp.Vault.encrypt(card_number, encryption_context: context)
{:ok, ^card_number} = MyApp.Vault.decrypt(ciphertext, encryption_context: context)
```
`ciphertext` is the complete self-describing ESDK message and nothing else.
You store that one binary; there is no second column to keep in step with it.
`Encryptor.Message.describe/1` reads what it says about itself, without a key
and without verifying it:
```elixir
{:ok, info} = Encryptor.Message.describe(ciphertext)
info.encryption_context
#=> %{"app" => "acme_payments", "column" => "number", "table" => "payment_methods"}
info.committed?
#=> true
info.encrypted_data_keys
#=> [%{key_name: "card/v1", provider_id: "acme_payments"}]
```
Two refusals worth seeing, because they are the model working:
```elixir
MyApp.Vault.encrypt(card_number, encryption_context: %{"table" => "payment_methods"})
#=> {:error, %Encryptor.Error{reason: {:missing_required_context_keys, ["column"]}}}
MyApp.Vault.decrypt(ciphertext, encryption_context: %{"table" => "t", "column" => "c"})
#=> {:error, %Encryptor.Error{reason: :decrypt_failed}}
```
Three configuration notes the quickstart above is making silently:
- `:context_profile` and `:provider` have **no defaults**, because there is no
defensible guess at whether a vault is per-tenant or at where key material
comes from.
- `0x0478` keeps key commitment and drops ECDSA P-384 signing. Configure it
when the writer and the reader are the same trust domain, which the
encrypted-column case is. Keep the default `0x0578` when a ciphertext
crosses a trust boundary.
- `:max_age` is **required** whenever `:cache` is a list, in seconds, with no
default. It is how long a data key may stay in this node's memory, and
therefore how long a crypto-shred takes to take effect on a material-source
provider. On the keyring-backed path the shred is a KMS key deletion and its
own window bounds it, not `max_age`; see the [per-shape
table](guides/rotation-runbook.md#the-shred-and-the-rotate-per-key-shape).
The [getting-started guide](guides/getting-started.md) continues from here
into the per-tenant vault, the two root secrets a deployment provisions on day
one, and why the context must carry nothing that varies per row.
## What the package contains
| Module | What it is |
|---|---|
| `Encryptor.Vault` | The surface: the `use` macro, the supervision tree, the five-layer config resolution and its freeze, `encrypt/2`, `decrypt/2`, `rekey/2`, `derive/2`, bang variants, `config/0`, `started?/0` |
| `Encryptor.Provider` | The key-provider behaviour: a provider resolves a selector to key descriptors, and the vault alone turns descriptors into a keyring |
| `Encryptor.Provider.Static` / `.Function` | The two shipped adapters - keys held in configuration, and keys resolved by a function |
| `Encryptor.Provider.Conformance` | The behaviour's test suite, `use`-able against your own adapter: state, buildable descriptors, candidate ordering, distinct names, stability, unknown selectors |
| `Encryptor.Envelope` | The level 1 to level 2 relationship: `provision/3`, `unwrap/2`, `rewrap/2`, `tenant_ref/2` |
| `Encryptor.Kdf` | HKDF-SHA256: `label/1`, `derive_subkey/3`, `expand/3`, `extract/2`, `salted_subkey/5` |
| `Encryptor.Key` | The closed set of key descriptors, with `Aes` and `Kms` |
| `Encryptor.Message` | `describe/1` and its `Info` struct |
| `Encryptor.Error` | The one error struct and its closed reason vocabulary |
The materials cache is bounded by a recycler that drops the whole table on an
interval (`:recycle_after`, defaulting to `20 * max_age`), because the
engine's `LocalCache` has no capacity limit and cannot be substituted through
the cache behaviour
([upstream #95](https://github.com/riddler/aws-encryption-sdk-elixir/issues/95)).
Every entry is re-fetchable derived material, so the worst outcome of a
recycle is a cold miss.
### Derived subkeys
`derive/2` on your vault module (`Encryptor.Vault.derive/3` underneath) hands a
downstream library purpose-separated bytes from a tenant's key material without
handing over the material:
```elixir
{:ok, index_key} = MyApp.TenantVault.derive("blind-index", key: merchant_id, info: "email")
```
PRK = HKDF-Extract(:derivation_salt, key material)
purpose_key = HKDF-Expand(PRK, "encryptor/v1/<purpose>", 32)
derived = HKDF-Expand(purpose_key, info, length)
The salt is the vault's `:derivation_salt` and a caller cannot supply or
override it, so two deployments provisioned from the same tenant key material
derive unrelated subkeys. A vault configured without one starts normally and
fails this call with `{:missing_config, [:derivation_salt]}`.
This surface hides the key material from the caller; it does not create a
search-only capability. A component that can derive a tenant's index key holds
that tenant's master key and can therefore also decrypt.
**Rotating `:derivation_salt` is a full reindex.** Every value ever derived
under the old salt changes, so every stored blind index, and anything else
built from a derived subkey, must be recomputed from plaintext. Treat the salt
as pinned for the life of the deployment.
### Slow hashing for a blind index
`Encryptor.Kdf.slow_hash/3` is an Argon2id pre-hash of a *value*, for a
downstream blind index over low-entropy plaintext where a plain HMAC is
guessable. It returns 32 raw bytes for the consumer to feed an HMAC, and it is
the one function in that module that does not derive a key: it takes no
purpose, composes no label, and nothing this package holds is recoverable from
its output.
Its parameters are the vault's, under an optional `:slow_hash` key, so the
choice is one operator decision rather than one per call site:
```elixir
use Encryptor.Vault,
otp_app: :my_app,
slow_hash: [memory_kib: 65_536, iterations: 3, parallelism: 1]
```
Those are also the defaults, and a partially declared set is completed with
them at start. `:memory_kib` is a power of two of at least 32_768; the set is
readable through `MyVault.config/0` and passed straight through. Unlike
`:derivation_salt` it is not secret and is not refused in `use` options - it
must be *identical* everywhere a given index is written or read.
The salt is the caller's and must be deterministic and at least 16 bytes; the
recommended construction is `derive/2` under the index's own identity, which
is already salted per deployment.
The dependency is optional:
```elixir
{:argon2_elixir, "~> 4.0"}
```
A host whose vaults declare no `:slow_hash` carries no NIF. A vault that
declares one without the dependency present refuses to start with
`{:missing_optional_dependency, :argon2_elixir}`, and a direct call in a build
without it raises.
**Retuning the parameters invalidates every value hashed under the old ones**,
and this package cannot detect it - the output carries nothing about the
parameters that produced it. Treat a `:slow_hash` change the way you treat a
`:derivation_salt` rotation.
## Not yet
- **Telemetry.** ADR-0006 is accepted (2026-09-13), but its event set is not yet
implemented: no events are emitted. Do not build dashboards against it yet.
## Documentation
- **[Getting started](guides/getting-started.md)** - a single-key vault and a
per-tenant vault, where key material is allowed to come from, why a host
chooses `0x0478`, why `max_age` has no default, and the two root secrets a
deployment provisions on day one.
- **[Secrets at start](guides/secrets-at-start.md)** - where key material is
allowed to come from and how it gets there: reading the environment or a
secrets manager in `init/1`, what each way of getting it wrong looks like at
start, and why sourcing is the vault's job and not its provider's.
- **[Selector boundaries](guides/selector-boundaries.md)** - what a selector
is, how to choose the boundary it names, what one key per boundary buys,
the rotate/suspend/shred verb table, and cryptographic erasure at its true
strength.
- **[Rotation runbook](guides/rotation-runbook.md)** - the five operator
procedures, including suspend and reinstate; what each step destroys, which
steps this package ships as functions and which are actions on a store it
does not own, what a crypto-shred does and does not achieve, how the shred
and the rotate read per key shape, and the GCP operator section (the ring and
the IAM bindings out of band, the Terraform destroy-time hazard, and P3 step
2a).
- **[CHANGELOG](CHANGELOG.md)** - read it before every upgrade until 1.0.0.
### Decision records
Every cryptographic choice here is an ADR decision. A key-derivation scheme,
an encryption-context field, a ciphertext layout, or an algorithm suite chosen
inline in an implementation is a defect even when the choice happens to be a
good one, because the record is what makes it reviewable.
| Record | Decides | Status |
|---|---|---|
| [ADR-0001](https://github.com/riddler/encryptor/blob/main/docs/adr/0001-vault-layer.md) | The vault layer: one host-owned module that wraps the engine completely, what it supervises, how it is configured, how its cache is bounded, and its error vocabulary | accepted |
| [ADR-0002](https://github.com/riddler/encryptor/blob/main/docs/adr/0002-key-providers.md) | The key-provider behaviour: a provider resolves a selector to a key descriptor, and only the vault turns a descriptor into a keyring | accepted, amended |
| [ADR-0003](https://github.com/riddler/encryptor/blob/main/docs/adr/0003-per-tenant-envelope.md) | The per-tenant envelope: a tenant key is 32 random bytes wrapped into an ordinary message, and the host stores the wrapping | accepted, amended |
| [ADR-0004](https://github.com/riddler/encryptor/blob/main/docs/adr/0004-encryption-context.md) | The encryption-context convention: the canonical keys, who supplies each, and how a vault enforces them | accepted, amended |
| [ADR-0005](https://github.com/riddler/encryptor/blob/main/docs/adr/0005-rotation-and-crypto-shred.md) | Rotation and crypto-shred: three independent lifecycles, five operator procedures, and the one step that cannot be undone | accepted, amended |
| [ADR-0006](https://github.com/riddler/encryptor/blob/main/docs/adr/0006-telemetry-and-observability.md) | Telemetry: a closed event set whose metadata is an allow-list, and nothing key-shaped is ever in it | accepted |
| [ADR-0007](https://github.com/riddler/encryptor/blob/main/docs/adr/0007-gcp-kms-wrap-provider.md) | GCP KMS: a wrap-provider rather than a keyring, owning the tenant key's whole lifecycle from `provision/2` to the destroyed key version | accepted |
| [ADR-0008](https://github.com/riddler/encryptor/blob/main/docs/adr/0008-aws-kms-keyring-backed.md) | AWS KMS: the keyring-backed row, where the descriptor carries the client and the data key never leaves KMS | accepted |
The index, including the citation grammar for cross-repo references, is
[`docs/adr/README.md`](https://github.com/riddler/encryptor/blob/main/docs/adr/README.md).
## The family
| Package | Owns |
|---|---|
| `encryptor` (here) | The vault surface, the key-provider behaviour, the envelope and key-derivation scheme, the encryption-context convention, the rotation model |
| [`encryptor_ecto`](https://github.com/riddler/encryptor_ecto) | The Ecto types, the schema conventions, the wrapped-key storage and its migration, the re-encryption migrator |
The split is deliberate and it is a boundary, not a layering convenience: no
function in this package takes a repo, a query, a table, or a batch size, and
this package defines no storage schema at all.
## Engine notes
The design is written against `aws_encryption_sdk` v1.0.0 as published, with
module paths cited so every claim can be re-checked. Two upstream issues are
open and this package works around both until they move:
- [#95](https://github.com/riddler/aws-encryption-sdk-elixir/issues/95) - the
materials cache is unbounded and is not substitutable through the cache
behaviour, so this package bounds it by recycling the cache process.
- [#96](https://github.com/riddler/aws-encryption-sdk-elixir/issues/96) - a
warm decryption cache bypasses reproduced-context validation, so this
package performs the value comparison itself, above the engine.
## Contributing
The full quality gate is `mix quality`; the inner loop is
`mix quality --profile loop`. The gate must be green before any commit, and
the format stage runs in check mode, so run `mix format` yourself first.
Read the decision records before writing code here. Until a contract is fixed
by an accepted record, it is open - and stopping to ask is the correct move.
## License
Apache-2.0 - see
[LICENSE](https://github.com/riddler/encryptor/blob/main/LICENSE).