Encryptor.Error exception (Encryptor v0.2.0)

Copy Markdown View Source

The one error struct this package returns, and the closed vocabulary of reasons it carries.

Every non-bang entry point returns {:ok, value} or {:error, %Encryptor.Error{}}, and every bang variant raises this same struct. There is no second error shape, no bare atom, and no error tuple with a different arity depending on which layer failed.

The four fields

  • :reason - this package's own stable, matchable term. The complete set is reason/0, and it is closed: it is extended by an ADR, never by a call site inventing a near-miss.
  • :vault - the vault module the operation ran against.
  • :operation - :encrypt, :decrypt, :rekey, or :start.
  • :engine - the underlying error term, unchanged, or nil.

The split between :reason and :engine is the point. Consumers match on :reason, which this package owns and versions. Operators read :engine in a log line when they need to know which keyring rejected what. Terms from the engine - or from a provider's own stack, an Ecto changeset or an ExAws tuple - are carried in :engine, never translated away and never promoted into :reason. That is what keeps reason/0 an enumeration a case can be written against.

The oracle rule

Every decrypt-side failure that depends on the message collapses to :decrypt_failed. A wrong key, a failed authentication tag, an encryption context mismatch, a required key absent from the message, and a commitment policy rejection are indistinguishable in :reason. The detail lives in :engine, for logs only.

Distinguishable decrypt failures are a decryption oracle, and the caller cannot act differently on the distinctions anyway. decrypt_failed/3 is the collapse point: the decrypt path builds its message-dependent failures through it rather than by assembling a struct by hand.

Failures that depend only on caller-supplied arguments stay distinct, because they are not an oracle and the caller needs them: an unresolvable selector, a reserved context key, a required context key the caller omitted, a provider that could not answer at all. Provider resolution is carved out of the collapse in both directions - it happens before any ciphertext is examined, and reporting an unreachable key store as data corruption sends an operator looking for the wrong thing at three in the morning.

Messages never carry key-shaped values

Exception.message/1 renders :reason only, and only the parts of it that are safe to print. It never renders :engine, and it never renders the detail of {:invalid_key_descriptor, detail} or {:invalid_config, key, detail}, either of which can hold key material. Plaintext, data keys, and wrapping key material never reach a message, a log line, or a failure report.

Records: ADR-0001 decision 10, ADR-0002 decision 6, ADR-0004 decision 8.

Summary

Types

The vault operation an error arose from.

The complete reason vocabulary.

A key selector, as ADR-0004 decision 3 fixes it: a non-empty String.t() in a :tenant vault, and the atom :default in a :single one.

t()

Functions

Builds the collapsed failure for a decrypt-side condition that depends on the message.

Renders an error for a human.

Types

operation()

@type operation() :: :encrypt | :decrypt | :rekey | :start | :derive

The vault operation an error arose from.

:derive is ADR-0003 amendment A's addition. A derivation is none of the other four, and reporting it as one of them would make an operator's error line name a call the caller never made.

reason()

@type reason() ::
  :decrypt_failed
  | {:vault_not_started, module()}
  | {:missing_config, [atom()]}
  | {:invalid_config, atom(), term()}
  | {:unknown_key, selector()}
  | {:encryption_context_conflict, String.t()}
  | {:reserved_context_key, String.t()}
  | {:key_unavailable, selector()}
  | {:invalid_key_descriptor, term()}
  | {:provider_not_started, module()}
  | {:missing_optional_dependency, atom()}
  | {:missing_required_context_keys, [String.t()]}
  | {:invalid_context_value, String.t() | :count | :too_large}
  | {:invalid_selector, term()}

The complete reason vocabulary.

Assembled from the accepted records: ADR-0001 decision 10 fixes the first seven, ADR-0002 decision 6 adds four for key resolution, and ADR-0004 decision 8 adds three for the encryption context. ADR-0005 adds none - a rotation misconfiguration is an {:invalid_config, key, detail}.

selector()

@type selector() :: String.t() | :default

A key selector, as ADR-0004 decision 3 fixes it: a non-empty String.t() in a :tenant vault, and the atom :default in a :single one.

t()

@type t() :: %Encryptor.Error{
  __exception__: true,
  engine: term() | nil,
  operation: operation() | nil,
  reason: reason(),
  vault: module() | nil
}

Functions

decrypt_failed(vault, operation, engine)

@spec decrypt_failed(module(), operation(), term()) :: t()

Builds the collapsed failure for a decrypt-side condition that depends on the message.

The engine's term - or the vault's own term, when a check ran above the engine - is carried in :engine unchanged. It is never inspected to pick a reason, and it never reaches the rendered message.

iex> error = Encryptor.Error.decrypt_failed(MyApp.Vault, :decrypt, {:encryption_context_mismatch, "column"})
iex> error.reason
:decrypt_failed
iex> error.engine
{:encryption_context_mismatch, "column"}

A rekey/2 failure on its decrypt half carries :rekey, because the operation is what the caller asked for:

iex> Encryptor.Error.decrypt_failed(MyApp.Vault, :rekey, :key_mismatch).operation
:rekey

message(error)

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

Renders an error for a human.

The rendered string carries the reason and where it happened, never the :engine term and never a value that could be key-shaped.

iex> Encryptor.Error.message(%Encryptor.Error{reason: :decrypt_failed, vault: MyApp.Vault, operation: :decrypt})
"decryption failed (MyApp.Vault, decrypt)"

iex> Encryptor.Error.message(%Encryptor.Error{reason: {:missing_optional_dependency, :ecto}})
"missing optional dependency :ecto"