Encryptor.Vault.Config (Encryptor v0.2.0)

Copy Markdown View Source

A vault's resolved configuration: the five-layer precedence chain, every check that runs at start, and the :persistent_term freeze the hot path reads.

Configuration is resolved once, when the vault starts, and frozen into this struct under the :persistent_term key {Encryptor.Vault, vault}. Per-call reads are then lock-free and allocate nothing, which is what makes a check on every encrypted-column read affordable. Changing configuration under a running vault means restarting it, deliberately: configuration that changes key selection silently underneath in-flight operations is worse than an explicit restart (ADR-0001 decision 5).

The precedence chain

Lowest to highest, exactly as ADR-0001 decision 5 fixes it:

  1. defaults/0, declared by this package,
  2. options passed to use Encryptor.Vault,
  3. Application.get_env(otp_app, vault),
  4. options passed to start_link/1,
  5. the return of the vault's optional init/1 callback.

Layers merge by top-level key. A layer that supplies :cache replaces the whole cache setting rather than deep-merging into the layer below it, because a host that inherits half a bound it never wrote has no way to read its own configuration off the page. Sub-defaults for the cache are applied once, during validation, to whichever layer won.

init/1 receives the merged keyword list and returns {:ok, config}; its return replaces the merge rather than being merged over it. It is the runtime escape hatch and the intended place to read key material out of the environment or a secrets manager.

Layer 1 is the one exception to "replaces". The package defaults are applied a second time, underneath whatever init/1 returned, so a callback that builds a fresh keyword list rather than adding to the one it was handed does not silently drop the commitment policy floor and the EDK limit on its way past. Every default is stated once, in defaults/0, and nowhere else - a validator that carried its own copy of a default would be a second place for it to drift.

Key material is never a compile-time option

validate_use_opts!/2 raises at compile time when a use option, or a :provider option nested inside one, names key material: :key, :keys, :root_key, :private_key, :passphrase, or :reference_subkey. A secret in use options is a secret compiled into a .beam file and committed to the host's build artifacts, and the vault refuses to be the reason that happens (ADR-0001 decision 5; ADR-0004 decision 4 for the reference subkey).

That list is closed and is extended by an ADR, not by a call site.

:derivation_salt is refused there too, and for a different reason that the refusal message states: it is not secret, but it is per deployment, and a per-deployment value compiled into a .beam is shared by every deployment built from that artifact (ADR-0003 amendment A decision 3).

What is checked at start

Every check below produces an Encryptor.Error with operation: :start and a reason from the closed vocabulary. None of them is deferred to the first encrypt: a vault that cannot be configured correctly does not start.

  • :provider is required, is a {module, opts} pair, and may not carry both :key and :keys (ADR-0005 decision 4). Its Encryptor.Provider.init/1 runs here, once, and what it returns is frozen as :provider_state - the state every later resolution callback is handed (ADR-0002 decision 1). A provider exporting no init/1 keeps its option list as its state, which is the fallback Encryptor.Provider.init/2 owns.
  • :commitment_policy defaults to :require_encrypt_require_decrypt and may be relaxed to :require_encrypt_allow_decrypt. :forbid_encrypt_allow_decrypt is refused outright: that policy exists to write non-committed messages, this package has never written one, and a key that can turn key commitment off will eventually be turned off by someone who does not know what it does (ADR-0001 decision 8).
  • :max_encrypted_data_keys defaults to 10 and may never be nil. The engine reads nil as unlimited, and an unlimited EDK count on the decrypt path is a work-amplification lever handed to whoever supplies the ciphertext.
  • :algorithm_suite_id defaults to 0x0578 and accepts 0x0478. See "Choosing an algorithm suite" below.
  • :cache is false or a keyword list. :max_age is required with no default; :max_messages defaults to 100, :max_bytes to 1 GiB, and :recycle_after to 20 * max_age (ADR-0001 decision 6).
  • :context_profile is :single or :tenant, and :required_context is a list of context keys (ADR-0004 decision 3).
  • On a :tenant vault :reference_subkey is required, and when the deployment has pinned a :reference_check value the subkey must reproduce it (ADR-0004 decision 4).
  • :derivation_salt is optional on both profiles, and when present is a binary of at least 32 bytes. It is the one check here that is not complete at start: a vault without it starts, and only Encryptor.Vault.derive/3 fails, with {:missing_config, [:derivation_salt]} (ADR-0003 amendment A decision 3).
  • :static_encryption_context is validated and bounded here, against the vocabulary and the bounds Encryptor.Context owns: at most Encryptor.Context.max_pairs/0 pairs, at most Encryptor.Context.max_bytes/0 serialized, non-empty UTF-8 strings throughout, and no reserved key (ADR-0004 decisions 1, 2 and 9). This module applies them at start; the per-call half is Encryptor.Context.compose/3.

Choosing an algorithm suite

The default 0x0578 is the engine's own default: AES-256-GCM, HKDF-SHA512, key commitment, and ECDSA P-384 signing. A wrapper should not silently weaken what the engine chose, so that is what a host gets without saying anything.

A host should configure 0x0478 - which keeps key commitment and drops the signature - when the writer and the reader are the same trust domain. The encrypted-column case encryptor_ecto serves is exactly that shape: signing exists so a reader can verify a writer it does not trust, and paying an ECDSA P-384 sign per column write, a verify per read, and the signature's bytes per row buys nothing when one application is both parties (ADR-0001 decision 9).

The profile is start-time, not compile-time

:context_profile arrives through the same five layers as everything else, and three of those layers do not exist when the vault module - or any module downstream of it - is compiled. A downstream layer that needs to know whether a vault is :single or :tenant reads it from the frozen struct at runtime, through fetch/1, and never from the use options.

