CI Hex.pm Version Hex Downloads Hex Docs License

Status: pre-1.0. This package is under active development ahead of its 1.0.0 release, expected within the next few weeks. Until then, public APIs, storage formats, and derivation constants may change between releases without a deprecation cycle. Pin an exact version and review the changelog before upgrading.

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.

  • 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. 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.

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

LevelWhatWhere it lives
1, root keyone per deploymentyour secrets manager; never encrypts application data
2, tenant master keyone per tenant per version32 random bytes, wrapped by level 1, stored in your key store
3, data keyone per messagegenerated 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), 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

def deps do
  [
    {:encryptor, "== 0.2.0"}
  ]
end

Pin an exact version and read the changelog before upgrading: per the stability notice above, public APIs, storage formats, and derivation constants may change between releases until 1.0.0. 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:

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

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

ModuleWhat it is
Encryptor.VaultThe 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.ProviderThe key-provider behaviour: a provider resolves a selector to key descriptors, and the vault alone turns descriptors into a keyring
Encryptor.Provider.Static / .FunctionThe two shipped adapters - keys held in configuration, and keys resolved by a function
Encryptor.Provider.ConformanceThe behaviour's test suite, use-able against your own adapter: state, buildable descriptors, candidate ordering, distinct names, stability, unknown selectors
Encryptor.EnvelopeThe level 1 to level 2 relationship: provision/3, unwrap/2, rewrap/2, tenant_ref/2
Encryptor.KdfHKDF-SHA256: label/1, derive_subkey/3, expand/3, extract/2, salted_subkey/5
Encryptor.KeyThe closed set of key descriptors, with Aes and Kms
Encryptor.Messagedescribe/1 and its Info struct
Encryptor.ErrorThe 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.

Not yet

  • Argon2id. There is no slow-hash surface in this package: no code, no configuration, no dependency, and no accepted record naming one. A consumer needing a memory-hard derivation cannot get it here yet. Tracked as enc-dtv.
  • Telemetry. ADR-0006 is proposed, not accepted, and no events are emitted. Do not build dashboards against it yet.

Documentation

  • Getting started - 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.
  • Rotation runbook - the four operator procedures, what each step destroys, which steps this package ships as functions and which are actions on a store it does not own, and what a crypto-shred does and does not achieve.
  • CHANGELOG - 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.

RecordDecidesStatus
ADR-0001The 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 vocabularyaccepted
ADR-0002The key-provider behaviour: a provider resolves a selector to a key descriptor, and only the vault turns a descriptor into a keyringaccepted
ADR-0003The per-tenant envelope: a tenant key is 32 random bytes wrapped into an ordinary message, and the host stores the wrappingaccepted, amended
ADR-0004The encryption-context convention: the canonical keys, who supplies each, and how a vault enforces themaccepted, amended
ADR-0005Rotation and crypto-shred: three independent lifecycles, four operator procedures, and the one step that cannot be undoneaccepted
ADR-0006Telemetry: a closed event set whose metadata is an allow-list, and nothing key-shaped is ever in itproposed

The index, including the citation grammar for cross-repo references, is docs/adr/README.md.

The family

PackageOwns
encryptor (here)The vault surface, the key-provider behaviour, the envelope and key-derivation scheme, the encryption-context convention, the rotation model
encryptor_ectoThe 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 - the materials cache is unbounded and is not substitutable through the cache behaviour, so this package bounds it by recycling the cache process.
  • #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.