Encryptor
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.
Why encryptor
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 format, readable from the official SDKs in other languages.
Install
Add encryptor to the dependencies in your mix.exs:
def deps do
[
{:encryptor, "~> 0.6.0"}
]
end
Raw-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.
Basic usage
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 under any other context 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.
Documentation
- 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.
- 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.
Compatibility
The package needs Elixir 1.18 or later (elixir: "~> 1.18" in mix.exs). Its
runtime dependencies are aws_encryption_sdk ~> 1.0 and telemetry ~> 1.3;
argon2_elixir ~> 4.0 and goth ~> 1.4 are optional. Ciphertexts are
interoperable with the official AWS Encryption SDKs: data written from Elixir
is readable from Java, Python, JavaScript or the AWS CLI, and the other way
round.
Two open engine issues are worked round here until they move: #95, an unbounded materials cache, which the vault bounds by recycling it, and #96, a warm decryption cache that skips context validation, which the vault replaces with its own comparison.
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.
License
Apache-2.0 - see LICENSE.