Encryptor.Ecto.KeyStore (Encryptor.Ecto v0.4.0)

Copy Markdown View Source

The store-backed key provider: a wrapped-key table in, key descriptors out.

encryptor's ADR-0002 decision 5 puts the Ecto-backed provider in this package because it owns a schema, a migration and a repo, and its ADR-0003 decision 9 says the vault package defines no storage at all: the vault owns the six fields of Encryptor.Envelope.WrappedKey and nothing about where they live. This module is the other half - the table, the query, and the Encryptor.Provider implementation that turns rows into the descriptors a tenant vault builds keyrings from.

Configuring it

defmodule MyApp.TenantVault do
  use Encryptor.Vault, otp_app: :my_app, context_profile: :tenant

  def init(config) do
    # `root_subkey/2` takes the 32 root-key *bytes*, not a vault module.
    # Expand them once and hand the same value to both keys, so the
    # provider looks for a row under the reference the vault writes.
    subkey = Encryptor.Envelope.root_subkey(root_key(), "tenant-ref")

    {:ok,
     Keyword.merge(config,
       provider:
         {Encryptor.Ecto.KeyStore,
          repo: MyApp.Repo,
          root_vault: MyApp.RootVault,
          reference_subkey: subkey},
       reference_subkey: subkey
     )}
  end

  # The pinned reference root, wherever this host keeps key material -
  # the same bytes `MyApp.RootVault` is configured with.
  defp root_key do
    :my_app
    |> Application.fetch_env!(:root_key_base64)
    |> Base.decode64!()
  end
end
Option
:reporequiredThe Ecto.Repo the wrapped-key table lives in
:root_vaultrequiredThe vault the wrappings were produced by. Static provider, cache: false
:reference_subkeyrequired32 bytes: the pinned reference root expanded under "tenant-ref"
:table"encryptor_wrapped_keys"The table to read
:prefixnilThe schema prefix the table lives in; the repo's default when absent

:prefix is a placement decision, and it is singular

A host that puts the wrapped-key table in a non-default Postgres schema names that schema here, and every query this module issues carries it. It is singular for the same reason Encryptor.Ecto.Migrator's is: a prefix is a deployment-time placement decision rather than a fact about the table, so a host running several schemas configures one provider per schema rather than asking this module to enumerate them. Nothing here reads a database catalog to discover one.

The generators write no prefix into their migration source, deliberately. mix ecto.migrate --prefix is Ecto's own way to place a migration, it applies to the table and both indexes together, and baking the schema name into a file the host commits would freeze a placement decision into source that outlives it.

:reference_subkey is required, and it is not an extra

A row is found by tenant_ref, never by the selector: the selector is the host's tenant identifier and putting it in a column would publish it beside every ciphertext, which is the whole reason Encryptor.Envelope.tenant_ref/2 is a keyed derivation rather than a hash. So resolving a selector to a row is that derivation, and the subkey it derives under has to be in provider state. It must be the same value the vault itself is configured with, or the provider will look for a row under one reference while the vault writes a header claiming another.

What it does, and the three things it will not do

Encryptor.Provider.decryption_keys/2 reads every row for the selector's tenant_ref, newest version first, and unwraps each under the root vault. Encryptor.Provider.encryption_key/2 reads the same rows in the same single query and unwraps the newest one, which is the provider contract's "the encryption key is the current one" stated as one query rather than two.

One bad row is not the whole store

The two callbacks share the query and part company on what a row that will not unwrap means to each of them, because it does not mean the same thing.

Encryptor.Provider.encryption_key/2 unwraps the newest row and no other. A wrapping four rotations old that no longer opens - a root rotation the rewrap pass has not finished, a row somebody edited, a shape this build cannot serve - says nothing about whether this tenant can be written to, and blocking every write for the tenant on it would turn one stale row into an outage. The newest row is the one a write is going to be encrypted under, so it is the only one a write's answer may depend on.

Encryptor.Provider.decryption_keys/2 skips the rows that do not unwrap and answers with the ones that do, newest first. The list is a candidate list: a version missing from it is a version the vault cannot decrypt under, and that is already true of a row that will not unwrap. Halting on the first failure instead would make every stored value for the tenant unreadable to protect the subset written under the one bad version, which is the outage again, in the other direction.

When no row unwraps there is nothing to answer with, and the failure of the newest row is returned - the same term, for the same row, that a store holding only that row has always returned. So the arms below are unchanged for a tenant whose rows are all bad, and a partially-broken tenant now keeps the half that works.

A consequence worth naming: during a partial root rotation Encryptor.Provider.encryption_key/2 can fail while Encryptor.Provider.decryption_keys/2 succeeds with the older versions. That is the honest report - reads work, and a write must not go under a key this store cannot vouch for - and it is why the encryption key is not described here as "the head of the decryption list" any more.

