The store-backed key provider: a wrapped-key table in, key descriptors out.
encryptor's ADR-0002 decision 5 puts the Ecto-backed provider in this
package because it owns a schema, a migration and a repo, and its ADR-0003
decision 9 says the vault package defines no storage at all: the vault owns
the six fields of Encryptor.Envelope.WrappedKey and nothing about where
they live. This module is the other half - the table, the query, and the
Encryptor.Provider implementation that turns rows into the descriptors a
scoped vault builds keyrings from.
Configuring it
defmodule MyApp.ScopedVault do
use Encryptor.Vault, otp_app: :my_app, context_profile: :scoped
def init(config) do
# `root_subkey/2` takes the 32 root-key *bytes*, not a vault module.
# Expand them once and hand the same value to both keys, so the
# provider looks for a row under the reference the vault writes.
subkey = Encryptor.Envelope.root_subkey(root_key(), "tenant-ref")
{:ok,
Keyword.merge(config,
provider:
{Encryptor.Ecto.KeyStore,
repo: MyApp.Repo,
root_vault: MyApp.RootVault,
reference_subkey: subkey},
reference_subkey: subkey
)}
end
# The pinned reference root, wherever this host keeps key material -
# the same bytes `MyApp.RootVault` is configured with.
defp root_key do
:my_app
|> Application.fetch_env!(:root_key_base64)
|> Base.decode64!()
end
end| Option | ||
|---|---|---|
:repo | required | The Ecto.Repo the wrapped-key table lives in |
:root_vault | required unless :gcp_kms is set | The vault the "engine_message" wrappings were produced by. Static provider, cache: false |
:reference_subkey | required | 32 bytes: the pinned reference root expanded under "tenant-ref" |
:table | "encryptor_wrapped_keys" | The table to read |
:prefix | nil | The schema prefix the table lives in; the repo's default when absent |
:gcp_kms | nil | Encryptor.Provider.GcpKms's options, for a table holding "gcp_kms_ciphertext" rows. Absent, such a row is refused |
:prefix is a placement decision, and it is singular
A host that puts the wrapped-key table in a non-default Postgres schema
names that schema here, and every query this module issues carries it. It
is singular for the same reason Encryptor.Ecto.Migrator's is: a prefix is
a deployment-time placement decision rather than a fact about the table, so
a host running several schemas configures one provider per schema rather
than asking this module to enumerate them. Nothing here reads a database
catalog to discover one.
The generators write no prefix into their migration source, deliberately.
mix ecto.migrate --prefix is Ecto's own way to place a migration, it
applies to the table and both indexes together, and baking the schema name
into a file the host commits would freeze a placement decision into source
that outlives it.
:reference_subkey is required, and it is not an extra
A row is found by tenant_ref, never by the selector: the selector is the
host's scope identifier and putting it in a column would publish it beside
every ciphertext, which is the whole reason Encryptor.Envelope.scope_ref/2
is a keyed derivation rather than a hash. So resolving a selector to a row
is that derivation, and the subkey it derives under has to be in provider
state. It must be the same value the vault itself is configured with, or the
provider will look for a row under one reference while the vault writes a
header claiming another.
:gcp_kms is the GCP branch's client, and the store keeps the read
A table can hold rows wrapped by Encryptor.Provider.GcpKms beside rows
wrapped by the root vault (ADR-0005 decision 5), and a GCP row needs a
configured KMS client to unwrap. :gcp_kms is that client's configuration:
the keyword list Encryptor.Provider.GcpKms documents, less the two options
this store supplies itself.
provider:
{Encryptor.Ecto.KeyStore,
repo: MyApp.Repo,
root_vault: MyApp.RootVault,
reference_subkey: subkey,
gcp_kms: [
project: "myapp-prod",
location: "us-east1",
key_ring: "encryptor-scope-keys",
http_client: MyApp.KmsHttp,
goth: MyApp.Goth
]}:reference_subkey is this store's own, so the two cannot disagree about
which row a selector names. :store is supplied per row: a GCP row is
unwrapped by handing Encryptor.Provider.GcpKms.decryption_keys/2 a store
that answers that one row, so the per-row dispatch and the "one bad row"
rule below hold for GCP rows exactly as they do for engine messages. Naming
either inside :gcp_kms is refused at start rather than silently
overridden. The options are checked through Encryptor.Provider.GcpKms's
own Encryptor.Provider.init/1 at start, so a misconfigured client fails
the vault's boot rather than its first GCP read. The decision is this
package's ADR-0005, "Amendment A (2026-09-24)".
:root_vault is required only when :gcp_kms is absent
The root vault unwraps "engine_message" rows and nothing else. A table
that holds only "gcp_kms_ciphertext" rows has no use for one, so a store
configured with :gcp_kms starts without it. A store configured with
neither is refused at start as {:missing_config, [:provider, :root_vault]}: it could serve no row at all.
Without a root vault, an "engine_message" row is answered rather than
crashed on, as {:invalid_key_descriptor, {:no_root_vault, "engine_message"}}, and the "one bad row" rule below keeps that answer to
the one row. The decision is this package's ADR-0005, "Amendment B
(2026-09-29)".
What it does, and the three things it will not do
Encryptor.Provider.decryption_keys/2 reads every row for the selector's
tenant_ref, newest version first, and unwraps each under the root vault.
Encryptor.Provider.encryption_key/2 reads the same rows in the same
single query and unwraps the newest one, which is the provider contract's
"the encryption key is the current one" stated as one query rather than two.
One bad row is not the whole store
The two callbacks share the query and part company on what a row that will not unwrap means to each of them, because it does not mean the same thing.
Encryptor.Provider.encryption_key/2 unwraps the newest row and no
other. A wrapping four rotations old that no longer opens - a root rotation
the rewrap pass has not finished, a row somebody edited, a shape this build
cannot serve - says nothing about whether this scope can be written to,
and blocking every write for the scope on it would turn one stale row into
an outage. The newest row is the one a write is going to be encrypted
under, so it is the only one a write's answer may depend on.
Encryptor.Provider.decryption_keys/2 skips the rows that do not unwrap
and answers with the ones that do, newest first. The list is a candidate
list: a version missing from it is a version the vault cannot decrypt
under, and that is already true of a row that will not unwrap. Halting on
the first failure instead would make every stored value for the scope
unreadable to protect the subset written under the one bad version, which
is the outage again, in the other direction.
When no row unwraps there is nothing to answer with, and the failure of the newest row is returned - the same term, for the same row, that a store holding only that row has always returned. So the arms below are unchanged for a scope whose rows are all bad, and a partially-broken scope now keeps the half that works.
A consequence worth naming: during a partial root rotation
Encryptor.Provider.encryption_key/2 can fail while
Encryptor.Provider.decryption_keys/2 succeeds with the older versions.
That is the honest report - reads work, and a write must not go under a
key this store cannot vouch for - and it is why the encryption key is not
described here as "the head of the decryption list" any more.
It mints nothing. Encryptor.Provider.init/1 resolves configuration
and touches no database; neither callback writes. Key creation is
Encryptor.Envelope.provision/3 and re-wrap is Encryptor.Envelope.rewrap/2,
verbs that operate on a key, which ADR-0002 decision 9 keeps out of this
package's task list. Crypto-shred is the one write this module performs,
and only when the host calls shred/3: never from a callback, never on a
schedule (ADR-0007 decision 3). Resolution is a lookup, and the provider
contract requires exactly that: a provider that minted material on the
first encrypt after a deploy would fail Encryptor.Provider.Conformance's
stability property, and rightly.
It issues no DDL. The table arrives as generated migration source the
host reads, commits and runs - mix encryptor.ecto.gen.key_store_migration,
the same arrangement ADR-0002 decision 9 already makes for the migrator's
checkpoint table.
It adds no cache. The scoped vault's materials cache already collapses
provider round trips to one per partition per max_age, and the provider
contract names a second unbounded cache as the thing not to add.
The table
| Column | |
|---|---|
id | the surrogate primary key Ecto.Migration.create/2 adds by default. This module never selects it |
tenant_ref | Encryptor.Envelope.scope_ref/2 of the host's selector. The lookup key. The column keeps the name it had before the scope rename, because it exists in every adopter's database; rows/3 selects it as :scope_ref (ADR-0006 decision 3) |
version | the key version. Ordering is the store's job, per ADR-0002 decision 4 |
namespace, name | what the encrypted data key matches on, byte for byte |
bits | 256 on this path |
wrapped | the wrapping, whose kind the next column names |
wrapping_shape | which kind of wrapping wrapped holds: "engine_message" or "gcp_kms_ciphertext" |
key_id | NULL for an engine message; the CryptoKey id a GCP ciphertext was produced under |
inserted_at, updated_at | nullable :utc_datetime timestamps the generator emits. This module neither writes nor reads them |
The generated DDL is those eleven columns and nothing else - see the
migration the mix encryptor.ecto.gen.key_store_migration task writes. The
timestamps are nullable and carry no default because this package writes no
rows: a host that inserts them gets them, and one that does not gets NULL
rather than a NOT NULL violation on its own insert.
wrapping_shape and key_id are ADR-0005's, and they are the reason a
reader never guesses.
Both wrapping kinds are opaque binaries, so a reader that picks the wrong
unwrap path gets a failure indistinguishable from a wrong key - one varchar
per row removes the guess. The vocabulary is closed at those two values here,
by that record, rather than by a database enum: this module queries the table
schemalessly and names no adapter, so a third value is an amendment to the
record and a clause in this module, not a migration on every adopter.
Two unique indexes carry properties nothing at runtime can:
{tenant_ref, version}closes the race ADR-0003 leaves to this package - "callingprovision/3twice concurrently for one scope can produce two rows claiming the same version; the transaction that closes that race isencryptor_ecto's". A second row claiming a live version is a candidate list with two entries for one version, and the loser of the race is the one no message was ever written under.{namespace, name}is the name contract made mechanical. A name is bound to its bytes forever, and two rows sharing one is the failure the contract exists to prevent - caught by the database rather than years later as an undecryptable row.
A shred is a DELETE of the row, which is honest: the wrapping is the only
copy of the key, so destroying it destroys the key. Nothing here soft-deletes,
because a soft-deleted wrapping is still a wrapping. shred/3 is that delete
for encryptor's enc-ADR-0005 P3 and P4, with the cache-drain wait and a
returned Encryptor.Ecto.KeyStore.Shred record.
The failure vocabulary
Where either callback answers at all, it answers in
Encryptor.Provider.reason/0 and nothing else. The conditions that are
not answers - a store configured wrong - raise instead, and "The failure
that is not in the vocabulary" below is that case.
{:unknown_key, selector}- no row for this selector'stenant_ref. A settled negative answer, and the same answer for a selector a scoped store cannot have a reference for at all (:default,"").{:key_unavailable, selector}- the store could not be asked, and asking again later could work. The repo is not started, the connection pool is exhausted, the server is shutting down or refusing connections, the query was cancelled. This is the one a caller retries, and telling it apart from the row genuinely being absent is why the provider contract carves both out of the decrypt path's collapse to:decrypt_failed.{:invalid_key_descriptor, :unwrap_failed}- a row was found and did not unwrap under the root vault. During a root rotation that is the expected answer for a wrapping the rewrap pass has not reached yet. The underlyingEncryptor.Erroris deliberately not carried out of here: a provider's return travels into the vault's error struct, and a wrapped key's failure detail is the last place a value should be allowed to ride along.{:invalid_key_descriptor, {:unknown_wrapping_shape, value}}- the row'swrapping_shapeis not one of the two ADR-0005 decision 1 publishes. This one does carry the stored value out, and it is the only thing here that does: a shape is not a failure detail but one of a closed set of literals a record publishes, and an operator debugging a stray row needs to know which literal it was.{:invalid_key_descriptor, :missing_key_id}- a"gcp_kms_ciphertext"row whosekey_idisNULL. The requirement is a read-side rule rather than aNOT NULLbecause it is conditional on another column's value, and a conditional constraint is not portable DDL.{:invalid_key_descriptor, :unexpected_key_id}- an"engine_message"row carrying akey_id. An engine message names its own keyring material inside the message, so a key id beside one means the row was written by something that did not know which shape it was writing.{:invalid_key_descriptor, {:unsupported_wrapping_shape, "gcp_kms_ciphertext"}}- a well-formed GCP row in a store configured without:gcp_kms. The branch has no client to unwrap with, so it answers rather than crashes. A store with no GCP-shaped rows never reaches it.{:invalid_key_descriptor, {:no_root_vault, "engine_message"}}- a well-formed engine-message row in a store configured with:gcp_kmsand without:root_vault. There is no vault to unwrap it under, so it answers rather than crashes. A store with no engine-message rows never reaches it, and thekey_idrule above runs first.
None of those six widens Encryptor.Provider.reason/0: they are new terms
inside {:invalid_key_descriptor, term()}, which is open by construction.
A GCP row in a store configured with :gcp_kms answers whatever
Encryptor.Provider.GcpKms answers for that row, unrelabelled. A Decrypt
that Cloud KMS refuses with HTTP 400 or 404 - the row's tenant_ref,
version or namespace no longer matching the data its wrapping was bound
to, a key version destroyed or disabled, a key that is not there - is
{:invalid_key_descriptor, {:kms_refused, status}}, which a retry does not
change. An IAM denial (403), a throttle, a server error, or the service
unreachable is {:key_unavailable, selector}, the term a caller retries.
A stored row it would never have written is
{:invalid_key_descriptor, :invalid_row}.
The failure that is not in the vocabulary
A store that was configured wrong does not answer at all: the exception raises out of the callback, unchanged.
Encryptor.Provider.reason/0 is a closed vocabulary and this package does
not get to widen it, so there is no term here for "the table was never
migrated", ":repo is not a repo" or "the columns are not the ones this
version reads". Reporting those as {:key_unavailable, selector} - which is
what a bare rescue did - is worse than having no term: it tells an
operator to wait for a transient condition to clear, and it never clears. A
host that forgot the migration would get key_unavailable for that scope
forever, and the Postgrex.Error naming the missing table would be dropped
on the floor.
So the rescue is narrowed to the conditions a retry can actually resolve,
and everything else keeps its own exception - Postgrex.Error with
undefined_table or undefined_column, UndefinedFunctionError for a
:repo that is not one. Those are deploy-time mistakes, they are permanent
until somebody changes something, and a loud crash naming the real cause is
the report they deserve. Nothing about this is a reason a caller matches on;
it is the absence of one.
Records: encryptor ADR-0002 decisions 4, 5 and 6; ADR-0003 decisions 1, 3,
4 and 9; ADR-0005 decisions 8 and 9; this package's ADR-0002 decision 9,
ADR-0005 and ADR-0007.
Summary
Types
One row of the wrapped-key table, as selected by rows/3.
What shred/3 answers instead of a record. Every one of them is answered
before anything is deleted, except that a transaction the database aborts
deletes nothing either. The one answer that does not mean "nothing was
deleted" is {:key_unavailable, selector} for a connection lost after
COMMIT was sent, where the outcome is unknown; shred/3 says how to
learn it.
What Encryptor.Provider.init/1 freezes for the life of the vault.
The closed vocabulary of ADR-0005 decision 1, as the read side branches on it.
Functions
Every live version for this selector, newest first.
The table name a host gets unless it names another.
The newest live version for this selector.
Resolves the provider's options into state. Opens no connection.
Crypto-shreds a scope, or one version of it, from the store a running vault
reads: encryptor's enc-ADR-0005 P3 (version: :all) or P4
(version: n). Irreversible.
Types
@type row() :: %{ scope_ref: String.t(), version: pos_integer(), namespace: String.t(), name: String.t(), bits: 256, wrapped: binary(), wrapping_shape: String.t(), key_id: String.t() | nil }
One row of the wrapped-key table, as selected by rows/3.
@type shred_error() :: {:unknown_key, Encryptor.Provider.selector()} | {:key_unavailable, Encryptor.Provider.selector()} | {:unknown_version, pos_integer()} | {:current_version, pos_integer()} | {:not_a_key_store_vault, module()} | {:missing_option, :version} | {:invalid_option, :version | :drain} | {:unknown_options, [atom()]} | Encryptor.Error.t()
What shred/3 answers instead of a record. Every one of them is answered
before anything is deleted, except that a transaction the database aborts
deletes nothing either. The one answer that does not mean "nothing was
deleted" is {:key_unavailable, selector} for a connection lost after
COMMIT was sent, where the outcome is unknown; shred/3 says how to
learn it.
@type state() :: %{ repo: module(), root_vault: module() | nil, reference_subkey: binary(), table: String.t(), prefix: String.t() | nil, gcp_kms: keyword() | nil }
What Encryptor.Provider.init/1 freezes for the life of the vault.
Everything in it is constant and none of it is a connection: the repo is a module name, and the pool behind it is the repo's own business.
@type wrapping_shape() :: :engine_message | :gcp_kms_ciphertext
The closed vocabulary of ADR-0005 decision 1, as the read side branches on it.
The stored column is a string; this is what the string is translated into before anything dispatches on it, and the translation is where a value the record does not publish stops being a row.
Functions
@spec decryption_keys(state(), Encryptor.Provider.selector()) :: {:ok, [Encryptor.Key.Aes.t(), ...]} | {:error, Encryptor.Provider.reason()}
Every live version for this selector, newest first.
Dropping a row from the table is what removes an entry from this list, and that is the crypto-shred mechanism rather than a cleanup. A row that will not unwrap is skipped rather than fatal, for the reasons the moduledoc's "one bad row is not the whole store" gives; the error arrives only when no row unwrapped at all.
@spec default_table() :: String.t()
The table name a host gets unless it names another.
iex> Encryptor.Ecto.KeyStore.default_table()
"encryptor_wrapped_keys"
@spec encryption_key(state(), Encryptor.Provider.selector()) :: {:ok, Encryptor.Key.Aes.t()} | {:error, Encryptor.Provider.reason()}
The newest live version for this selector.
The same single query Encryptor.Provider.decryption_keys/2 runs, so the
two cannot disagree about which version is current - but only the newest
row is unwrapped. An older wrapping that no longer opens is not a reason a
scope cannot be written to, and the moduledoc's "one bad row is not the
whole store" says why at length.
Resolves the provider's options into state. Opens no connection.
A missing or malformed option is refused here, at vault start, in the same
terms Encryptor.Vault.Config uses for the same values - which is the point
of refusing at start rather than on the first encrypted write.
@spec shred(module(), Encryptor.Provider.selector(), keyword()) :: {:ok, Encryptor.Ecto.KeyStore.Shred.t()} | {:error, shred_error()}
Crypto-shreds a scope, or one version of it, from the store a running vault
reads: encryptor's enc-ADR-0005 P3 (version: :all) or P4
(version: n). Irreversible.
{:ok, %Encryptor.Ecto.KeyStore.Shred{procedure: :scope, versions: [1, 2]}} =
Encryptor.Ecto.KeyStore.shred(MyApp.ScopedVault, "workspace-7", version: :all)vault is a started vault whose provider is this module; the rows are
deleted from the table, prefix and repo its provider options name, so the
shred cannot reach a different store from the one the vault decrypts
through. The call does P3's steps 2 and 3, or P4's steps 1 and 2, and the
procedures' preconditions stay the operator's: a recorded human decision
for P3, and a green whole-scope verification for P4.
Options
:version- required.:alldeletes every version of the scope (P3). A positive integer deletes that one version (P4), and is refused as{:current_version, n}when it is the scope's newest version: removing it would make an older version current again, and a scope whose only version goes is P3, which asks for a decision of its own.:drain-:wait(the default) returns only once the vault's materials cache can no longer serve a deleted version, which is its cachemax_ageafter the delete commits, and at once for a vault configuredcache: false.:skipreturns at once, for a host that restarts its vaults instead; the record's:drained_atstill says when waiting would have finished. "How far:drained_atholds" below says what the bound does not cover.
What it answers
{:ok, %Encryptor.Ecto.KeyStore.Shred{}} once the rows are deleted and,
under drain: :wait, drained. The record is ADR-0007 decision 4's: the
versions deleted, the versions left, the scope reference, and the two
timestamps a host certifies from.
After it, a read through the vault fails the way enc-ADR-0005 decision 9
says: {:unknown_key, selector} after P3, because no row is left for the
scope, and :decrypt_failed after P4 for a value written under the
deleted version, because the other versions still resolve.
Every refusal deletes nothing, with the one exception its
{:key_unavailable, selector} entry names:
{:unknown_key, selector}- no row for the scope, or a selector no scope reference is derived from (:default,"").{:unknown_version, n}- the scope has no versionn.{:current_version, n}-nis the scope's newest version.{:key_unavailable, selector}- the store could not be asked, and asking again later could work: the same transient conditions the provider callbacks answer this for. The delete is one transaction, so a failure part way deletes nothing. The one exception is a connection lost afterCOMMITwas sent: the database may have committed the delete, and the call cannot tell, so it answers this and the outcome is unknown rather than known to be nothing. Run the same shred again to learn which:{:unknown_key, selector}after P3, or{:unknown_version, n}after P4, says the first call's delete committed, and a record says the second call did it.{:not_a_key_store_vault, vault}- the vault's provider is not this module.{:missing_option, :version},{:invalid_option, key}and{:unknown_options, keys}- the options above.%Encryptor.Error{}- the vault is not started, asEncryptor.Vault.config/1reports it.
A version provisioned while P3 runs
The scope's rows are locked FOR UPDATE, which stops nobody inserting a
new version beside them. So P3 checks again after its delete, in the same
transaction: a version committed in between - an
Encryptor.Envelope.provision/3 the host ran concurrently - is deleted too
and listed in :versions, and remaining: [] is what that second look
found. The check reads what has committed when it starts, which is
Postgres's default READ COMMITTED behaviour; a repo whose transactions
run at a stricter isolation level reads a snapshot and does not see the
late row.
A version committed after the check starts is out of the call's reach and
survives as the scope's newest key, with the record saying remaining: [].
The host closes that window, not this call: it stops provisioning for a
scope before shredding it, and confirms afterwards that the store holds no
row for :scope_ref, as enc-ADR-0005's P3 verification asks. P4 deletes
the one version it names, so a version provisioned during it is left alone
and is not listed in :remaining.
How far :drained_at holds
:drained_at is :deleted_at plus max_age, and it bounds every cache
entry made before the delete committed. It is not a hard bound on every
entry. A decrypt that asked the store before the commit, and finished
after it, puts its materials in the cache at that later moment, and they
serve until that moment plus max_age: past :drained_at by as long as
that decrypt was in flight. Under P3 this cannot serve a read, because
encryptor asks the provider before it reads the cache and the provider
answers {:unknown_key, selector}; under P4 it can, for that long. A host
that needs a hard bound restarts its vaults after the shred, which drops
every entry at once.
Telemetry
One [:encryptor_ecto, :shred] event per shred, emitted after the delete
commits and before the drain wait. Its measurement is count, the number
of versions deleted, and its metadata is closed at vault, procedure and
table: no selector, no scope reference and no version number, because a
shred is exactly the event an operator would want to label with the scope,
and encryptor's telemetry records carry no per-scope dimension
(ADR-0007 decision 5).