A vault's resolved configuration: the five-layer precedence chain, every
check that runs at start, and the :persistent_term freeze the hot path
reads.
Configuration is resolved once, when the vault starts, and frozen into
this struct under the :persistent_term key {Encryptor.Vault, vault}.
Per-call reads are then lock-free and allocate nothing, which is what makes
a check on every encrypted-column read affordable. Changing configuration
under a running vault means restarting it, deliberately: configuration that
changes key selection silently underneath in-flight operations is worse
than an explicit restart (ADR-0001 decision 5).
The precedence chain
Lowest to highest, exactly as ADR-0001 decision 5 fixes it:
defaults/0, declared by this package,- options passed to
use Encryptor.Vault, Application.get_env(otp_app, vault),- options passed to
start_link/1, - the return of the vault's optional
init/1callback.
Layers merge by top-level key. A layer that supplies :cache replaces
the whole cache setting rather than deep-merging into the layer below it,
because a host that inherits half a bound it never wrote has no way to read
its own configuration off the page. Sub-defaults for the cache are applied
once, during validation, to whichever layer won.
init/1 receives the merged keyword list and returns {:ok, config}; its
return replaces the merge rather than being merged over it. It is the
runtime escape hatch and the intended place to read key material out of the
environment or a secrets manager.
Layer 1 is the one exception to "replaces". The package defaults are applied
a second time, underneath whatever init/1 returned, so a callback that
builds a fresh keyword list rather than adding to the one it was handed does
not silently drop the commitment policy floor and the EDK limit on its way
past. Every default is stated once, in defaults/0, and nowhere else -
a validator that carried its own copy of a default would be a second place
for it to drift.
Key material is never a compile-time option
validate_use_opts!/2 raises at compile time when a use option, or a
:provider option nested inside one, names key material: :key, :keys,
:root_key, :private_key, :passphrase, or :reference_subkey. 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 (ADR-0001 decision 5; ADR-0004 decision 4 for the reference subkey).
That list is closed and is extended by an ADR, not by a call site.
:derivation_salt is refused there too, and for a different reason that the
refusal message states: it is not secret, but it is per deployment, and a
per-deployment value compiled into a .beam is shared by every deployment
built from that artifact (ADR-0003 amendment A decision 3).
What is checked at start
Every check below produces an Encryptor.Error with operation: :start and
a reason from the closed vocabulary. None of them is deferred to the first
encrypt: a vault that cannot be configured correctly does not start.
:provideris required, is a{module, opts}pair, and may not carry both:keyand:keys(ADR-0005 decision 4). ItsEncryptor.Provider.init/1runs here, once, and what it returns is frozen as:provider_state- the state every later resolution callback is handed (ADR-0002 decision 1). A provider exporting noinit/1keeps its option list as its state, which is the fallbackEncryptor.Provider.init/2owns.:commitment_policydefaults to:require_encrypt_require_decryptand may be relaxed to:require_encrypt_allow_decrypt.:forbid_encrypt_allow_decryptis refused outright: that policy exists to write non-committed messages, this package has never written one, and a key that can turn key commitment off will eventually be turned off by someone who does not know what it does (ADR-0001 decision 8).:max_encrypted_data_keysdefaults to10and may never benil. The engine readsnilas unlimited, and an unlimited EDK count on the decrypt path is a work-amplification lever handed to whoever supplies the ciphertext.:algorithm_suite_iddefaults to0x0578and accepts0x0478. See "Choosing an algorithm suite" below.:cacheisfalseor a keyword list.:max_ageis required with no default;:max_messagesdefaults to100,:max_bytesto 1 GiB, and:recycle_afterto20 * max_age(ADR-0001 decision 6).:context_profileis:singleor:tenant, and:required_contextis a list of context keys (ADR-0004 decision 3).- On a
:tenantvault:reference_subkeyis required, and when the deployment has pinned a:reference_checkvalue the subkey must reproduce it (ADR-0004 decision 4). :derivation_saltis optional on both profiles, and when present is a binary of at least 32 bytes. It is the one check here that is not complete at start: a vault without it starts, and onlyEncryptor.Vault.derive/3fails, with{:missing_config, [:derivation_salt]}(ADR-0003 amendment A decision 3).:static_encryption_contextis validated and bounded here, against the vocabulary and the boundsEncryptor.Contextowns: at mostEncryptor.Context.max_pairs/0pairs, at mostEncryptor.Context.max_bytes/0serialized, non-empty UTF-8 strings throughout, and no reserved key (ADR-0004 decisions 1, 2 and 9). This module applies them at start; the per-call half isEncryptor.Context.compose/3.
Choosing an algorithm suite
The default 0x0578 is the engine's own default: AES-256-GCM, HKDF-SHA512,
key commitment, and ECDSA P-384 signing. A wrapper should not silently
weaken what the engine chose, so that is what a host gets without saying
anything.
A host should configure 0x0478 - which keeps key commitment and drops the
signature - when the writer and the reader are the same trust domain.
The encrypted-column case encryptor_ecto serves is exactly that shape:
signing exists so a reader can verify a writer it does not trust, and paying
an ECDSA P-384 sign per column write, a verify per read, and the signature's
bytes per row buys nothing when one application is both parties
(ADR-0001 decision 9).
The profile is start-time, not compile-time
:context_profile arrives through the same five layers as everything else,
and three of those layers do not exist when the vault module - or any module
downstream of it - is compiled. A downstream layer that needs to know
whether a vault is :single or :tenant reads it from the frozen struct at
runtime, through fetch/1, and never from the use options.
case Encryptor.Vault.Config.fetch(MyApp.TenantVault) do
{:ok, %{context_profile: :tenant}} -> :ok
{:ok, %{context_profile: :single}} -> {:error, :vault_is_single_profile}
{:error, error} -> {:error, error}
endRecords: ADR-0001 decisions 5, 6, 8 and 9; ADR-0004 decisions 3, 4 and 9; ADR-0005 decision 4.
Summary
Types
The resolved cache bounds, or false when the vault runs no cache.
An encryption context: a flat map of string to string.
Which shape of vault this is, and therefore which selector and required keys it takes.
Functions
The package defaults - layer 1 of the precedence chain.
Removes a vault's frozen configuration.
Reads a vault's frozen configuration.
Publishes a resolved configuration under {Encryptor.Vault, vault}.
Computes the known-answer value a deployment pins as :reference_check.
Resolves the five layers and validates the result.
Refuses key material in use options, at compile time.
Types
@type cache() :: false | %{ max_age: pos_integer(), max_messages: pos_integer(), max_bytes: pos_integer(), recycle_after: pos_integer() }
The resolved cache bounds, or false when the vault runs no cache.
An encryption context: a flat map of string to string.
@type profile() :: :single | :tenant
Which shape of vault this is, and therefore which selector and required keys it takes.
@type t() :: %Encryptor.Vault.Config{ algorithm_suite_id: 1400 | 1144, cache: cache(), commitment_policy: :require_encrypt_require_decrypt | :require_encrypt_allow_decrypt, context_profile: profile(), derivation_salt: binary() | nil, max_encrypted_data_keys: pos_integer(), otp_app: atom(), provider: {module(), term()}, provider_state: term(), reference_check: String.t() | nil, reference_subkey: binary() | nil, required_context: [String.t()], required_keys: [String.t()], static_encryption_context: context(), vault: module() }
Functions
@spec defaults() :: keyword()
The package defaults - layer 1 of the precedence chain.
Three keys are deliberately absent. :provider and :context_profile are
required, because there is no defensible default for where key material
comes from or for whether a vault is per-tenant, and a wrong guess at either
changes what goes into a message. :reference_subkey is key material and
arrives through init/1.
iex> Keyword.fetch(Encryptor.Vault.Config.defaults(), :commitment_policy)
{:ok, :require_encrypt_require_decrypt}
iex> Keyword.fetch(Encryptor.Vault.Config.defaults(), :context_profile)
:error
@spec erase(module()) :: :ok
Removes a vault's frozen configuration.
Called when a vault stops. :persistent_term.erase/1 triggers a global
scan, which is why this happens on a vault's lifecycle boundary and never on
a call path.
@spec fetch(module()) :: {:ok, t()} | {:error, Encryptor.Error.t()}
Reads a vault's frozen configuration.
A vault that has not been started has no entry, and that is a typed error
rather than a raise: {:vault_not_started, vault} is the check ADR-0001
decision 2 requires at every entry point.
iex> Encryptor.Vault.Config.fetch(MyApp.UnstartedVault)
{:error,
%Encryptor.Error{
reason: {:vault_not_started, MyApp.UnstartedVault},
vault: MyApp.UnstartedVault,
operation: :start,
engine: nil
}}
Publishes a resolved configuration under {Encryptor.Vault, vault}.
Called once, by the vault's supervisor, at start. Returns the config so it can be threaded.
Computes the known-answer value a deployment pins as :reference_check.
The value is the reference this package derives for a fixed probe selector, by ADR-0003 decision 5's keyed derivation. An operator runs this once, against the reference subkey the deployment was provisioned with, and writes the result into the tenant vault's configuration. Every node then refuses to start unless its subkey reproduces it.
The check exists because the alternative failure is silent and fleet-wide: a
node deployed with a wrong reference subkey writes messages no correct
reader can open, and fails every correct message as :decrypt_failed -
corruption-shaped, and discovered at decrypt time, when the reference subkey
is already permanent (ADR-0004 decision 4).
The returned value is not secret. It is the same shape as a tenant_ref,
which travels in the clear in every message header.
Resolves the five layers and validates the result.
use_opts is layer 2 and start_opts is layer 4; layers 3 and 5 are read
here, from Application.get_env/3 and from the vault's init/1 when it
exports one.
Returns the validated struct. It is not frozen - freeze/1 is a
separate step so a caller can resolve and inspect a configuration without
publishing it.
Refuses key material in use options, at compile time.
Called by the use Encryptor.Vault macro while it expands, so the failure
is a compilation failure rather than a start-time error: by the time a vault
starts, the secret is already in the .beam file.
It raises ArgumentError rather than returning an Encryptor.Error,
because this is a misuse of a macro rather than an operation on a vault, and
because Encryptor.Error.message/1 deliberately never renders the detail of
an {:invalid_config, key, detail} - the one thing an operator needs to see
here is which option was refused.
Returns the options unchanged when they are clean, so the macro can thread it.