Encryptor.Ecto.BlindIndex (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

Declares a keyed blind index on an encrypted field (ADR-0003 decisions 4, 3c and 6's declaration half).

A blind index stores, in a second column beside an encrypted one, a deterministic keyed fingerprint of the plaintext, so that equality on plaintext becomes equality on fingerprint. blind_index/3 is where a host says that a column has one, and what it is:

defmodule MyApp.Accounts.Customer do
  use Ecto.Schema
  import Encryptor.Ecto.BlindIndex

  schema "customers" do
    field :merchant_id, :string

    field :email, MyApp.Encrypted.String
    field :email_index, :binary
    blind_index :email, :email_index, normalize: :email

    field :phone, MyApp.Encrypted.String
    field :phone_index, :binary
    blind_index :phone, :phone_index, normalize: :digits
  end
end

The index column is an ordinary column the host declares in its own migration and its own schema (decision 5). This package writes no migrations, adds no fields, and hooks no Repo callbacks. What the declaration does is put the index's normalization and its key derivation in exactly one place, so that the changeset helper that writes the column and the query helper that reads it cannot disagree about either.

Options

OptionDefaultMeaning
:namethe index column's nameThe index_name component of the HKDF info string (decision 2)
:scope:tenant:tenant or :global key derivation (decision 3)
:normalize:noneWhat the HMAC is computed over (decision 4)
:bits256Stored width; 64/128/192 truncate the HMAC
:slowfalsetrue runs Argon2id before the HMAC
:version1Participates in the derivation, so a bump is a real rotation (decision 7)

:normalize is the option to read before any of the others. Encryptor.Ecto.BlindIndex.Normalizer documents the set and says, at the option rather than in a rotation appendix, that normalization is lossy and directional and that changing it invalidates every stored value in the column.

:bits narrows the stored value, never the key: the index key stays the full 32 bytes the derivation produces and :bits never reaches the HKDF info string, so a width change stores different bytes under the same key. The truncation is applied in Encryptor.Ecto.BlindIndex.Value, inside the one function every surface here computes through, which is what makes a write and a read agree on the width without either call site reading the option. Encryptor.Ecto.BlindIndex.Value's Width section documents which end is kept and why. A width change invalidates the column exactly as a normalizer change does (decision 7). What a narrower width actually buys, and what it does not, is Truncation and false positives below.

:slow is declared but not available. It is accepted, checked and carried on the declaration, and it does nothing to a computed value: a slow: true index and a slow: false index over the same plaintext store the same bytes. Decision 6 puts Argon2id's parameters in "the vault's configuration rather than this package's" and the vault exposes no Argon2id surface, so the half of the option that would read them is not implemented. Read it as a reserved option name rather than as a mitigation available today - the low-entropy row of the table below has no defence in this package until the vault grows one (enc-dtv). See Encryptor.Ecto.BlindIndex.Value's Width section.

Security properties

Adding a blind index to a column is a schema decision with a leakage cost, paid per column and permanently. ADR-0003's closing Consequences make this table the thing a host reviews before declaring an index rather than after, which is why it is here at the declaration rather than in a record the host reads once.

Throughout, equality structure means: which rows in the column share a value, and how many distinct values the column holds. It does not mean the values themselves.

An attacker holdingLearns from a scope: :tenant indexLearns from a scope: :global index
the dump, and no key materialequality structure within each tenant, and nothing across tenantsequality structure across the whole table, and across every table whose index derives under the same info components
the dump, and a guess at a plaintext they think is presentnothing: a candidate value cannot be computed without the index keynothing: the same
the dump and one tenant's index keywhich rows in that tenant hold any plaintext they can guess, and nothing about any other tenantwhich rows anywhere hold any plaintext they can guess
the same, over a low-entropy columnfull recovery of that column over the guessable space, at HMAC speedthe same, across every tenant at once
the dump and the encryption keyeverything; the index adds nothing once the column itself is readablethe same
a retained dump, after the tenant's key material is destroyednothing: the column is unusable noise, because no candidate value can be computed to compare against itequality structure survives the shred, and guessable values stay recoverable to anyone holding the index key

Three rows are worth reading twice.

The second row is the actual security claim, and it is the one the unkeyed folk pattern - :crypto.hash(:sha256, String.downcase(value)) into a :binary column - does not have. Against an unkeyed fingerprint, an attacker with the dump alone recovers every value whose plaintext comes from a guessable space, and the columns hosts most want to search on are exactly the guessable spaces. Keying the fingerprint is the whole security value of the feature.

The fourth row is the one with no mitigation here. Decision 6 offers :slow for it, and :slow is inert (above). A column whose plaintext space is small enough to enumerate is a column whose index key is worth exactly as much as the column, to anyone who obtains it.

The last row is why scope: :global has to be written out loud, and why declaring nothing on a tenant: :none field is a compile-time error rather than a silent fallback. A tenant whose key material has been destroyed still has its :global index columns answering equality questions about its data, which is not a shred in any sense a compliance conversation will accept.

One caveat applies to every row naming an index key. Holding an index key today means holding a vault that can also decrypt. ADR-0003 assumption A9 hoped for a search-only capability and was resolved against at acceptance: index keys are derived subkeys of the same key material the encryption keys derive from, so a component that can compute an index value can also read the column. What survives in full is the structural half - an index key is never an encryption key (Encryptor.Ecto.BlindIndex.Derivation documents the two-label nesting that makes that so), and the index shreds with the tenant key. Independently wrapped per-tenant index keys are the recorded upgrade path if a genuine search-only consumer ever materializes.

Per-tenant is the default, and it is what stops cross-tenant correlation

A scope: :tenant index keys off the tenant's own key material, resolved through the field's declared tenant strategy. The behaviour the suite pins: the same plaintext, in the same column, under two different tenants derives under different keys and therefore stores unrelated bytes. Two tenants sharing a customer are not visible as sharing one, to anyone reading the dump or a backup.

Two further separations are pinned the same way, and both fall out of the info string rather than out of discipline: two columns holding the same plaintext produce unrelated index values, so a dump cannot be joined across them; and two deployments provisioned from the same key material derive unrelated keys, because the vault's per-deployment :derivation_salt sits under the whole construction - a restored backup or a cloned staging database cannot be joined against production on an index column.

Equality, and nothing else

An encrypted column is not otherwise queryable, and a blind index does not change that: it restores exact match on one column and no other operation. There is no LIKE, no prefix or substring search, no range, no ordering, no MIN/MAX, no BETWEEN, and no operator argument that could be made to generate one. Decision 9 makes that permanent rather than pending.

The equality is also over norm(plaintext), never over plaintext. An index hit is not proof of byte equality, and Encryptor.Ecto.BlindIndex.Normalizer is where that is spelled out.

What this does not defend against

Two things, stated so that nobody assumes otherwise.

Frequency analysis within a scope. The index reveals which value is the most common one in its scope, and for a skewed distribution the plaintext is usually guessable from that alone. A country code, a plan name, or a status-like string that someone indexed by mistake is readable off the cardinality without any key at all. This is why the guidance is to reserve indexes for high-cardinality exact-match keys - an email address, a phone number, a tax identifier - and why an index on a low-cardinality column is a defect rather than a tradeoff.

A chosen-plaintext oracle. An attacker who can both cause rows to be written and observe the index column learns the index value of a plaintext of their choosing. A public signup form over an indexed column is that attacker. It is inherent to any deterministic index and cannot be designed away here; what bounds it is the scope, since the oracle a scope: :tenant index gives away is one tenant's, and the oracle a scope: :global index gives away is everybody's. That is one more argument for the default.

A related consequence of decision 8: a "" plaintext produces a real index value over norm(""), which is a constant per key scope. A host indexing a column where the empty string is common publishes that fact to anyone reading cardinality.

Truncation and false positives

:bits is decision 6's collision knob. A b-bit index value means two distinct normalized plaintexts collide with probability 2^-b, so over a scope holding N rows a lookup returns about N / 2^b spurious rows, and over D distinct values the column holds about D^2 / 2^(b+1) colliding pairs anywhere in it - one such pair expected at around D = 2^((b+1)/2).

The record calls the rate "known" without stating it, so the numbers below are that arithmetic rather than a citation:

:bitsspurious rows per lookup, per row in scopedistinct values before one collision is expected
645.4e-20about 6.1e9
1282.9e-39about 2.6e19
1921.6e-58about 1.1e29
2568.6e-78about 4.8e38

Read them honestly: at every width this package offers, a false positive is not something a real table will see. A 64-bit index would need on the order of six billion distinct values in one scope before colliding even once, so the blurring decision 6 describes is theoretical at these widths and :bits is in practice a storage choice. The 256 row is HMAC-SHA256's own collision probability and is the floor no width beats.

The obligation where_eq_candidates/3 carries is still real, because the rate is not zero and a host that assumes exactness has assumed something the width does not promise. But a host choosing :bits to obscure its equality structure should know it is buying almost nothing, and a host choosing it for storage should know it is a reindex to undo.

Every invalidating change is a reindex

Five things change the bytes a column must hold, and each one requires decision 7's two-column sequence over rows decrypted in tenant scope. There is no in-place recomputation, because recomputing requires the plaintext.

ChangeWhy the stored bytes move
:normalizethe HMAC is taken over different bytes
:bitsthe same value is stored at a different width
:versionit participates in the HKDF info, so the key changes
the vault's :derivation_saltit sits at the extract under every derivation, so every index key changes
a tenant's key material rotatingthe derivation consults the current key, so that tenant's index keys change without any declaration changing

The last row is the one with no supported sequence today, and it is stated as today's truth rather than as a promise. Decision 7's dance covers a change to a declaration; a tenant key rotation changes no declaration, and during the window between the rotation and a reindex of that tenant's index columns, where_eq/3 matches nothing and raises nothing. where_eq_candidates/3 does not help: it is the truncation surface, not a multi-key candidate surface. A host rotating a tenant key must reindex that tenant's index columns, and what this package should do about it is an open question for the record rather than a default invented here.

Rotating :derivation_salt has the same shape and reaches further: it is a full reindex of every index column in the deployment, for every tenant. The salt is effectively permanent from the first stored index value.

The index can be forgotten, and nothing prevents it

This is the gap ADR-0003 names rather than hides, and it is the mirror image of this package's central property. Encryption cannot be forgotten, because it lives in the type. A field declared MyApp.Encrypted.String encrypts on every write through every path, including one nobody remembered. Indexing can be forgotten, because it lives in the call site. put_index/3 is a line in a changeset, so a second write path - another changeset function, a bulk insert, an admin script, a data backfill - produces a row whose index column is absent or stale, and whose lookup then silently misses. The row exists and the query does not find it.

Making the index automatic would mean a Repo hook or a changeset macro owning cast/3, which is a larger intrusion into a host than this package is willing to make. So the mitigation ADR-0003 proposes is a test-support assertion a host runs over its own write paths, and the honest statement is that nothing enforces it: no such assertion ships here yet, the compile-time checks cover declaration hygiene and say nothing about call sites, and neither the compiler nor this package can see a write path it is not in. A host adopting a blind index owns the audit of its own writers.

Two indexes over one field

A field may declare more than one index - decision 3d's per-tenant and global pair, or decision 7's rotation pair - and they are distinct because their index_names are. The default :name is the index column's name rather than the source field's, so two indexes over one field are distinct without the host writing anything:

field :email, MyApp.Encrypted.String
field :email_index, :binary
field :email_v2_index, :binary
blind_index :email, :email_index
blind_index :email, :email_v2_index, version: 2

Decision 6 tables the default as "the column name" and the record uses "column" for both the source and the index column. The reading taken here is the one that makes decision 2's "index_name distinguishes two indexes over the same column" true: a default taken from the source field would give every index on one field the same index_name, which is the collapse the component exists to prevent.

The two compile-time errors

Both are decision 3c's, and both are deliberate.

A tenant: :none field must write its :scope. scope: :global is the only possibility on a field that has no tenant, and declaring nothing is an error rather than a silent fallback to it. The reviewer reading that schema line is the person who needs to know that the column is cross-tenant correlatable and survives a tenant shred, and silence is exactly what a reviewer does not see. Declaring scope: :tenant on such a field is the other half of the same error (open question Q4): a global ciphertext with a per-tenant index is an incoherent pair, and it stays an error until somebody brings the case.

An index on a field this package does not encrypt is an error. The derivation reads the encrypted field's frozen Ecto.ParameterizedType params for the declared table and column that reach the HKDF info string, so a source that is a plain :string field, or no field at all, has nothing to derive from.

Three further conditions are refused at compile time as well. They are not in the record, and they are declaration hygiene rather than decisions: the index column must be a field on the schema, since nothing can write a column that is not one; and two declarations may not share a {source, column} pair or an index_name, since either collapses two indexes the design keeps distinct onto one column or one key.

All of them are raised from an @after_compile hook rather than at the declaration, because that is the first moment the schema's own field list is readable - a blind_index line may legitimately be written above the field it indexes.

Reading a declaration

Encryptor.Ecto.BlindIndex.Declaration is the read surface: list/1 and list/2 enumerate a schema's indexes, fetch!/3 resolves one, and derivation!/1 and normalize!/2 turn it into the two things computing an index value needs. Encryptor.Ecto.BlindIndex.Derivation performs the key derivation itself, through the vault, and Encryptor.Ecto.BlindIndex.Value is the one place the two meet.

The two helpers, and no magic (decision 5)

A declaration on its own writes nothing and reads nothing. put_index/3 computes the column on the write side and where_eq/3 constrains it on the read side, and both read their configuration from the declaration above, so they cannot disagree about normalization or key derivation:

changeset
|> cast(attrs, [:email])
|> put_index(:email, :email_index)

from(c in Customer) |> where_eq(:email, "bob@example.com")

put_index/3 does not recompute an index whose source field was not changed, and writes nil when the source is set to nil (decision 8): a NULL plaintext beside a non-NULL index would leak that a value exists.

where_eq/3 expands to an equality on the index column and to nothing else. There is no where_like, no where_gt, no ordering helper, and no operator argument that could be made to generate one - decision 9 makes equality the whole surface permanently, and an operator parameter is how a package ships the rest of it by accident. where_eq_candidates/3 is the same constraint under the weaker contract a truncated index answers under (decision 6), and compute/3 is the value itself, for a host building its own query.

Both raise on a missing tenant, where_eq/3 included

This is the sharpest requirement in the record. A scope: :tenant index computed outside tenant scope raises Encryptor.Ecto.MissingTenantError identically to ADR-0001 decision 5c, on the read side as well as the write side - so a query built outside tenant scope raises where it is built, rather than being executed and matching nothing. A blind-index query that silently matches nothing is the worst failure this feature can have, because it looks exactly like "the record does not exist".

Naming the index when a field has more than one

put_index/3 always names its column. where_eq/3, where_eq_candidates/3 and compute/3 take the source field, which resolves on their own when the field has exactly one index and is ambiguous when it has the two that decision 3d's per-tenant/global pair and decision 7's rotation pair both produce. Each therefore has a four-argument form naming the index column:

from(c in Customer) |> where_eq(:email, :email_v2_index, "bob@example.com")

which is what decision 7 step 4 - "switch where_eq/3 to the new version" - is written with, since during the rotation window both versions are declared and neither is the one the source field means. The three-argument form refuses an ambiguous field by name rather than picking; the arities are the record's and the disambiguating form is this package's addition to them.

Two things the record does not settle, carried here

ADR-0003 Q3, a query outliving its scope. where_eq/3 binds the tenant at build time, which is the fail-loud choice and the one decision 5 makes. The consequence is that the returned Ecto.Query carries a tenant-specific constant that is invisible in the struct and wrong if the struct is reused in another tenant's scope - it would then match nothing, which is the failure mode the build-time raise exists to prevent, arriving by a different road. Nothing here stamps the query or checks at execute: that is a guard the record leaves open, and adding one unasked would put a tenant identifier into a struct hosts serialize. It is recorded rather than resolved.

A tenant key rotation is not the rotation this record describes. Decision 7's two-column dance covers a change to a declaration - a version, a normalizer, a width. encryptor's ADR-0003 amendment A decision 7 consults only the current encryption key, so rotating a tenant's key changes every index key under it without any declaration changing, and every value already stored under the superseded key stops being derivable. During that window where_eq/3 matches nothing and raises nothing. ADR-0003 does not describe that case and this package does not invent a behaviour for it: where_eq_candidates/3 is decision 6's truncation surface and is not a multi-key candidate surface. A host rotating a tenant key must reindex that tenant's index columns, and what the package should do about it is an open question for the record rather than a default chosen here.

Summary

Functions

Declares a blind index on source, stored in column.

The index value itself, for hosts building their own queries.

The same, naming the index column.

Computes and puts the index column for a changed source field.

Checks every blind index declared on a schema, raising on the first fault.

Adds an equality constraint on a full-width index.

The same, naming the index column.

Adds an equality constraint on a truncated index. Returns candidates: the caller filters after decrypting.

The same, naming the index column.

Functions

blind_index(source, column, opts \\ [])

(macro)
@spec blind_index(atom(), atom(), keyword()) :: Macro.t()

Declares a blind index on source, stored in column.

Used inside a schema module - conventionally inside the schema/2 block, beside the fields it names, though anywhere in the module body works. See the moduledoc for the options and for what is refused at compile time.

compute(schema, source, value)

@spec compute(module(), atom(), term()) :: binary()

The index value itself, for hosts building their own queries.

The read-side computation, asked of the tenant strategy with :load exactly as where_eq/3 is, so a host-built query and a helper-built one constrain the same bytes.

A host using this to write a column - a backfill, a second write path - is on the write side and should reach for put_index/3, which asks with :dump. The two differ only for a host whose resolver answers differently for reads and writes, and that host is the one ADR-0001 has in mind when it allows it.

The returned value is a directly usable search token. It is on ADR-0003's never-logged list beside plaintext and key material, and a host that puts one in a log line has published the ability to confirm that value's presence to anyone reading it.

iex> alias Encryptor.Ecto.BlindIndex
iex> alias Encryptor.Ecto.TestSchemas.Customer
iex> Encryptor.Ecto.Tenant.put("merchant_7f3")
iex> byte_size(BlindIndex.compute(Customer, :phone, "+1 (555) 0100"))
32

compute(schema, source, column, value)

@spec compute(module(), atom(), atom(), term()) :: binary()

The same, naming the index column.

put_index(changeset, source, column)

@spec put_index(Ecto.Changeset.t(), atom(), atom()) :: Ecto.Changeset.t()

Computes and puts the index column for a changed source field.

Reads the cast plaintext, applies the field's normalizer, computes the HMAC under the field's index key, and puts the result in column:

changeset
|> cast(attrs, [:email])
|> put_index(:email, :email_index)

Three behaviours are decision 5's and decision 8's rather than conveniences, and each is observable:

  • A source that was not changed is not recomputed. The changeset is returned untouched - not with the same value written again - so a changeset that does not cast the source leaves a stored index alone instead of rewriting it, and so an update in a scope that cannot derive the key does not raise for a field it was not touching.
  • A source set to nil sets the index to nil. A NULL plaintext beside a non-NULL index would leak that a value exists, which is exactly ADR-0001 decision 7's rule. No key is derived on that path, because none is needed: writing NULL is not a computation and a missing tenant does not make it one.
  • A source set to "" gets a real index value, over norm(""). That is a constant per key scope, so a host indexing a column where the empty string is common publishes that fact to anyone reading cardinality.

Raises Encryptor.Ecto.MissingTenantError when a scope: :tenant index is computed outside tenant scope, Encryptor.Ecto.BlindIndex.NormalizationError when the declared normalizer cannot produce a binary, and ArgumentError when no index is declared for the {source, column} pair. There is no :error arm, for ADR-0001 decision 6's reason: the failure paths raise.

iex> alias Encryptor.Ecto.TestSchemas.Customer
iex> %Customer{email: "bob@example.com", email_index: <<0, 1, 2>>}
...> |> Ecto.Changeset.cast(%{email: nil}, [:email])
...> |> Encryptor.Ecto.BlindIndex.put_index(:email, :email_index)
...> |> Ecto.Changeset.fetch_change(:email_index)
{:ok, nil}

iex> alias Encryptor.Ecto.TestSchemas.Customer
iex> %Customer{}
...> |> Ecto.Changeset.cast(%{}, [:email])
...> |> Encryptor.Ecto.BlindIndex.put_index(:email, :email_index)
...> |> Ecto.Changeset.fetch_change(:email_index)
:error

validate!(schema)

@spec validate!(module()) :: :ok

Checks every blind index declared on a schema, raising on the first fault.

Called for a schema automatically as it compiles; public because a host that builds schemas some other way still wants the check, and because a check that can only be observed by compiling something is a check nobody can test.

where_eq(queryable, source, value)

@spec where_eq(Ecto.Queryable.t(), atom(), term()) :: Ecto.Query.t()

Adds an equality constraint on a full-width index.

from(c in Customer) |> where_eq(:email, "bob@example.com")

expands to where: c.email_index == ^computed, and to nothing else. Equality is the whole surface (decision 9): there is no where_like, no where_gt, no ordering helper, and no operator argument.

The value is normalized before it is fingerprinted, so the query finds "Bob@Example.COM " when asked for "bob@example.com" under a normalize: :email index. Normalization is lossy and directional, and an index hit is not proof of byte equality.

Raises Encryptor.Ecto.MissingTenantError when the index is scope: :tenant and there is no tenant in scope - at build time, which is the point: a query built outside scope must not be executable and match nothing.

Raises ArgumentError when the index is truncated (bits other than 256), naming where_eq_candidates/3, because a truncated index answers with a candidate set the caller has to filter after decrypting and a call site that did not say so has forgotten it.

iex> import Encryptor.Ecto.BlindIndex
iex> alias Encryptor.Ecto.TestSchemas.Customer
iex> Encryptor.Ecto.Tenant.put("merchant_7f3")
iex> where_eq(Customer, :phone, "+1 (555) 0100").wheres |> length()
1

where_eq(queryable, source, column, value)

@spec where_eq(Ecto.Queryable.t(), atom(), atom(), term()) :: Ecto.Query.t()

The same, naming the index column.

Decision 7's rotation window is what this exists for: while both versions are declared, the source field names two indexes and neither is the one meant. It is also the form to reach for beside decision 3d's per-tenant and scope: :global pair on one field.

where_eq_candidates(queryable, source, value)

@spec where_eq_candidates(Ecto.Queryable.t(), atom(), term()) :: Ecto.Query.t()

Adds an equality constraint on a truncated index. Returns candidates: the caller filters after decrypting.

A narrower index produces false-positive matches at a known rate, which is decision 6's whole purpose - the collisions blur the equality structure an attacker reads out of the column. The cost is that the rows this constrains are candidates rather than matches, and the host must filter them after decrypting. The name is the reminder; the record chose it so the call site could not forget.

It also accepts a full-width index, where the candidate set happens to be exact. Refusing that direction as well would make the pairing total, and ADR-0003 states only the other half of it - where_eq/3 refusing a truncated index - so the reverse refusal is left to the record rather than decided here. The weaker contract is sound over a full-width index either way.

Raises exactly what where_eq/3 raises, minus the truncation refusal.

where_eq_candidates(queryable, source, column, value)

@spec where_eq_candidates(Ecto.Queryable.t(), atom(), atom(), term()) ::
  Ecto.Query.t()

The same, naming the index column.