case Encryptor.Vault.Config.fetch(MyApp.TenantVault) do
  {:ok, %{context_profile: :tenant}} -> :ok
  {:ok, %{context_profile: :single}} -> {:error, :vault_is_single_profile}
  {:error, error} -> {:error, error}
end

Records: ADR-0001 decisions 5, 6, 8 and 9; ADR-0004 decisions 3, 4 and 9; ADR-0005 decision 4.

Summary

Types

The resolved cache bounds, or false when the vault runs no cache.

An encryption context: a flat map of string to string.

Which shape of vault this is, and therefore which selector and required keys it takes.

t()

Functions

The package defaults - layer 1 of the precedence chain.

Removes a vault's frozen configuration.

Reads a vault's frozen configuration.

Publishes a resolved configuration under {Encryptor.Vault, vault}.

Computes the known-answer value a deployment pins as :reference_check.

Resolves the five layers and validates the result.

Refuses key material in use options, at compile time.

Types

cache()

@type cache() ::
  false
  | %{
      max_age: pos_integer(),
      max_messages: pos_integer(),
      max_bytes: pos_integer(),
      recycle_after: pos_integer()
    }

The resolved cache bounds, or false when the vault runs no cache.

context()

@type context() :: %{optional(String.t()) => String.t()}

An encryption context: a flat map of string to string.

profile()

@type profile() :: :single | :tenant

Which shape of vault this is, and therefore which selector and required keys it takes.

t()

@type t() :: %Encryptor.Vault.Config{
  algorithm_suite_id: 1400 | 1144,
  cache: cache(),
  commitment_policy:
    :require_encrypt_require_decrypt | :require_encrypt_allow_decrypt,
  context_profile: profile(),
  derivation_salt: binary() | nil,
  max_encrypted_data_keys: pos_integer(),
  otp_app: atom(),
  provider: {module(), term()},
  provider_state: term(),
  reference_check: String.t() | nil,
  reference_subkey: binary() | nil,
  required_context: [String.t()],
  required_keys: [String.t()],
  static_encryption_context: context(),
  vault: module()
}

Functions

defaults()

@spec defaults() :: keyword()

The package defaults - layer 1 of the precedence chain.

Three keys are deliberately absent. :provider and :context_profile are required, because there is no defensible default for where key material comes from or for whether a vault is per-tenant, and a wrong guess at either changes what goes into a message. :reference_subkey is key material and arrives through init/1.

iex> Keyword.fetch(Encryptor.Vault.Config.defaults(), :commitment_policy)
{:ok, :require_encrypt_require_decrypt}

iex> Keyword.fetch(Encryptor.Vault.Config.defaults(), :context_profile)
:error

erase(vault)

@spec erase(module()) :: :ok

Removes a vault's frozen configuration.

Called when a vault stops. :persistent_term.erase/1 triggers a global scan, which is why this happens on a vault's lifecycle boundary and never on a call path.

fetch(vault)

@spec fetch(module()) :: {:ok, t()} | {:error, Encryptor.Error.t()}

Reads a vault's frozen configuration.

A vault that has not been started has no entry, and that is a typed error rather than a raise: {:vault_not_started, vault} is the check ADR-0001 decision 2 requires at every entry point.

iex> Encryptor.Vault.Config.fetch(MyApp.UnstartedVault)
{:error,
 %Encryptor.Error{
   reason: {:vault_not_started, MyApp.UnstartedVault},
   vault: MyApp.UnstartedVault,
   operation: :start,
   engine: nil
 }}

freeze(config)

@spec freeze(t()) :: t()

Publishes a resolved configuration under {Encryptor.Vault, vault}.

Called once, by the vault's supervisor, at start. Returns the config so it can be threaded.

known_answer(reference_subkey)

@spec known_answer(binary()) :: String.t()

Computes the known-answer value a deployment pins as :reference_check.

The value is the reference this package derives for a fixed probe selector, by ADR-0003 decision 5's keyed derivation. An operator runs this once, against the reference subkey the deployment was provisioned with, and writes the result into the tenant vault's configuration. Every node then refuses to start unless its subkey reproduces it.

The check exists because the alternative failure is silent and fleet-wide: a node deployed with a wrong reference subkey writes messages no correct reader can open, and fails every correct message as :decrypt_failed - corruption-shaped, and discovered at decrypt time, when the reference subkey is already permanent (ADR-0004 decision 4).

The returned value is not secret. It is the same shape as a tenant_ref, which travels in the clear in every message header.

resolve(vault, otp_app, use_opts, start_opts \\ [])

@spec resolve(module(), atom(), keyword(), keyword()) ::
  {:ok, t()} | {:error, Encryptor.Error.t()}

Resolves the five layers and validates the result.

use_opts is layer 2 and start_opts is layer 4; layers 3 and 5 are read here, from Application.get_env/3 and from the vault's init/1 when it exports one.

Returns the validated struct. It is not frozen - freeze/1 is a separate step so a caller can resolve and inspect a configuration without publishing it.

validate_use_opts!(vault, opts)

@spec validate_use_opts!(
  module(),
  keyword()
) :: keyword()

Refuses key material in use options, at compile time.

Called by the use Encryptor.Vault macro while it expands, so the failure is a compilation failure rather than a start-time error: by the time a vault starts, the secret is already in the .beam file.

It raises ArgumentError rather than returning an Encryptor.Error, because this is a misuse of a macro rather than an operation on a vault, and because Encryptor.Error.message/1 deliberately never renders the detail of an {:invalid_config, key, detail} - the one thing an operator needs to see here is which option was refused.

Returns the options unchanged when they are clean, so the macro can thread it.