It mints nothing. Encryptor.Provider.init/1 resolves configuration and touches no database; neither callback writes. Key creation is Encryptor.Envelope.provision/3, re-wrap is Encryptor.Envelope.rewrap/2, and crypto-shred is a DELETE the host schedules - all of them verbs that operate on a key, which ADR-0002 decision 9 keeps out of this package's task list. Resolution is a lookup, and the provider contract requires exactly that: a provider that minted material on the first encrypt after a deploy would fail Encryptor.Provider.Conformance's stability property, and rightly.

It issues no DDL. The table arrives as generated migration source the host reads, commits and runs - mix encryptor.ecto.gen.key_store_migration, the same arrangement ADR-0002 decision 9 already makes for the migrator's checkpoint table.

It adds no cache. The tenant vault's materials cache already collapses provider round trips to one per partition per max_age, and the provider contract names a second unbounded cache as the thing not to add.

The table

Column
idthe surrogate primary key Ecto.Migration.create/2 adds by default. This module never selects it
tenant_refEncryptor.Envelope.tenant_ref/2 of the host's selector. The lookup key
versionthe key version. Ordering is the store's job, per ADR-0002 decision 4
namespace, namewhat the encrypted data key matches on, byte for byte
bits256 on this path
wrappedthe wrapping, whose kind the next column names
wrapping_shapewhich kind of wrapping wrapped holds: "engine_message" or "gcp_kms_ciphertext"
key_idNULL for an engine message; the CryptoKey id a GCP ciphertext was produced under
inserted_at, updated_atnullable :utc_datetime timestamps the generator emits. This module neither writes nor reads them

The generated DDL is those eleven columns and nothing else (Mix.Tasks.Encryptor.Ecto.Gen.KeyStoreMigration.source/2). The timestamps are nullable and carry no default because this package writes no rows: a host that inserts them gets them, and one that does not gets NULL rather than a NOT NULL violation on its own insert.

wrapping_shape and key_id are ADR-0005's, and they are the reason a reader never guesses. Both wrapping kinds are opaque binaries, so a reader that picks the wrong unwrap path gets a failure indistinguishable from a wrong key - one varchar per row removes the guess. The vocabulary is closed at those two values here, by that record, rather than by a database enum: this module queries the table schemalessly and names no adapter, so a third value is an amendment to the record and a clause in this module, not a migration on every adopter.

Two unique indexes carry properties nothing at runtime can:

  • {tenant_ref, version} closes the race ADR-0003 leaves to this package - "calling provision/3 twice concurrently for one tenant can produce two rows claiming the same version; the transaction that closes that race is encryptor_ecto's". A second row claiming a live version is a candidate list with two entries for one version, and the loser of the race is the one no message was ever written under.
  • {namespace, name} is the name contract made mechanical. A name is bound to its bytes forever, and two rows sharing one is the failure the contract exists to prevent - caught by the database rather than years later as an undecryptable row.

A shred is a DELETE of the row, which is honest: the wrapping is the only copy of the key, so destroying it destroys the key. Nothing here soft-deletes, because a soft-deleted wrapping is still a wrapping.

The failure vocabulary

Where either callback answers at all, it answers in Encryptor.Provider.reason/0 and nothing else. The conditions that are not answers - a store configured wrong - raise instead, and "The failure that is not in the vocabulary" below is that case.

  • {:unknown_key, selector} - no row for this selector's tenant_ref. A settled negative answer, and the same answer for a selector a tenant store cannot have a reference for at all (:default, "").
  • {:key_unavailable, selector} - the store could not be asked, and asking again later could work. The repo is not started, the connection pool is exhausted, the server is shutting down or refusing connections, the query was cancelled. This is the one a caller retries, and telling it apart from the row genuinely being absent is why the provider contract carves both out of the decrypt path's collapse to :decrypt_failed.
  • {:invalid_key_descriptor, :unwrap_failed} - a row was found and did not unwrap under the root vault. During a root rotation that is the expected answer for a wrapping the rewrap pass has not reached yet. The underlying Encryptor.Error is deliberately not carried out of here: a provider's return travels into the vault's error struct, and a wrapped key's failure detail is the last place a value should be allowed to ride along.
  • {:invalid_key_descriptor, {:unknown_wrapping_shape, value}} - the row's wrapping_shape is not one of the two ADR-0005 decision 1 publishes. This one does carry the stored value out, and it is the only thing here that does: a shape is not a failure detail but one of a closed set of literals a record publishes, and an operator debugging a stray row needs to know which literal it was.
  • {:invalid_key_descriptor, :missing_key_id} - a "gcp_kms_ciphertext" row whose key_id is NULL. The requirement is a read-side rule rather than a NOT NULL because it is conditional on another column's value, and a conditional constraint is not portable DDL.
  • {:invalid_key_descriptor, :unexpected_key_id} - an "engine_message" row carrying a key_id. An engine message names its own keyring material inside the message, so a key id beside one means the row was written by something that did not know which shape it was writing.
  • {:invalid_key_descriptor, {:unsupported_wrapping_shape, "gcp_kms_ciphertext"}} - a well-formed GCP row this store cannot serve. ADR-0005's decision 5 branches per row, and its open question 2 leaves where the GCP branch's client comes from open; until that is decided this state holds a root_vault and nothing else, so the branch answers rather than crashes. A store with no GCP-shaped rows never reaches it.

