Encryptor.Vault behaviour (Encryptor v0.2.0)

Copy Markdown View Source

The vault: a host-owned module that wraps the engine completely.

A host writes one module and calls it from then on:

defmodule MyApp.Vault do
  use Encryptor.Vault, otp_app: :my_app
end

and adds it to a supervision tree:

children = [MyApp.Vault]

No host code names AwsEncryptionSdk or any module under it, no vault function accepts an engine struct, and no vault function returns one. The reason for total wrapping is not aesthetics: every decision the accepted records make - which suite, which commitment policy, which context keys are required, how a selector maps to a cache partition - is enforceable only if there is exactly one door (ADR-0001 decision 1).

What use captures

:otp_app and the module name, and nothing else. Everything else is read when the vault starts, through the five-layer precedence chain Encryptor.Vault.Config owns, and frozen into :persistent_term. Options written at use are layer 2 of that chain, not a configuration of their own: they are carried forward and can be overridden by application environment, by start_link/1, and by init/1.

Key material passed to use is a compile-time error, not a warning. A secret in use options is a secret compiled into a .beam file and committed to the host's build artifacts, and the vault refuses to be the reason that happens. The refusal is Encryptor.Vault.Config.validate_use_opts!/2, called while this macro expands (ADR-0001 decision 5).

What a vault supervises

Starting a vault starts a Supervisor (see Encryptor.Vault.Supervisor) whose children are:

  1. Encryptor.Vault.Lifecycle, which owns the frozen configuration's lifetime: it publishes the resolved struct when the vault starts and erases it when the vault stops,
  2. the vault's materials cache, when caching is configured on,
  3. the key provider, when the provider module exports child_spec/1.

Two consequences are deliberate. A vault configured with cache: false still starts, because a provider may need supervision even when the cache does not exist. And two vaults never share a cache process: the pair {otp_app, vault_module} is the whole configuration key, so nothing here is global and one vault's max_age never applies to another vault's materials (ADR-0001 decisions 2 and 3).

The lifecycle checks

A vault that is not running is a typed error, not a crash. Every entry point calls ready/2 first, which checks that the vault's supervisor is alive and reads the frozen configuration, and returns {:error, %Encryptor.Error{reason: {:vault_not_started, MyApp.Vault}}} rather than letting a call to an unregistered name raise an exit from inside a library. ensure_provider_started/2 is its sibling for a provider that has a process: {:provider_not_started, module} (ADR-0001 decision 2; ADR-0002 decision 6).

Neither is a rescue. Both are checks, which is what keeps them compatible with ADR-0001 decision 10's rule that this package never rescues an exception into an {:error, _}.

Generated functions

use Encryptor.Vault defines, on the host's module:

  • encrypt/2 and encrypt!/2 - the write half of the door,
  • decrypt/2 and decrypt!/2 - the read half,
  • rekey/2 and rekey!/2 - the rotation half: one message, re-encrypted under current materials with its context preserved byte for byte,
  • child_spec/1 and start_link/1 - the supervision-tree surface,
  • stop/0 - stops the vault and erases its frozen configuration,
  • config/0 - the frozen configuration, or {:vault_not_started, _},
  • started?/0 - whether the vault's supervisor is alive.

The optional init/1 callback is layer 5 of the precedence chain and the intended place to read key material out of the environment or a secrets manager, following the pattern hosts already know from Ecto.Repo.init/2.

All three entry points are built on ready/2 from here, which is why the lifecycle checks live in one place rather than three.

What the vault stores about a message: nothing

This module reads the engine's header in exactly one place, through Encryptor.Message, and rekey/2 is the reason it has to. A message carries its own encryption context, so a rotation needs no row, no table and no second copy of what the ciphertext was bound to. That property is the engine's deviation from the specification rather than the specification, and the rekey path's implementation (lib/encryptor/vault/rekey.ex) records what changes if it is ever corrected (ADR-0004 decision 11 and open question 5).

Records: ADR-0001 decisions 1, 2, 3, 4, 5 and 10; ADR-0002 decision 6; ADR-0004 decision 11; ADR-0005 decision 7.

Summary

Types

A key selector.

Callbacks

Decrypts a message this vault's currently resolved materials can open.

decrypt/2, raising the same Encryptor.Error it would have returned.

Encrypts a value under this vault's currently resolved materials.

encrypt/2, raising the same Encryptor.Error it would have returned.

Layer 5 of the precedence chain: the runtime configuration escape hatch.

Re-encrypts a message under this vault's currently resolved materials, preserving its encryption context byte for byte (ADR-0001 decision 4).

rekey/2, raising the same Encryptor.Error it would have returned.

Functions

The registered name of a vault's materials cache.

A vault's frozen configuration, with the not-started check in front of it.

The decrypt path, behind a vault module's generated decrypt/2.

The derivation path, behind a vault module's generated derive/2.

The encrypt path, behind a vault module's generated encrypt/2.

