A provider that holds its keys in configuration.
The single-key vault, the root vault under the per-tenant envelope, and the test double. It resolves nothing from a store, which is exactly why the envelope's root vault uses it: a root vault configured with a store-backed provider would be a genuine cycle.
It is deliberately not a tenancy solution. The selector is ignored, not
rejected, so a :single vault resolves :default and a :tenant vault
resolves every tenant to the same key.
Two option shapes, mutually exclusive
One key:
provider: {Encryptor.Provider.Static,
key: material, namespace: "myapp", name: "card/v1"}Or a candidate list, newest first:
provider: {Encryptor.Provider.Static,
keys: [
[key: new_material, namespace: "myapp", name: "card/v2"],
[key: old_material, namespace: "myapp", name: "card/v1"]
]}With :keys, encryption_key/2 answers the head of the list and
decryption_keys/2 answers all of it. That is what makes a staged root
rotation readable: writes go under the incoming key while reads still find
the outgoing one, and the outgoing entry is removed in a later deploy.
:namespace defaults to "encryptor" and :name to "v1". Both defaults
are ones a host will regret if it ever rotates, because a name is bound to
its bytes forever - name the key for what it protects and version it.
What it refuses at start
- Both
:keyand:keys-{:invalid_config, :provider, :key_and_keys}. The two shapes answer the same question and there is no reading of the pair that is not a mistake. - Neither -
{:missing_config, [:provider, :key]}. - An empty or malformed
:keyslist, or an entry with no:key. - Two entries sharing a
:name. A candidate list is a list of versions, and two versions under one name is the failure mode the whole name contract exists to prevent - caught here, at start, rather than years later as an undecryptable row. - Material that is not a binary of 16, 24, or 32 bytes -
{:invalid_config, :provider, :key_size}. The declared size is derived from the bytes rather than configured, so this is the one descriptor field this provider has to be sure of before it builds anything.
Everything else about a descriptor - the reserved namespace prefix, the printability of a name - is the vault's to validate, immediately before it builds a keyring, and it is not repeated here.
Rotating means restarting
Configuration is resolved once and frozen into :persistent_term, so
changing the candidate list means restarting the vault. For the root vault,
which runs no cache, the restart costs nothing but the restart.
Records: ADR-0002 decisions 1, 4 and 5; ADR-0005 decision 4.
Summary
Types
One entry in a candidate list. :name is the version identity that travels
in the clear, and it must be distinct per entry.
Exactly one of :key or :keys. :keys is newest first.
The frozen state: the descriptors, newest first, resolved once at start.
Functions
The whole candidate list, newest first, whatever the selector.
The head of the candidate list, whatever the selector.
Resolves the configured key or candidate list into descriptors.
Types
One entry in a candidate list. :name is the version identity that travels
in the clear, and it must be distinct per entry.
@type opts() :: [key: binary(), namespace: String.t(), name: String.t()] | [{:keys, [entry(), ...]}]
Exactly one of :key or :keys. :keys is newest first.
@type state() :: %{keys: [Encryptor.Key.Aes.t(), ...]}
The frozen state: the descriptors, newest first, resolved once at start.
Functions
@spec decryption_keys(state(), Encryptor.Provider.selector()) :: {:ok, [Encryptor.Key.Aes.t(), ...]}
The whole candidate list, newest first, whatever the selector.
@spec encryption_key(state(), Encryptor.Provider.selector()) :: {:ok, Encryptor.Key.Aes.t()}
The head of the candidate list, whatever the selector.
@spec init(opts()) :: {:ok, state()} | {:error, Encryptor.Error.reason()}
Resolves the configured key or candidate list into descriptors.
The descriptors are built here, once, so that resolution is a lookup on every call afterwards.