The blind index's key derivation: ADR-0003 decisions 2 and 3a, as amended on 2026-08-27 and reworked onto the vault's salted derivation surface on 2026-08-28.
A blind index value is HMAC-SHA256(index_key, norm(plaintext))
(ADR-0003 decision 1). This module is where index_key comes from, and
nothing else: it computes no index values and reads no schema. It performs
no cryptography of its own either - every byte comes back from
Encryptor.Vault.derive/3, and this module's job is to name the scope that
call derives under.
The derivation
ADR-0003 was accepted with index keys as derived subkeys under
enc-ADR-0003 decision 7's "encryptor/v1/blind-index" label, while its own
decision 2 states a literal domain-separation prefix. The operator's
2026-08-27 ruling settled how the two compose - "compose by nesting - the
index key is derived as a subkey under the upstream label, and d2's literal
prefix becomes the info string inside that subkey derivation" - and the
operator's 2026-08-28 ruling put the salt decision 2 always asked for
underneath both:
I agree with the salt ruling - add the salt now via an upstream HKDF-Extract amendment, shaped to A8's
{ikm_selector, salt, info, length}
enc-ADR-0003 amendment A implements that shape, and the whole construction
now lives in Encryptor.Kdf.salted_subkey/5 behind
Encryptor.Vault.derive/3:
PRK = HKDF-Extract(salt: vault's :derivation_salt, ikm: key material)
purpose_key = HKDF-Expand(PRK, "encryptor/v1/blind-index", 32)
index_key = HKDF-Expand(purpose_key, info, 32)Three steps, each separating at its own layer. The salt is the vault's
per-deployment :derivation_salt and is never this package's to supply -
that is what makes two deployments provisioned from the same tenant key
material derive unrelated index values, and it is why a restored backup or
a cloned staging environment cannot be joined against production on an
index column. The outer label separates the whole blind-index tree from
every other use of a tenant's key material and belongs to encryptor; this
package names the purpose "blind-index" and never spells the namespace by
hand. The inner info separates this package's index derivation from
anything else that might one day derive under that tree, and belongs to
this record:
info = "encryptor_ecto/blind_index/v1|" <> table <> "|" <> column <>
"|" <> index_name <> "|" <> Integer.to_string(version)Both halves of the structural "an index key is never an encryption key"
guarantee hold, and each component of info is load-bearing:
- the prefix is the domain separation of decision 2, and it is structural rather than a matter of configuration discipline;
- table and column are the encrypted field's declared context values - the ones ADR-0001 acceptance amendment 5 freezes at declaration, not the physical names in use today - so two columns holding the same plaintext produce unrelated index values and a dump cannot be joined across them;
- index_name distinguishes two indexes over one column (decision 6: a full index and a truncated one, or a rotation pair);
- version participates because the operator's D1 ruling says it does. Without it, decision 7's two-column rotation would recompute the new column to byte-identical values and rotate nothing while reporting that it had.
Key material never arrives here
This module never sees, receives, holds, or returns the key material an
index key is derived from. That is ADR-0003's assumptions A8 and A11, and
as of enc-ADR-0003 amendment A the vault discharges them: derive/3
resolves the descriptor inside the vault's derivation path
(lib/encryptor/vault/derive.ex in encryptor), derives there, and
hands back derived bytes only. There is no argument on any function in this
module that a tenant master key could be passed as, which is the strongest
form the property can take - a rule that cannot be broken by a call site
beats a rule a call site is asked to follow.
The consequence, recorded rather than glossed: a component that can derive an index key is a component holding a vault that can also decrypt. Amendment A's consequences section says so plainly, and the independently wrapped index keys of ADR-0003's A9 resolution remain the upgrade path if a genuine search-only consumer materializes.
The seam this module chose, and why
derive/3 calls Encryptor.Vault.derive/3 itself rather than returning a
scope for a caller to derive with. The salt sits at the extract step, over
key material the vault refuses to export, so there is no arrangement in
which this package composes the construction from parts: either the vault
performs the whole derivation or the derivation is wrong. Everything except
the one line that makes the call is pure - info/1, derive_opts/2,
outer_label/0, and every validation - so the constants stay reviewable
and testable without a vault, and only the composed result needs one.
What is deliberately not here is where the vault module and the operation
come from on a real write path. That is put_index/3's, and it belongs to
the surface bead rather than to this one.
The Argon2id salt
ADR-0003 amendment C adds a second value derived under this same identity:
the per-index Argon2id salt a slow: true declaration hashes its normalized
plaintext under. It is obtained exactly the way the index key is - the same
derive/3 call, the same reserved "blind-index" purpose, the same
selector, the same 32 bytes - and differs only in the info string, which
carries one further component:
salt_info = <the index key's info> <> "|slow-salt"Decision C1 rejects the three alternatives by name: a literal constant is
identical in every deployment of this package and precomputable against all
of them at once, a stored random salt puts durable state into a package
whose decision 5 promises none, and a host-supplied salt makes a
cryptographic parameter a call-site constant. Deriving through the vault
costs one HKDF expansion and gets a salt that is per deployment - because
the extract is under the vault's :derivation_salt - per index, per tenant
(C3), and stable for the life of the index without existing anywhere but in
the derivation.
The separation from the index key's own info is structural rather than
probabilistic, which is decision C2's argument and the reason the component
is appended rather than mixed in: no component may be empty or carry the
separator, so an index key's info has exactly four separators and a salt's
has exactly five, and no declaration a host can write produces one from the
other side of that count.
Nothing here is derived unless it is asked for. derive_salt/3 is a
separate call from derive/3 precisely so that a slow: false declaration
performs exactly one derivation and decision 1's formula is literally
unchanged for it.
Scope
selector!/3 discharges decision 3a. It has no tenant channel of its own
and does not read Encryptor.Ecto.Tenant: it asks the encrypted field's
configured strategy through Encryptor.Ecto.TenantContext, so a host that
replaced :scope with a resolver module gets the same replacement here for
free, and a missing tenant raises Encryptor.Ecto.MissingTenantError
identically to ADR-0001 decision 5c. A scope: :global index asks no
resolver anything, which is decision 3c's whole point: the global choice is
written at the field and visible to the reviewer.
Redaction
No message, log line, or Inspect output produced here carries plaintext,
an index value, or key material. Encryptor.Ecto.Error's prohibition
applies unchanged; Encryptor.Ecto.BlindIndex.DerivationError's reasons are
tuples of atoms naming a violated constraint, never a value.
Summary
Types
The encrypted field's frozen parameters, as Encryptor.Ecto.Binary holds
them. Only the four keys the tenant strategy needs are read.
ADR-0003 decision 3's key scope, declared at the field.
Which scope's key material a derivation needs.
One index's derivation identity: everything that reaches the HKDF info
string, plus the key scope decision 3 chooses between.
Functions
Derives the 32-byte index key for one identity, through vault.
The Encryptor.Vault.derive/3 options one identity derives under.
Derives the 32-byte Argon2id salt for one identity, through vault.
The HKDF info string for one derivation identity.
Builds a validated derivation identity.
The label of the outer expansion, which belongs to encryptor.
The Encryptor.Vault.derive/3 options the Argon2id salt derives under.
The HKDF info string the per-index Argon2id salt derives under.
Resolves which scope's key material a derivation needs (decision 3a).
The Argon2id parameters a slow index hashes under, read off vault.
Types
@type field_params() :: %{ :vault => module(), :tenant => :scope | :none | module(), :table => String.t(), :column => String.t(), optional(atom()) => term() }
The encrypted field's frozen parameters, as Encryptor.Ecto.Binary holds
them. Only the four keys the tenant strategy needs are read.
@type scope() :: :tenant | :global
ADR-0003 decision 3's key scope, declared at the field.
@type selector() :: {:tenant, String.t()} | :global
Which scope's key material a derivation needs.
{:tenant, tenant} names the resolved tenant; :global names the
deployment-wide index root of decision 3c.
@type t() :: %Encryptor.Ecto.BlindIndex.Derivation{ column: String.t(), index_name: String.t(), scope: scope(), table: String.t(), version: pos_integer() }
One index's derivation identity: everything that reaches the HKDF info
string, plus the key scope decision 3 chooses between.
Functions
@spec derive(module(), t(), selector()) :: {:ok, binary()} | {:error, Encryptor.Error.t()}
Derives the 32-byte index key for one identity, through vault.
The whole construction is the vault's - Encryptor.Vault.derive/3 extracts
under the deployment salt, expands under the reserved "blind-index"
purpose, and expands again under this record's info. This function names
the purpose and the scope and returns what comes back:
selector = Derivation.selector!(derivation, params, :dump)
{:ok, index_key} = Derivation.derive(Payments.Vault, derivation, selector)The result is the vault's tagged tuple, unwrapped by nothing here. A vault
with no :derivation_salt configured answers
{:missing_config, [:derivation_salt]}, a %Encryptor.Key.Kms{} descriptor
answers {:invalid_key_descriptor, :not_derivable}, and both are the
vault's to phrase because both are facts about the vault's configuration
rather than about this index's declaration. Translating them into a
Encryptor.Ecto.BlindIndex.DerivationError would put this package's words on a
misconfiguration it cannot see.
The Encryptor.Vault.derive/3 options one identity derives under.
Public for the same reason info/1 is: this is A8's scope, minus the one
element that is never the caller's. The salt does not appear because the
vault supplies it from its own :derivation_salt and refuses a caller's
(enc-ADR-0003 amendment A decision 3) - an option list that could carry a
salt is an option list a call site could get wrong.
iex> alias Encryptor.Ecto.BlindIndex.Derivation
iex> Derivation.new!(table: "payments", column: "card_number",
...> index_name: "card_number_index")
...> |> Derivation.derive_opts({:tenant, "merchant_7f3"})
[
info: "encryptor_ecto/blind_index/v1|payments|card_number|card_number_index|1",
length: 32,
key: "merchant_7f3"
]A scope: :global index names no key, so a single-key vault's :default
selector applies (decision 3c):
iex> alias Encryptor.Ecto.BlindIndex.Derivation
iex> Derivation.new!(table: "signups", column: "email",
...> index_name: "email_index", scope: :global)
...> |> Derivation.derive_opts(:global)
[info: "encryptor_ecto/blind_index/v1|signups|email|email_index|1", length: 32]
@spec derive_salt(module(), t(), selector()) :: {:ok, binary()} | {:error, Encryptor.Error.t()}
Derives the 32-byte Argon2id salt for one identity, through vault.
Amendment C decision C1. The same call derive/3 makes, under
salt_derive_opts/2 - so the salt is a pure function of the vault's
:derivation_salt, the selector's key material, and the constants
salt_info/1 composes. Nothing in it is random, time-varying, stored, or
configurable, which is what enc-ADR-0003 amendment B decision 3 requires of
a slow-hash salt and what decision C6 promises about it.
It is a separate function rather than a second return from derive/3
because C5 makes the salt lazy: a slow: false declaration must
perform exactly one derivation, and a call that always produced both would
spend an HKDF expansion on every index in the package to serve the ones
that ask.
The result is the vault's tagged tuple, unwrapped by nothing here, for the
reason derive/3 gives: a vault with no :derivation_salt is a fact about
the vault rather than about this index's declaration.
The HKDF info string for one derivation identity.
Public because it is the constant the operator's crypto read checks, and a value that can only be observed through the bytes it produces is a value nobody reviews.
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments", column: "card_number", index_name: "card_number_index")
...> |> Encryptor.Ecto.BlindIndex.Derivation.info()
"encryptor_ecto/blind_index/v1|payments|card_number|card_number_index|1"A version bump changes it, which is what makes decision 7's rotation rotate anything:
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments", column: "card_number",
...> index_name: "card_number_index", version: 2)
...> |> Encryptor.Ecto.BlindIndex.Derivation.info()
"encryptor_ecto/blind_index/v1|payments|card_number|card_number_index|2"
Builds a validated derivation identity.
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments",
...> column: "card_number",
...> index_name: "card_number_index"
...> )
%Encryptor.Ecto.BlindIndex.Derivation{
table: "payments",
column: "card_number",
index_name: "card_number_index",
version: 1,
scope: :tenant
}:version defaults to 1, matching index_opts/0's default, so an index
that declares no version derives under ...|<index_name>|1 and nothing
about an existing declaration changes. :scope defaults to :tenant,
matching decision 3a.
Every component is a binary, never an atom. An atom would have to be
rendered to reach the info string, and Atom.to_string/1 renders a module
alias as "Elixir.Foo" - a rendering that silently changes the derived key
for a caller who thought they were passing a name. The conversion belongs at
the declaration, where the atom is still visible.
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments", column: "card_number", index_name: :card_number_index)
** (Encryptor.Ecto.BlindIndex.DerivationError) a blind index key could not be derived (table: "payments", column: "card_number", context keys: [], tenant: nil, reason: {:invalid, :index_name, :not_a_non_empty_binary}, index name: nil, index version: 1)A component may not carry the info string's own separator, for the reason
Encryptor.Kdf.label/1 refuses a purpose carrying "/": a component that
can spell a separator can spell a different identity's info string from a
different starting point, and two indexes the design says are independent
collapse onto one key.
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments", column: "card_number", index_name: "a|b")
** (Encryptor.Ecto.BlindIndex.DerivationError) a blind index key could not be derived (table: "payments", column: "card_number", context keys: [], tenant: nil, reason: {:invalid, :index_name, :contains_separator}, index name: "a|b", index version: 1)
@spec outer_label() :: String.t()
The label of the outer expansion, which belongs to encryptor.
iex> Encryptor.Ecto.BlindIndex.Derivation.outer_label()
"encryptor/v1/blind-index"
The Encryptor.Vault.derive/3 options the Argon2id salt derives under.
derive_opts/2 with salt_info/1 in place of info/1, and identical in
every other position - amendment C decision C3 puts the salt under the same
selector as the index key, and C4 puts it at the same length.
iex> alias Encryptor.Ecto.BlindIndex.Derivation
iex> Derivation.new!(table: "payments", column: "card_number",
...> index_name: "card_number_index")
...> |> Derivation.salt_derive_opts({:tenant, "merchant_7f3"})
[
info: "encryptor_ecto/blind_index/v1|payments|card_number|card_number_index|1|slow-salt",
length: 32,
key: "merchant_7f3"
]C3 is the cheap choice and the stronger one: a deployment-wide salt would make the Argon2id output a function of the plaintext alone, reintroducing one layer in the cross-tenant correlatability decision 3b argues against. Resolving one selector per computation and using it twice is also what makes it impossible for the salt and the key to disagree about which tenant a row belongs to.
iex> alias Encryptor.Ecto.BlindIndex.Derivation
iex> Derivation.new!(table: "signups", column: "email",
...> index_name: "email_index", scope: :global)
...> |> Derivation.salt_derive_opts(:global)
[info: "encryptor_ecto/blind_index/v1|signups|email|email_index|1|slow-salt", length: 32]
The HKDF info string the per-index Argon2id salt derives under.
Amendment C decision C2: info/1's string with "|slow-salt" appended.
Public for the same reason info/1 is - it is a constant the operator's
crypto read checks, and a constant only observable through the bytes it
produces is a constant nobody reviews.
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments", column: "card_number", index_name: "card_number_index")
...> |> Encryptor.Ecto.BlindIndex.Derivation.salt_info()
"encryptor_ecto/blind_index/v1|payments|card_number|card_number_index|1|slow-salt"It changes under exactly the changes info/1 changes under and under no
others, which is what makes decision C6's claim - that the salt adds no new
way to invalidate a column - true rather than merely intended:
iex> Encryptor.Ecto.BlindIndex.Derivation.new!(
...> table: "payments", column: "card_number",
...> index_name: "card_number_index", version: 2)
...> |> Encryptor.Ecto.BlindIndex.Derivation.salt_info()
"encryptor_ecto/blind_index/v1|payments|card_number|card_number_index|2|slow-salt"
@spec selector!(t(), field_params(), Encryptor.Ecto.TenantContext.operation()) :: selector()
Resolves which scope's key material a derivation needs (decision 3a).
A scope: :global index asks no resolver anything - the choice was made at
the field, out loud, and decision 3c is what makes it visible:
iex> alias Encryptor.Ecto.BlindIndex.Derivation
iex> Derivation.new!(table: "identities", column: "email",
...> index_name: "email_index", scope: :global)
...> |> Derivation.selector!(%{vault: Signups.Vault, tenant: :none,
...> table: "identities", column: "email"}, :dump)
:globalA scope: :tenant index asks the encrypted field's own strategy, with the
field's declared context as the resolver's params - the same call
Encryptor.Ecto.Binary makes on the encryption path, so the two cannot
disagree about which tenant a row belongs to.
operation is the caller's, because ADR-0003 does not say which of
:dump/:load an index computation is and a resolver may legitimately
answer differently for a write and a read.
@spec slow_params!(module(), t()) :: Encryptor.Kdf.params()
The Argon2id parameters a slow index hashes under, read off vault.
Amendment C decision C5: the parameters are the vault's frozen :slow_hash
configuration, read through Encryptor.Vault.config/1, passed through and
never interpreted. enc-ADR-0003 amendment B decision 4 completes and
validates the set once, at vault start, so there is nothing left here to
check and nothing here that could disagree with the primitive about what a
complete set is.
A vault that declares no :slow_hash declares no slow parameters, and
decision C7 says what this package does with that: it raises
Encryptor.Ecto.BlindIndex.DerivationError and computes no value. It does
not fall back to a plain HMAC, which would write plain-cost bytes into a
column an operator believes is hardened, and it does not invent parameters,
which would be this package choosing a cryptographic parameter set.
The refusal is this package's words rather than the vault's - the contrast
with derive/3's missing :derivation_salt - because the vault cannot see
the declaration that asked. What is wrong is the pairing of a slow: true
declaration and a vault configuration that cannot serve it, and a reader
sent to the vault alone would find nothing there to fix.