Encryptor.Ecto.Error (Encryptor.Ecto v0.2.0)

Copy Markdown View Source

The shared shape, and the redaction rules, of the encrypted-field exception family (ADR-0001 decision 6, acceptance amendment 2).

Every exception this package raises from dump/3 and load/3 carries the same four identifying values - the declared table, the declared column, the encryption-context keys, and the upstream reason - plus the tenant identifier where one was resolved. Individual exceptions add a field or two of their own; none of them adds a value.

The prohibition this module exists to enforce

No exception message, log line, or Exception.message/1 ever includes plaintext, ciphertext bytes, or key material - including in the Inspect implementation of the exception struct.

That is ADR-0001 decision 6, and it is a decision rather than a review preference. It is enforced structurally here rather than by convention in five separate modules: __using__/1 defines the struct, message/1, and an Inspect implementation that replaces the derived one, so an exception in this family cannot be added without the override.

The mechanism is redact/1, which renders a term the upstream vault supplied. Only atoms and integers survive it verbatim; tuples and lists are rendered structurally with each element redacted in turn; a binary is reduced to its byte count and everything else to a bare marker. The rule is deliberately about the shape of a term rather than about where it came from, because a vault :reason is an opaque term this layer cannot inspect for secrets - a binary in one is as likely to be a ciphertext fragment as a message.

The declared table, the declared column and the context keys are not redacted: they are declaration-time strings, they are what makes a failure diagnosable, and the bead's rule names them as carried. The tenant identifier is likewise rendered, as the one value the prohibition exempts.

The cost, stated

Redaction by shape loses operator-facing detail that happens to be a binary. {:encryption_context_mismatch, "table"} in a DecryptError's :engine field renders its tag and the byte count of its key name, not the key name. The alternative - a heuristic that lets "short, printable" binaries through - passes a three-character plaintext, so the structural rule is the one that holds.

Where the guarantee stops

It covers every rendering this layer controls: Exception.message/1, Exception.format/3 and the stacktrace it prints, and Kernel.inspect/2.

It does not cover a caller who deliberately steps around the Inspect protocol - inspect(error, structs: false), or Map.from_struct/1 followed by anything. Those bypass every implementation in the system, Ecto's own @derive Inspect redaction included, and no implementation can defend against them. The :reason field holds the vault's term rather than a pre-rendered string precisely so that a caller can still match on it, which is what leaves the term reachable at all.

Summary

Types

The identifying values every exception in the family carries.

A rendered label: value pair, ready to join into a detail list.

Functions

Defines one member of the exception family.

The rendered detail pairs every exception in the family carries.

Renders a term supplied by the vault, keeping plaintext, ciphertext bytes and key material out of the result.

Renders detail pairs as the Inspect form of an exception struct.

Renders a headline and its detail pairs as an Exception.message/1 result.

Types

common()

@type common() :: [
  table: String.t() | nil,
  column: String.t() | nil,
  context_keys: [String.t()],
  tenant: term(),
  reason: term()
]

The identifying values every exception in the family carries.

:context_keys are encryption-context key names; the context's values are never carried. :tenant is the resolved tenant identifier, the single value ADR-0001 decision 6 exempts from the prohibition.

detail()

@type detail() :: {String.t(), String.t()}

A rendered label: value pair, ready to join into a detail list.

Functions

__using__(opts)

(macro)

Defines one member of the exception family.

Injects the common struct fields, an Exception implementation whose message/1 renders the headline and the detail list, and an Inspect implementation that replaces the derived one. :extra_fields adds struct fields beyond the common set.

The using module must define headline/0. It may define extra_detail/1, returning detail/0 pairs appended to the common ones; the default returns none.

common_detail(error)

@spec common_detail(struct()) :: [detail()]

The rendered detail pairs every exception in the family carries.

redact(term)

@spec redact(term()) :: String.t()

Renders a term supplied by the vault, keeping plaintext, ciphertext bytes and key material out of the result.

Atoms and integers render as themselves. Tuples and lists render structurally, with every element redacted in turn. A binary renders as its byte count, and any other term as a bare marker.

iex> Encryptor.Ecto.Error.redact(:decrypt_failed)
":decrypt_failed"

iex> Encryptor.Ecto.Error.redact({:missing_required_context_keys, [:table]})
"{:missing_required_context_keys, [:table]}"

iex> Encryptor.Ecto.Error.redact("4111111111111111")
"<<redacted 16 bytes>>"

iex> Encryptor.Ecto.Error.redact(%{card_number: "4111111111111111"})
"<redacted>"

render_inspect(module, details)

@spec render_inspect(module(), [detail()]) :: String.t()

Renders detail pairs as the Inspect form of an exception struct.

Deliberately not the derived form: the derived one prints every field with Inspect defaults, which is precisely what ADR-0001 decision 6 forbids.

render_message(headline, details)

@spec render_message(String.t(), [detail()]) :: String.t()

Renders a headline and its detail pairs as an Exception.message/1 result.