None of those five widens Encryptor.Provider.reason/0: they are new terms inside {:invalid_key_descriptor, term()}, which is open by construction.

The failure that is not in the vocabulary

A store that was configured wrong does not answer at all: the exception raises out of the callback, unchanged.

Encryptor.Provider.reason/0 is a closed vocabulary and this package does not get to widen it, so there is no term here for "the table was never migrated", ":repo is not a repo" or "the columns are not the ones this version reads". Reporting those as {:key_unavailable, selector} - which is what a bare rescue did - is worse than having no term: it tells an operator to wait for a transient condition to clear, and it never clears. A host that forgot the migration would get key_unavailable for that tenant forever, and the Postgrex.Error naming the missing table would be dropped on the floor.

So the rescue is narrowed to the conditions a retry can actually resolve, and everything else keeps its own exception - Postgrex.Error with undefined_table or undefined_column, UndefinedFunctionError for a :repo that is not one. Those are deploy-time mistakes, they are permanent until somebody changes something, and a loud crash naming the real cause is the report they deserve. Nothing about this is a reason a caller matches on; it is the absence of one.

Records: encryptor ADR-0002 decisions 4, 5 and 6; ADR-0003 decisions 1, 3, 4 and 9; this package's ADR-0002 decision 9 and ADR-0005.

Summary

Types

One row of the wrapped-key table, as selected by rows/3.

What Encryptor.Provider.init/1 freezes for the life of the vault.

The closed vocabulary of ADR-0005 decision 1, as the read side branches on it.

Functions

Every live version for this selector, newest first.

The table name a host gets unless it names another.

The newest live version for this selector.

Resolves the provider's options into state. Opens no connection.

Types

row()

@type row() :: %{
  tenant_ref: String.t(),
  version: pos_integer(),
  namespace: String.t(),
  name: String.t(),
  bits: 256,
  wrapped: binary(),
  wrapping_shape: String.t(),
  key_id: String.t() | nil
}

One row of the wrapped-key table, as selected by rows/3.

state()

@type state() :: %{
  repo: module(),
  root_vault: module(),
  reference_subkey: binary(),
  table: String.t(),
  prefix: String.t() | nil
}

What Encryptor.Provider.init/1 freezes for the life of the vault.

Everything in it is constant and none of it is a connection: the repo is a module name, and the pool behind it is the repo's own business.

wrapping_shape()

@type wrapping_shape() :: :engine_message | :gcp_kms_ciphertext

The closed vocabulary of ADR-0005 decision 1, as the read side branches on it.

The stored column is a string; this is what the string is translated into before anything dispatches on it, and the translation is where a value the record does not publish stops being a row.

Functions

decryption_keys(state, selector)

@spec decryption_keys(state(), Encryptor.Provider.selector()) ::
  {:ok, [Encryptor.Key.Aes.t(), ...]} | {:error, Encryptor.Provider.reason()}

Every live version for this selector, newest first.

Dropping a row from the table is what removes an entry from this list, and that is the crypto-shred mechanism rather than a cleanup. A row that will not unwrap is skipped rather than fatal, for the reasons the moduledoc's "one bad row is not the whole store" gives; the error arrives only when no row unwrapped at all.

default_table()

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

The table name a host gets unless it names another.

iex> Encryptor.Ecto.KeyStore.default_table()
"encryptor_wrapped_keys"

encryption_key(state, selector)

@spec encryption_key(state(), Encryptor.Provider.selector()) ::
  {:ok, Encryptor.Key.Aes.t()} | {:error, Encryptor.Provider.reason()}

The newest live version for this selector.

The same single query Encryptor.Provider.decryption_keys/2 runs, so the two cannot disagree about which version is current - but only the newest row is unwrapped. An older wrapping that no longer opens is not a reason a tenant cannot be written to, and the moduledoc's "one bad row is not the whole store" says why at length.

init(opts)

@spec init(keyword()) :: {:ok, state()} | {:error, term()}

Resolves the provider's options into state. Opens no connection.

A missing or malformed option is refused here, at vault start, in the same terms Encryptor.Vault.Config uses for the same values - which is the point of refusing at start rather than on the first encrypted write.