Application-layer encryption for Elixir: a vault module your code calls, pluggable key providers, per-scope keys in envelopes, and rotation. It runs on the aws_encryption_sdk engine, so every ciphertext is a standard AWS Encryption SDK message.
Encrypting data before it reaches the database usually means choosing between
a thin wrapper over :crypto that leaves every key question to you, and a full
encryption SDK client whose surface is shaped for the cryptography rather than
for your application. Either way the questions a real application asks stay
open: which key does this record use, where does the key material come from,
and how does a key rotate without a migration. With this package your call
sites name one vault module and nothing else; where key material comes from is
an adapter behind one behaviour, so the source can change without the call
sites changing; each scope you key by (an account, a workspace) gets its own
key, which a ciphertext names, so rotation is re-encryption against a new
version and a crypto-shred destroys one scope's key; and the messages stay in
the AWS Encryption SDK's format, which the official SDKs in other languages
read, as Compatibility says.
Add encryptor to the dependencies in your mix.exs:
def deps do
[
{:encryptor, "~> 0.8.0"}
]
endRaw-keyring use pulls in no AWS, HTTP or XML library; only the KMS-backed
providers bring that stack in. Two dependencies are optional:
{:argon2_elixir, "~> 4.0"} for Encryptor.Kdf.slow_hash/3, and
{:goth, "~> 1.4"} for Encryptor.Provider.GcpKms.
A single-key vault encrypting one column. The key is 32 random bytes, Base64
in the environment, and it reaches the vault through init/1 at start: a use
option such as :key fails compilation.
defmodule MyApp.Vault do
use Encryptor.Vault,
otp_app: :my_app,
context_profile: :single,
required_context: ["table", "column"]
@impl true
def init(config) do
key = Base.decode64!(System.fetch_env!("MY_APP_VAULT_KEY"))
provider = {Encryptor.Provider.Static, key: key, namespace: "my_app", name: "data/v1"}
{:ok, Keyword.put(config, :provider, provider)}
end
end
# With MyApp.Vault in your supervision tree:
context = %{"table" => "notes", "column" => "body"}
{:ok, ciphertext} = MyApp.Vault.encrypt("a private note", encryption_context: context)
{:ok, "a private note"} = MyApp.Vault.decrypt(ciphertext, encryption_context: context)
# A write that leaves out a required context key is refused, not written unbound.
{:error, %Encryptor.Error{reason: {:missing_required_context_keys, ["column"]}}} =
MyApp.Vault.encrypt("a private note", encryption_context: %{"table" => "notes"})
# A decrypt naming another table or column fails, and every such failure looks the same.
{:error, %Encryptor.Error{reason: :decrypt_failed}} =
MyApp.Vault.decrypt(ciphertext, encryption_context: %{"table" => "t", "column" => "c"})ciphertext is the whole self-describing message: you store that one binary,
and there is no second column to keep in step with it.
- Learn
- Getting started: a single-key vault, then a scoped vault with one key per scope, and the two root secrets a deployment provisions on day one.
- Do
- How to source secrets at start: read key material from the environment or a secrets manager in
init/1, and what each mistake looks like at start. - How to rotate, retire, shred and suspend keys: the five operator procedures, what each step destroys, and the GCP operator section.
- Encrypt Ecto schema columns:
encryptor_ecto, the companion package with the Ecto types, the wrapped-key storage and the re-encryption migrator.
- How to source secrets at start: read key material from the environment or a secrets manager in
- Look up
- The vault: the functions
use Encryptor.Vaultgenerates, the lifecycle checks,derive/3,suspend/2andreinstate/2. - Vault configuration: the five-layer precedence chain, each option and what is checked at start, and choosing an algorithm suite.
- Key providers: the behaviour, its conformance suite, and the
Static,Function,KmsandGcpKmsadapters. - Key derivation: the HKDF trees, the label grammar, derived subkeys and the Argon2id slow hash.
- The materials cache bound: how
:recycle_afterbounds the engine's cache, and why dropping the table is safe. - Telemetry events: the closed event set, its measurements and its allow-listed metadata.
- Errors: the one error struct and its closed reason vocabulary.
- The changelog: what changed in each version, and what to do about each breaking change.
- The vault: the functions
- Understand
- The security model: keys, scopes and envelopes: the three levels of keys, why a scope's key is random and stored rather than derived, what the encryption context binds, why decrypt failures look alike, and what the model does not protect against.
- The threat model: what is protected, from whom, and how we know: the assets, the adversaries and the trust boundaries, the test or record behind each claim, the limits of AES-GCM, the known engine defects, and what the evidence was tested against.
- Choosing the scope: what a scope is, where to draw its boundary, the rotate, suspend and shred verbs, and what cryptographic erasure honestly achieves.
- The decision records: the record behind every cryptographic choice here, with an index of what each one decides.
The package needs Elixir 1.18 or later (elixir: "~> 1.18" in mix.exs). Its
runtime dependencies are aws_encryption_sdk ~> 1.1 and telemetry ~> 1.3;
argon2_elixir ~> 4.0 and goth ~> 1.4 are optional. CI runs the full
gate on Erlang/OTP 27 and the test suite on Erlang/OTP 26, both with Elixir
1.18. Ciphertexts are in the AWS Encryption SDK's message format, and a CI job
(python-interop) checks them against the official SDK for Python
(the test). A single-key and a per-scope vault's messages cross both
ways, at suites 0x0478 and 0x0578. aws_encryption_sdk 1.1 follows the
specification and stores no required context key in a message's header, so
Encryptor.Message.describe/1 does not show a vault's required pairs for a
message it writes; messages written on 1.0.x still decrypt and rekey. A 1.0.x
reader cannot read a message the 1.1 engine writes with required context, so
upgrade every reader before any writer.
One open engine issue is worked round here until it moves:
#95, an
unbounded materials cache, which the vault bounds by recycling it.
#96, a warm
decryption cache that skipped context validation, is fixed in
aws_encryption_sdk 1.1; the vault keeps its own comparison for every key a
header stores.
Until 1.0, the public surface may change between minor releases: a release may
rename modules, callbacks, telemetry events or error vocabulary with no
compatibility shim. Every such change is recorded in the
changelog under
a bold Breaking heading that says what to do about it, and pinning to an
exact minor, ~> X.Y.0, is the recommended way to take the package until
then. Do not depend on encryptor 0.1.0: it is a name reservation with no
code in it.
Tested against published vectors, self-reviewed, no formal third-party audit. The vectors are Wycheproof AES-GCM (316 cases, the 119 with an IV other than 96 bits asserted refused) and HKDF (86 SHA-256 cases, 83 each for SHA-384 and SHA-512), and the AWS Encryption SDK decrypt vectors (of the corpus's 9089, 661 expected to decrypt and 4240 expected to fail run here, with 2200 RSA and 1988 KMS vectors excluded by count); messages cross both ways with the AWS Encryption SDK for Python 4.0.7 and the Material Providers Library; the reviewers are the maintainer, who is the team's security lead, and an LLM adversarial pass with fresh context, not an independent engineer. The threat model says what each claim rests on, and the review ledger lists every finding and its disposition.
Apache-2.0 - see LICENSE.