Encryptor.Ecto.BlindIndex.Derivation (Encryptor.Ecto v0.4.0)

Copy Markdown View Source

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.

t()

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

field_params()

@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.

scope()

@type scope() :: :tenant | :global

ADR-0003 decision 3's key scope, declared at the field.

selector()

@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.

t()

@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

derive(vault, derivation, selector)

@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.

derive_opts(derivation, selector)

@spec derive_opts(t(), selector()) :: keyword()

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]

derive_salt(vault, derivation, selector)

@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.

info(derivation)

@spec info(t()) :: String.t()

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"

new!(opts)

@spec new!(keyword()) :: t()

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)

outer_label()

@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"

salt_derive_opts(derivation, selector)

@spec salt_derive_opts(t(), selector()) :: keyword()

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]

salt_info(derivation)

@spec salt_info(t()) :: String.t()

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"

selector!(derivation, params, operation)

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)
:global

A 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.

slow_params!(vault, derivation)

@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.