Encryptor

CI Hex.pm Version Hex Downloads Hex Docs License

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 Encryption SDK's format, which the official SDKs in other languages read, as Compatibility says.

Install

Add encryptor to the dependencies in your mix.exs:

def deps do
  [
    {:encryptor, "~> 0.8.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 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.

Documentation

Compatibility

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.

Security review

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.

License

Apache-2.0 - see LICENSE.