Encryptor.Ecto.BlindIndex.Derivation (Encryptor.Ecto v0.2.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.

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.

The HKDF info string for one derivation identity.

Builds a validated derivation identity.

The label of the outer expansion, which belongs to encryptor.

Resolves which scope's key material a derivation needs (decision 3a).

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]

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"

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.