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

Copy Markdown View Source

The normalizers a blind index declares, and the guarantee that applying one is total (ADR-0003 decision 4).

An index answers equality over norm(plaintext), never over plaintext. This module is norm: it is handed a binary and a declared normalizer and returns the bytes the HMAC is computed over. It computes no index values, it reads no schema, and it holds no key material.

The set

NormalizerDoes
:noneByte-exact. The default, because the default should be the one that loses nothing
:trimString.trim/1
:downcaseString.trim/1 then String.downcase/1
:emailExactly :downcase. The local part is case-sensitive per RFC 5321 and no host wants that
:digitsKeeps the bytes ?0..?9 and drops everything else
{module, function}A host-supplied (binary() -> binary())

Normalization is lossy and directional, and changing it is a reindex

Both consequences are stated at the option rather than left to a rotation appendix, because the moment a host chooses a normalizer is the only moment the choice is free.

Lossy and directional. A :downcase index finds "Bob@Example.COM " when asked for "bob@example.com". A host that reads an index hit as proof of byte equality is wrong, and the wrongness is invisible until two values that normalize together are treated as one. It matters most where ADR-0003 decision 9 allows a unique constraint over the index column: the constraint is then uniqueness of norm(plaintext) within the key's scope, so a normalizer that merges two real values merges two real rows.

Changing it invalidates the column. Every stored value was computed under the old rule, so a normalizer change is a reindex - decision 7's two-column sequence, handled exactly like a key rotation. The normalizer is deliberately not mixed into the HKDF info (Encryptor.Ecto.BlindIndex.Derivation): mixing it in would turn a normalizer change into a silent no-match, where every lookup quietly returns nothing, instead of a reindex the host has to plan. The reindex is the honest path.

:digits and international phone numbers

ADR-0003 open question Q5, carried here and still open. Stripping to digits is right for a single-country host and merges "+1 555 0100" with "5550100", which under a unique constraint can merge two real people's rows. The record ships the normalizer and flags it; nothing here resolves it. A host with international numbers should normalize to E.164 with its own {module, function} normalizer rather than reach for :digits.

Totality

ADR-0003 requires normalizers to be pure and total, and to raise on no binary. The built-ins meet that on every binary including one that is not valid UTF-8: :digits iterates bytes, and String.trim/1 and String.downcase/1 pass bytes they cannot interpret through unchanged rather than raising. There is therefore no arm on which a built-in normalizer can fail, which is why every condition Encryptor.Ecto.BlindIndex.NormalizationError reports comes from a host normalizer or from a non-binary argument.

A host normalizer gets no such guarantee, so it is called inside a rescue. Raising, throwing, exiting, not being exported, and returning anything that is not a binary all become the same NormalizationError, which names the table, the column, the index and the normalizer - and no value.

Summary

Types

What a failure is allowed to name: the encrypted field's declared table and column, and the index being computed. Never a value.

t()

A declared normalizer: one of the built-in atoms, or a host function.

Functions

The built-in normalizer names, in the order ADR-0003 decision 4 tables them.

Applies a declared normalizer to one value.

Whether a term is a normalizer this package will accept at a declaration.

Types

context()

@type context() :: [
  table: String.t() | nil,
  column: String.t() | nil,
  index_name: String.t() | nil
]

What a failure is allowed to name: the encrypted field's declared table and column, and the index being computed. Never a value.

t()

@type t() :: :none | :trim | :downcase | :email | :digits | {module(), atom()}

A declared normalizer: one of the built-in atoms, or a host function.

Functions

builtin()

@spec builtin() :: [atom()]

The built-in normalizer names, in the order ADR-0003 decision 4 tables them.

iex> Encryptor.Ecto.BlindIndex.Normalizer.builtin()
[:none, :trim, :downcase, :email, :digits]

normalize!(normalizer, value, context \\ [])

@spec normalize!(t(), binary(), context()) :: binary()

Applies a declared normalizer to one value.

iex> Encryptor.Ecto.BlindIndex.Normalizer.normalize!(:email, " Bob@Example.COM ")
"bob@example.com"

iex> Encryptor.Ecto.BlindIndex.Normalizer.normalize!(:digits, "+1 (555) 0100")
"15550100"

iex> Encryptor.Ecto.BlindIndex.Normalizer.normalize!(:none, " Bob@Example.COM ")
" Bob@Example.COM "

context is what a failure is allowed to name, and the caller supplies it because this module has no schema to read it from. Encryptor.Ecto.BlindIndex.Declaration.normalize!/2 is the arm that fills it in from the declaration.

iex> Encryptor.Ecto.BlindIndex.Normalizer.normalize!(
...>   {String, :to_atom}, "bob", table: "signups", column: "email",
...>   index_name: "email_index")
** (Encryptor.Ecto.BlindIndex.NormalizationError) a blind index value could not be normalized (table: "signups", column: "email", context keys: [], tenant: nil, reason: {:returned, :not_a_binary}, index name: "email_index", normalizer: {String, :to_atom})

valid?(normalizer)

@spec valid?(term()) :: boolean()

Whether a term is a normalizer this package will accept at a declaration.

A {module, function} pair is accepted on its shape alone. Whether the function exists cannot be answered where the declaration is written - the host module naming it may not be compiled yet, and a check that forced it to be would make declaration order significant.

iex> alias Encryptor.Ecto.BlindIndex.Normalizer
iex> {Normalizer.valid?(:email), Normalizer.valid?({MyApp.Phone, :e164})}
{true, true}

iex> alias Encryptor.Ecto.BlindIndex.Normalizer
iex> {Normalizer.valid?(:uppercase), Normalizer.valid?({MyApp.Phone, "e164"})}
{false, false}