Checks that a vault's provider is alive, when the provider has a process.

Checks that a vault is running, and reads its frozen configuration.

The registered name of the process that owns a vault's frozen configuration.

The lifecycle check every entry point runs before it does anything else.

The registered name of a vault's cache recycler.

The rekey path, behind a vault module's generated rekey/2.

rekey/3, raising the Encryptor.Error it would have returned.

Starts a vault. The generated start_link/1 calls this.

Whether a vault's supervisor is alive.

Stops a running vault.

Whether a provider module supplies its own process.

The registered name of a vault's supervisor.

Types

selector()

@type selector() :: Encryptor.Error.selector()

A key selector.

An alias of Encryptor.Error.selector/0 rather than a restatement of it: the selector vocabulary is fixed once, by ADR-0004 decision 3, and a second copy of it is a second place for it to drift.

Callbacks

decrypt(ciphertext, opts)

@callback decrypt(ciphertext :: binary(), opts :: keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

Decrypts a message this vault's currently resolved materials can open.

Returns the plaintext and nothing else (ADR-0001 decision 4).

decrypt!(ciphertext, opts)

@callback decrypt!(ciphertext :: binary(), opts :: keyword()) :: binary()

decrypt/2, raising the same Encryptor.Error it would have returned.

encrypt(plaintext, opts)

@callback encrypt(plaintext :: binary(), opts :: keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

Encrypts a value under this vault's currently resolved materials.

Returns the complete self-describing engine message and nothing else (ADR-0001 decision 4).

encrypt!(plaintext, opts)

@callback encrypt!(plaintext :: binary(), opts :: keyword()) :: binary()

encrypt/2, raising the same Encryptor.Error it would have returned.

init(config)

(optional)
@callback init(config :: keyword()) :: {:ok, keyword()}

Layer 5 of the precedence chain: the runtime configuration escape hatch.

Receives the merged keyword list and returns the configuration the vault starts with. Its return replaces the merge rather than being merged over it, with the package defaults re-applied underneath so a callback that builds a fresh list does not silently drop the commitment policy floor.

Optional. A vault that exports none is configured entirely from the layers below it.

rekey(ciphertext, opts)

@callback rekey(ciphertext :: binary(), opts :: keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

Re-encrypts a message under this vault's currently resolved materials, preserving its encryption context byte for byte (ADR-0001 decision 4).

rekey!(ciphertext, opts)

@callback rekey!(ciphertext :: binary(), opts :: keyword()) :: binary()

rekey/2, raising the same Encryptor.Error it would have returned.

Functions

cache_name(vault)

@spec cache_name(module()) :: atom()

The registered name of a vault's materials cache.

Derived from the vault module, because vaults do not share a cache process: the cache's bounds live on the caching CMM while eviction lives in the cache, and that combination is only coherent when one cache serves one bound set (ADR-0001 decision 3).

config(vault)

@spec config(module()) ::
  {:ok, Encryptor.Vault.Config.t()} | {:error, Encryptor.Error.t()}

A vault's frozen configuration, with the not-started check in front of it.

decrypt(vault, ciphertext, opts \\ [])

@spec decrypt(module(), binary(), keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

The decrypt path, behind a vault module's generated decrypt/2.

The order of operations, the value comparison this package performs above the engine, and the reason that comparison cannot be left to the engine are all in the decrypt path's implementation, lib/encryptor/vault/decrypt.ex.

A non-binary ciphertext is a FunctionClauseError rather than an Encryptor.Error, for the same reason encrypt/3's non-binary plaintext is: a value that is not a binary is wrong in the source, not at runtime.

decrypt!(vault, ciphertext, opts \\ [])

@spec decrypt!(module(), binary(), keyword()) :: binary()

decrypt/3, raising the Encryptor.Error it would have returned.

The struct raised is the same one the non-bang variant returns, so a rescue clause matches on :reason exactly as a case would.

derive(vault, purpose, opts \\ [])

@spec derive(module(), String.t(), keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

The derivation path, behind a vault module's generated derive/2.

ADR-0003 amendment A. The scope is {ikm_selector, salt, info, length}: purpose and opts[:key] are the two halves of the selector, the salt is the vault's :derivation_salt and never the caller's, and :info and :length are the caller's. The derivation path's implementation (lib/encryptor/vault/derive.ex) holds the order and the reasons.

There is no derive!/3. Every other bang variant here exists for an application call site that would rather let a supervisor see the failure; this surface's caller is a library, which has a tagged tuple to thread and an error vocabulary of its own to map onto.

A non-binary purpose is a FunctionClauseError rather than an Encryptor.Error, for the same reason encrypt/3 makes a non-binary plaintext one: it is wrong in the source, not at runtime.

encrypt(vault, plaintext, opts \\ [])

@spec encrypt(module(), binary(), keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

The encrypt path, behind a vault module's generated encrypt/2.

The order of operations, the CMM stack this builds, and the reason the stack order is not configurable are all in the encrypt path's implementation, lib/encryptor/vault/encrypt.ex.

A non-binary plaintext is a FunctionClauseError rather than an Encryptor.Error: the closed reason vocabulary describes what can go wrong with a correct program's arguments at runtime, and a value that is not a binary is wrong in the source.

encrypt!(vault, plaintext, opts \\ [])

@spec encrypt!(module(), binary(), keyword()) :: binary()

encrypt/3, raising the Encryptor.Error it would have returned.

The struct raised is the same one the non-bang variant returns, so a rescue clause matches on :reason exactly as a case would.

ensure_provider_started(config, operation)

@spec ensure_provider_started(Encryptor.Vault.Config.t(), Encryptor.Error.operation()) ::
  :ok | {:error, Encryptor.Error.t()}

Checks that a vault's provider is alive, when the provider has a process.

A provider only has a process when it exports child_spec/1, and ADR-0002 decision 1 makes that process an implementation detail of the provider's own callbacks - the vault never talks to it and does not know what name, if any, it registered under. The authoritative answer is therefore the vault supervisor's own child list, where the child's id is the provider module because Encryptor.Vault.Supervisor sets it.

A provider with no child_spec/1 - the common encrypted-column case - skips the check entirely, which is what keeps the supervisor round trip off the hot path for every vault that does not need it.

ensure_started(vault, operation)

@spec ensure_started(module(), Encryptor.Error.operation()) ::
  {:ok, Encryptor.Vault.Config.t()} | {:error, Encryptor.Error.t()}

Checks that a vault is running, and reads its frozen configuration.

Both halves matter. The registered name answers whether the supervisor is alive; the :persistent_term entry answers whether a configuration was ever published under it. A vault brought down abnormally can leave the second without the first, and an entry point that read only the frozen struct would encrypt against the configuration of a vault that is no longer there.

lifecycle_name(vault)

@spec lifecycle_name(module()) :: atom()

The registered name of the process that owns a vault's frozen configuration.

ready(vault, operation)

@spec ready(module(), Encryptor.Error.operation()) ::
  {:ok, Encryptor.Vault.Config.t()} | {:error, Encryptor.Error.t()}

The lifecycle check every entry point runs before it does anything else.

Returns the frozen configuration when the vault is running and its provider, if it has a process, is alive. Every later bead's encrypt/2, decrypt/2 and rekey/2 is built on this function, which is why the checks live in one place rather than three.

operation is stamped onto the error so an operator reading a log line knows which call failed, not merely that a vault was down.

recycler_name(vault)

@spec recycler_name(module()) :: atom()

The registered name of a vault's cache recycler.

Derived the same way the cache name is, and for the same reason: the recycler bounds exactly one vault's cache (ADR-0001 decision 6).

rekey(vault, ciphertext, opts \\ [])

@spec rekey(module(), binary(), keyword()) ::
  {:ok, binary()} | {:error, Encryptor.Error.t()}

The rekey path, behind a vault module's generated rekey/2.

The order of operations, the reason the encryption context comes from the message rather than from the caller, and the reason the vault-side value comparison still runs when the reproduced context is the stored one are all in the rekey path's implementation, lib/encryptor/vault/rekey.ex.

A non-binary ciphertext is a FunctionClauseError rather than an Encryptor.Error, for the same reason encrypt/3 and decrypt/3 make it one: a value that is not a binary is wrong in the source, not at runtime.

rekey!(vault, ciphertext, opts \\ [])

@spec rekey!(module(), binary(), keyword()) :: binary()

rekey/3, raising the Encryptor.Error it would have returned.

The struct raised is the same one the non-bang variant returns, so a rescue clause matches on :reason exactly as a case would.

start_link(vault, start_opts \\ [])

@spec start_link(
  module(),
  keyword()
) :: Supervisor.on_start()

Starts a vault. The generated start_link/1 calls this.

start_opts are layer 4 of the precedence chain. A configuration the vault refuses is an {:error, %Encryptor.Error{}} from here, not a started vault that fails at the first encrypt.

started?(vault)

@spec started?(module()) :: boolean()

Whether a vault's supervisor is alive.

This is the liveness half of the not-started check. It reads a registered name and allocates nothing, so an entry point can afford it per call.

stop(vault, reason \\ :normal)

@spec stop(module(), term()) :: :ok | {:error, Encryptor.Error.t()}

Stops a running vault.

Stopping erases the frozen configuration, so a subsequent call returns {:vault_not_started, vault} rather than reading a stale struct.

supervised_provider?(module)

@spec supervised_provider?(module()) :: boolean()

Whether a provider module supplies its own process.

child_spec/1 is optional on Encryptor.Provider, and its presence is the only signal the vault has that a provider wants supervising.

supervisor_name(vault)

@spec supervisor_name(module()) :: atom()

The registered name of a vault's supervisor.