Encryptor

CI Hex.pm Version Hex Downloads Hex Docs 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 under a bold Breaking heading that says what to do about it. Pinning to an exact minor - ~> X.Y.0 - 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 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.

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:

Installation

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.

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
# 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:

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:

{: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:

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:

The getting-started guide 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). 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:

{: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:

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:

{: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.

Telemetry

Documentation

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 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, amended
ADR-0002 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 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 The encryption-context convention: the canonical keys, who supplies each, and how a vault enforces them accepted, amended
ADR-0005 Rotation and crypto-shred: three independent lifecycles, five operator procedures, and the one step that cannot be undone accepted, amended
ADR-0006 Telemetry: a closed event set whose metadata is an allow-list, and nothing key-shaped is ever in it accepted, amended
ADR-0007 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 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.

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 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:

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.