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

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.