The index value itself: ADR-0003 decision 1, over a declaration.
Encryptor.Ecto.BlindIndex.Derivation answers where the index key comes
from and Encryptor.Ecto.BlindIndex.Declaration answers what the value is
computed over. This module is the one place the two meet:
index_value = HMAC-SHA256(index_key, norm(plaintext))Or, for a slow: true declaration (ADR-0003 decision 6, as amendment C
fixes it):
index_value = HMAC-SHA256(index_key, Argon2id(norm(plaintext), index_salt))Every surface in Encryptor.Ecto.BlindIndex - put_index/3, where_eq/3,
where_eq_candidates/3 and compute/3 - computes through compute!/3 and
nothing else. That is what makes decision 5's promise structural rather than
a review preference: the write side and the read side cannot disagree about
normalization or derivation, because neither of them performs either.
The order of operations, and why it is this one
Three things happen before any plaintext is touched, and the first of them
is the tenant. A slow: true declaration adds a fourth, and it happens
before the plaintext too.
- The selector is resolved, which is where a missing tenant raises (decision 3a, ADR-0001 decision 5c). It is first because it is the only step that does not depend on the value, and because a blind-index computation outside tenant scope has to fail the same way whether the value is well-formed or not. Decision 5 calls a silently-matching-nothing query the single worst failure this feature can have; a scope check that could be reached only after a normalizer succeeded would be one that a host could stop reaching.
- The slow parameters are read, for a
slow: truedeclaration only, which is where amendment C's decision C7 refuses a vault that declares no:slow_hash. It is here, above the value, for the reason the selector is first: what is wrong in that case is the pairing of a declaration and a vault configuration, a constant that is wrong in the source, and a constant that is wrong in the source has to be wrong whether or not the value that arrived happens to normalize. - The value is normalized (decision 4), under the declaration's normalizer, with the encrypted field's declared table and column reaching the failure so it names the schema line.
- The slow hash is taken, again for a
slow: truedeclaration only, over the normalized bytes and under a salt derived for this index. The Slow hashing section below is what that salt is. - The key is derived through the vault, and the HMAC is taken over whichever of the two the steps above produced.
Width
The value is the leading bits / 8 bytes of HMAC-SHA256, where bits is
the declaration's - the full 32 at the bits: 256 default, and 8, 16 or 24
under 64, 128 or 192. That is ADR-0003 decision 6's :bits, and three
things about how it is applied are load-bearing:
- The output is truncated, never the key. The index key stays the full
32 bytes
Encryptor.Ecto.BlindIndex.Derivationderives, and:bitsnever reaches the derivation - it is not in the HKDFinfostring, and abitschange therefore does not change which key an index derives. Truncating the key instead would weaken the HMAC itself, which is not what decision 6 asks for: the record calls:bitsa collision knob, and collisions are a property of the stored value's width. - The leading bytes, not the trailing ones. RFC 2104 section 5 defines
HMAC truncation as "the leftmost t bits", and NIST SP 800-107 section
5.3.1 says the same for truncating any approved hash output. Either end
is equally sound over HMAC-SHA256, so this is a convention rather than a
security choice - but it is a constant a host's stored bytes depend on
forever, so it is written down here rather than left to the reader of
binary_part/3. - One place, so the two sides agree. Truncation happens here, after
the HMAC and inside the single function every surface computes through,
so a
put_index/3write and awhere_eq_candidates/3read cannot store and pin different widths. It is decision 5's promise applied to decision 6, and it is why:bitsis read at no call site.
A bits change therefore invalidates the column exactly as decision 7 says
it does - the stored value changes even though the key does not - and the
two-column dance is the migration, with the new width declared under its own
index_name or :version.
Slow hashing
:slow is the other half of decision 6, and unlike :bits it is applied
before the HMAC rather than after it. A slow: true declaration hashes
the normalized value with Argon2id first and takes the HMAC over the 32
bytes that come back:
normalized = norm(plaintext)
index_salt = Derivation.derive_salt(vault, derivation, selector)
slow_input = Encryptor.Kdf.slow_hash(normalized, index_salt, params)
index_value = leading bits/8 bytes of HMAC-SHA256(index_key, slow_input)Three things about that are decisions rather than implementation, and each one is amendment C's:
- The parameters are the vault's, never this package's (C5). They are
the frozen
:slow_hashconfigurationEncryptor.Ecto.BlindIndex.Derivation.slow_params!/2reads, passed through without being interpreted, and a vault that declares none gets a refusal rather than a default - C7, and the reason:slowshipped inert until the vault had a surface to read them from. :slowdoes not reach the HKDFinfostring, exactly as:bitsdoes not and for the same reason: it is a property of the stored value, so flipping it changes the bytes without changing which key the index derives. What it does change is the salt'sinfo- but only by existing at all, since aslow: falsedeclaration derives no salt.- The salt derivation is lazy (C5 again). A
slow: falsedeclaration performs exactly onederive/3call and decision 1's formula is literally unchanged for it; aslow: trueone performs two, the second an HKDF expansion standing next to an Argon2id hash tuned to cost tens of mebibytes, which is not the cost anyone will measure.
Turning :slow on over an already-written column invalidates it, in
decision 7's ordinary sense and with decision 7's two-column dance as the
migration. Amendment C's C6 calls out the one case a host cannot avoid by
changing nothing: a column written under a slow: true declaration before
this was wired holds plain-HMAC bytes, because the option was accepted and
inert then.
Redaction
Nothing this module raises carries the plaintext, the normalized value, the index value, or the derived key. The index value is on that list for ADR-0003's own reason: it is a directly usable search token to anyone who reads it out of a log line.
A vault error is re-raised exactly as the vault phrased it, rather than
being wrapped. Encryptor.Ecto.BlindIndex.DerivationError reports constants
that are wrong in the source; a vault with no :derivation_salt configured
is neither that nor something this package can see, and putting this
package's words on it would send a reader to the wrong file.
Summary
Functions
The stored width of one declaration's index value, in bytes.
The index value for one declaration and one plaintext.
Functions
@spec byte_width(Encryptor.Ecto.BlindIndex.Declaration.t()) :: pos_integer()
The stored width of one declaration's index value, in bytes.
Public because it is what a host sizes its index column against, and because
it is the arithmetic the operator's crypto read checks rather than infers
from a div/2 buried in a pipeline.
iex> Encryptor.Ecto.BlindIndex.Declaration.fetch!(
...> Encryptor.Ecto.TestSchemas.Customer, :email, :email_index)
...> |> Encryptor.Ecto.BlindIndex.Value.byte_width()
32
iex> Encryptor.Ecto.BlindIndex.Declaration.fetch!(
...> Encryptor.Ecto.TestSchemas.Customer, :email, :email_short_index)
...> |> Encryptor.Ecto.BlindIndex.Value.byte_width()
8
@spec compute!( Encryptor.Ecto.BlindIndex.Declaration.t(), term(), Encryptor.Ecto.TenantContext.operation() ) :: binary()
The index value for one declaration and one plaintext.
operation is the Encryptor.Ecto.TenantContext operation the resolver is
asked with, and it is the caller's: a write-side computation asks with
:dump and a read-side computation asks with :load, matching what the
encrypted field itself would be doing at the same moment.
Encryptor.Ecto.BlindIndex.Declaration's moduledoc records that mapping and
why it is this package's to make.
The result is byte_width/1 bytes wide - the declaration's :bits, applied
to the HMAC output as the moduledoc's Width section describes.
Raises Encryptor.Ecto.MissingTenantError when a scope: :tenant index is
computed outside tenant scope, and
Encryptor.Ecto.BlindIndex.NormalizationError when the declared normalizer
cannot produce a binary. A value that is not a binary is the normalizer's
refusal rather than a separate one, so a host indexing a field this package
encrypts but cannot fingerprint learns it in the same words. A slow: true
declaration also raises Encryptor.Ecto.BlindIndex.DerivationError when the
vault it names declares no :slow_hash parameters (amendment C decision
C7).