PhoenixKit.Integrations.KeyStore behaviour (phoenix_kit v2.13.18)

Copy Markdown View Source

Where the integrations encryption secret is kept, as an extension point.

mix phoenix_kit.integrations.rotate_key used to print a freshly generated secret exactly once and save it nowhere. An operator who lost that line lost every stored integration credential with it — the ciphertext stays in the database and nothing can read it again. This behaviour exists so the secret has somewhere to land.

The default is deliberately boring

PhoenixKit.Integrations.KeyStore.File writes one file, mode 0600, outside the repository. No external service, no account, no credentials of its own — it works for any PhoenixKit user on any host, which a cloud secrets manager does not. Hosts that want Vault, AWS Secrets Manager or anything else implement this behaviour and configure their module instead; nothing here assumes the default.

Configuring

config :phoenix_kit, integrations_key_store: PhoenixKit.Integrations.KeyStore.File

# or, with options
config :phoenix_kit,
  integrations_key_store: {PhoenixKit.Integrations.KeyStore.File, path: "/etc/phoenix_kit/app.key"}

Unconfigured is a supported state, not a broken one: rotation keeps its old print-once behaviour and says plainly that the secret was saved nowhere.

Reading

PhoenixKit.Integrations.Encryption consults the store when no explicit :integrations_encryption_key is configured, so a rotation followed by a restart needs no config edit. The read is memoised in :persistent_term — encryption runs per credential and must not hit the filesystem each time. Rotation invalidates that cache, and so can invalidate_cache/0.

Secrets never travel in error terms

Every callback returns the path or a reason, never the secret. A secret in an error tuple ends up in a log, a crash report or a support ticket, and a secret that reaches a log is compromised in the only sense that matters.

Summary

Types

A store implementation plus the options it was configured with.

Callbacks

Human-readable location, for operator-facing messages. Never the secret.

Checks the store can be written to, without writing the real secret.

Reads the stored secret.

Persists secret, replacing whatever was there.

Functions

Memoised read/0, for the encryption hot path.

The configured store as {module, opts}, or nil.

Whether a store is configured at all.

Where the configured store keeps the secret, for messages. Never the secret.

A short description of a store error, safe to put in a log or a message.

Drops the memoised secret. Safe to call when nothing is cached.

Calls a store callback without letting a secret escape into an exception.

Runs the configured store's pre-flight check.

Reads the secret from the configured store.

Writes the secret, then reads it back and compares before reporting success.

Types

configured()

@type configured() :: {module(), keyword()}

A store implementation plus the options it was configured with.

Callbacks

describe(opts)

@callback describe(opts :: keyword()) :: String.t()

Human-readable location, for operator-facing messages. Never the secret.

preflight(opts)

@callback preflight(opts :: keyword()) :: :ok | {:error, term()}

Checks the store can be written to, without writing the real secret.

Called before a rotation re-encrypts anything. Rotation is the dangerous moment: once rows are re-encrypted, a store that then refuses the write leaves the operator holding a database no key opens. Finding out first turns that into an abort that changed nothing.

read(opts)

@callback read(opts :: keyword()) ::
  {:ok, String.t()} | :not_configured | {:error, term()}

Reads the stored secret.

:not_configured means the store has nothing yet (a first run), which is different from {:error, reason} — an existing secret that could not be read. Callers must not collapse the two: "no key yet" invites writing one, "could not read" must never lead to overwriting one.

write(secret, opts)

@callback write(secret :: String.t(), opts :: keyword()) :: :ok | {:error, term()}

Persists secret, replacing whatever was there.

Implementations must not log the secret, and must not leave a partially written file behind on failure.

Functions

cached_read()

@spec cached_read() :: {:ok, String.t()} | :not_configured | {:error, term()}

Memoised read/0, for the encryption hot path.

Encryption runs once per credential field; an unmemoised read would stat and open a file on every one. The cache is invalidated by invalidate_cache/0, which rotation calls after storing a new secret.

configured()

@spec configured() :: configured() | nil

The configured store as {module, opts}, or nil.

Accepts a bare module or a {module, opts} tuple.

configured?()

@spec configured?() :: boolean()

Whether a store is configured at all.

describe()

@spec describe() :: String.t() | nil

Where the configured store keeps the secret, for messages. Never the secret.

describe_error(reason)

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

A short description of a store error, safe to put in a log or a message.

Deliberately NOT inspect/1 of the whole term. The reasons produced here are known to be safe, but a host-supplied store returns whatever it likes from read/1 and write/2 — including, plausibly, an error that quotes the value it failed to store. Only the recognised shapes are formatted; anything else is reduced to its outermost tag, so an unknown term cannot carry a secret into a log by accident.

invalidate_cache()

@spec invalidate_cache() :: :ok

Drops the memoised secret. Safe to call when nothing is cached.

invoke_store(module, fun, args)

@spec invoke_store(module(), atom(), [term()]) :: term()

Calls a store callback without letting a secret escape into an exception.

Public because PhoenixKit.Integrations.KeyStore.Chain calls member stores and must not bypass this: apply/3 on a mistyped or unloaded module raises, and Erlang formats such reports with their arguments — which for write/2 is the secret.

preflight()

@spec preflight() :: :ok | :not_configured | {:error, term()}

Runs the configured store's pre-flight check.

:not_configured when there is no store — not an error; the caller decides whether that is acceptable.

read()

@spec read() :: {:ok, String.t()} | :not_configured | {:error, term()}

Reads the secret from the configured store.

Returns :not_configured both when no store is configured and when the configured store holds nothing yet — from a caller's point of view those are the same situation: there is no secret to use.

write_verified(secret)

@spec write_verified(String.t()) :: :ok | :not_configured | {:error, term()}

Writes the secret, then reads it back and compares before reporting success.

A write that reports :ok and did not land is the failure this whole module exists to prevent, and it is not hypothetical: a key file was lost this way on 2026-08-18 — the file looked intact and the value inside was empty. So the write is not trusted on its own word; it is verified by reading.

{:error, {:verification_failed, _}} means the data may be re-encrypted while the secret is NOT safely stored. Callers must treat that as loud.