PhoenixKit.Integrations.Encryption (phoenix_kit v2.17.0)

Copy Markdown View Source

AES-256-GCM encryption for sensitive integration credentials.

Encrypts fields like access_token, refresh_token, client_secret, api_key, bot_token, secret_key, password before storing in the database. Decrypts them when reading.

Key resolution

The AES key is derived (SHA-256) from a secret, tried in this order:

  1. Dedicated keyconfig :phoenix_kit, :integrations_encryption_key. The recommended setup: a random secret independent of anything else in the app, generated with mix phoenix_kit.integrations.rotate_key and wired from an environment variable in runtime.exs, e.g.

    config :phoenix_kit,
      integrations_encryption_key: System.get_env("PHOENIX_KIT_INTEGRATIONS_ENCRYPTION_KEY")
  2. Legacy fallback — the application's secret_key_base (flat config :phoenix_kit, :secret_key_base, or the host app's own Endpoint secret). This is what every install used before the dedicated key existed, and it stays supported for backwards compatibility — but it means anyone who can read secret_key_base (env, config file, git history) can decrypt every stored integration credential, since that secret is shared with session signing, CSRF tokens, and everything else Phoenix derives from it. status/0 reports this tier as :legacy_secret_key_base and PhoenixKit.Supervisor logs a boot warning about it — see warn_if_insecure/0.

Set config :phoenix_kit, integration_encryption_enabled: false to turn encryption off entirely (new and existing writes store plaintext). This is reported as :disabled_explicit by status/0 and is also warned about at boot — the setting takes effect silently, but its EFFECT is never silent.

Key rotation

Changing which secret produces the key — setting a dedicated key for the first time, or rotating an existing one — makes every existing enc:v1: value undecryptable under the new key. There is no dual-key fallback at read time (that would silently mask a misconfigured key with plaintext read failures, the opposite of the point). Use PhoenixKit.Integrations.KeyRotation.rotate/2 (or mix phoenix_kit.integrations.rotate_key) to re-encrypt every stored connection under the new secret BEFORE switching the app's config over to it.

A field that's temporarily undecryptable (mismatched key, mid-rotation) is never permanently lost by an unrelated write in the meantime — see decrypt_fields_with_failures/1 and PhoenixKit.Integrations.save_setup/4's write path. The stored ciphertext survives untouched until the correct key is active again.

Summary

Types

The active tier plus why it is that tier.

Everything a surface needs to say about the key state, decided together.

The raw signals the key state is made of, gathered in one pass.

Which secret currently backs the encryption key, from most to least secure

Functions

Decrypt sensitive fields in an integration data map after reading.

Same as decrypt_fields/1, but also returns the names of any fields that looked encrypted (carried the enc:v1: prefix) yet failed to decrypt under the currently active key.

Decrypt a value produced by encrypt_value/1.

Check if encryption is available and enabled.

Encrypt sensitive fields in an integration data map before saving.

Encrypts sensitive fields using an EXPLICIT secret, bypassing the configured-key resolution entirely — the rotation primitive.

Encrypt a single value, for callers with a bare field to protect rather than a full encrypt_fields/1-shaped map (e.g. an Ecto schema field like PhoenixKit.Modules.Storage.Bucket.secret_access_key).

Encrypts a single value using an EXPLICIT secret — the single-value counterpart to encrypt_fields_with_secret/2, for the same rotation use case (PhoenixKit.Integrations.KeyRotation re-encrypting PhoenixKit.Settings' restricted setting values, which are stored as a bare value string rather than a value_json map of sensitive fields).

Whether value looks like an already-encrypted field value (carries the current enc:v1: prefix).

The active tier and the reason for it. See key_diagnosis/0.

A short, non-reversible fingerprint of the key currently in use, or :none.

The complete report for the current state.

The verdict for a set of signals — total over the signal space.

The report rendered as one sentence, for a log line.

Reads every signal the key verdict depends on, in one pass.

Shortest secret accepted as a dedicated key.

Returns the list of field keys that are encrypted.

Reports which secret currently backs the encryption key. See key_status/0.

Logs a one-time warning when integration credentials are not protected by a dedicated key — called once at boot by PhoenixKit.boot/1. Deliberately silent (no log line) for the healthy :dedicated case; the common, correctly-configured install must produce zero noise here.

Types

key_diagnosis()

@type key_diagnosis() ::
  {:dedicated, :ok | :store_unreadable | :store_shadowed}
  | {:legacy_secret_key_base,
     :store_unreadable | :store_shadowed | :key_too_short | :no_dedicated_key}
  | {:disabled_no_key,
     :store_unreadable | :store_shadowed | :key_too_short | :no_key_material}
  | {:disabled_explicit, :turned_off}

The active tier plus why it is that tier.

Exists because two places give advice about the same situation — warn_if_insecure/0 and mix phoenix_kit.doctor — and advice that is correct for one reason is actively harmful for another. Telling an operator whose key store is unreadable to run mix phoenix_kit.integrations.rotate_key would rotate away from a key their data may still be encrypted under. Telling someone who did configure a key that none is configured is simply false.

Both callers branch on this one value, so they cannot drift apart.

key_report()

@type key_report() :: %{
  diagnosis: key_diagnosis(),
  severity: :ok | :warn | :fail,
  summary: String.t(),
  consequence: String.t(),
  action: String.t(),
  rotation_safe?: boolean(),
  rejected_key: false | :config | :store,
  fingerprint: :none | {:ok, String.t(), String.t()},
  key_store:
    nil | {:no_secret_yet | :unreadable | :shadowed | :holding, String.t()}
}

Everything a surface needs to say about the key state, decided together.

One clause of key_report/1 produces a whole one of these. There is no path by which its parts can disagree: the fingerprint and the tier that produced it are a single term, and :key_store is nil unless a store is actually configured.

key_signals()

@type key_signals() :: %{
  enabled?: boolean(),
  tier: :dedicated | :legacy | :none,
  rejected_key: false | :config | :store,
  store:
    :absent
    | {:no_secret_yet, String.t()}
    | {:unreadable, String.t()}
    | {:shadowed, String.t()}
    | {:holding, String.t()},
  fingerprint: :none | {:ok, String.t()}
}

The raw signals the key state is made of, gathered in one pass.

Deliberately separate from the verdict below. Everything that decides what an operator should be told is read here, once, and then the verdict is a function of this map alone — it consults nothing further. Three rounds of fixes each produced a message contradicting itself because a later step went back to the environment for one more fact and got a different answer than the step before.

key_status()

@type key_status() ::
  :dedicated | :legacy_secret_key_base | :disabled_no_key | :disabled_explicit

Which secret currently backs the encryption key, from most to least secure:

  • :dedicated — a dedicated :integrations_encryption_key is set.
  • :legacy_secret_key_base — no dedicated key; falling back to a key derived from secret_key_base. Functional, but shares its secret with the rest of the app.
  • :disabled_no_key — encryption is enabled but no key material at all resolves (neither a dedicated key nor a usable secret_key_base). New writes store plaintext.
  • :disabled_explicitintegration_encryption_enabled: false. New writes store plaintext.

Functions

decrypt_fields(data)

@spec decrypt_fields(map()) :: map()

Decrypt sensitive fields in an integration data map after reading.

Only values with the enc:v1: prefix are decrypted. Non-encrypted values are returned as-is for backwards compatibility.

decrypt_fields_with_failures(data)

@spec decrypt_fields_with_failures(map()) :: {map(), [String.t()]}

Same as decrypt_fields/1, but also returns the names of any fields that looked encrypted (carried the enc:v1: prefix) yet failed to decrypt under the currently active key.

decrypt_fields/1 drops an undecryptable field entirely — correct for every caller that treats the result as a live credential to use or display, since a caller must never mistake stale ciphertext for a real value. But PhoenixKit.Integrations.resolve_uuid/2 also hands this same map to write paths that merge new attributes onto it and save the result wholesale: for THOSE callers, "absent because it failed to decrypt" and "absent because nothing was ever there" are not the same thing — the first must not be permanently erased by an unrelated write. The returned field-name list lets a write path restore an untouched field's original ciphertext from storage (see PhoenixKit.Integrations.save_setup/4, refresh_access_token/1, exchange_code/4, record_validation/3) without ever exposing that ciphertext as if it were usable.

decrypt_value(value)

@spec decrypt_value(String.t() | nil) :: {:ok, String.t() | nil} | {:error, term()}

Decrypt a value produced by encrypt_value/1.

Returns {:ok, plaintext}. A value without the enc:v1: prefix is returned as {:ok, value} unchanged — backwards compatibility with data written before encryption was applied. {:error, :encryption_unavailable} when the value IS prefixed but no encryption key is available (no secret_key_base) — unlike the nil-key path in encrypt_value/1, there is no plaintext to fall back to here, only ciphertext nobody can read right now. {:error, reason} for any other decrypt failure (wrong/rotated key, corrupted ciphertext).

enabled?()

@spec enabled?() :: boolean()

Check if encryption is available and enabled.

True for both the :dedicated and :legacy_secret_key_base tiers — this answers "will values be encrypted at all", not "how well". Use status/0 to distinguish the two.

encrypt_fields(data)

@spec encrypt_fields(map()) :: map()

Encrypt sensitive fields in an integration data map before saving.

Non-sensitive fields and nil/empty values are left unchanged. Already-encrypted values (with enc:v1: prefix) are not re-encrypted.

encrypt_fields_with_secret(data, secret)

@spec encrypt_fields_with_secret(map(), String.t()) :: map()

Encrypts sensitive fields using an EXPLICIT secret, bypassing the configured-key resolution entirely — the rotation primitive.

secret is derived the same way a configured key would be (derive_key/1); this does not read :integrations_encryption_key or secret_key_base. Used by PhoenixKit.Integrations.KeyRotation to write values under a NEW secret before that secret becomes the active configured key — which is the whole point of rotation: the new key must be usable to encrypt before it's the one encryption_key/0 resolves to.

encrypt_value(value)

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

Encrypt a single value, for callers with a bare field to protect rather than a full encrypt_fields/1-shaped map (e.g. an Ecto schema field like PhoenixKit.Modules.Storage.Bucket.secret_access_key).

Same cipher, key derivation and enc:v1: prefix as encrypt_fields/1. Nil/empty and already-encrypted values (see encrypted?/1) pass through unchanged. When encryption is unavailable (no secret_key_base), the value is stored as plaintext — same as encrypt_fields/1 — but this path logs a warning, since a caller that reaches for single-value encryption is usually protecting something as sensitive as the fields encrypt_fields/1 already covers, and a schema field silently staying plaintext is exactly the gap this API exists to close.

encrypt_value_with_secret(value, secret)

@spec encrypt_value_with_secret(String.t() | nil, String.t()) :: String.t() | nil

Encrypts a single value using an EXPLICIT secret — the single-value counterpart to encrypt_fields_with_secret/2, for the same rotation use case (PhoenixKit.Integrations.KeyRotation re-encrypting PhoenixKit.Settings' restricted setting values, which are stored as a bare value string rather than a value_json map of sensitive fields).

Nil/empty pass through unchanged, same as encrypt_value/1. Unlike encrypt_value/1, does NOT skip already-encrypted input — rotation always calls this with a value it just decrypted under the OLD key, so by the time it reaches here it is plaintext, never still enc:v1:-prefixed.

encrypted?(value)

@spec encrypted?(term()) :: boolean()

Whether value looks like an already-encrypted field value (carries the current enc:v1: prefix).

Used to keep encrypt_value/1 idempotent (never double-encrypt) and to let callers tell an already-migrated field apart from legacy plaintext. Also public so callers outside this module — PhoenixKit.Integrations.KeyRotation detecting which fields were encrypted before a rotation — don't hardcode the prefix literal themselves. A future enc:v2: format only needs to update this one place.

key_diagnosis()

@spec key_diagnosis() :: key_diagnosis()

The active tier and the reason for it. See key_diagnosis/0.

The reasons are ordered by what must be acted on first: an unreadable key store outranks "no dedicated key", because repairing the store may restore the very key the data is encrypted under, while rotating would abandon it.

key_fingerprint()

@spec key_fingerprint() :: {:ok, String.t()} | :none

A short, non-reversible fingerprint of the key currently in use, or :none.

Exists so that key reuse between sites is visible. derive_key/1 is a plain SHA-256 over the secret, so two installs that share a secret_key_base — copied from a template, cloned from a sibling environment, inherited with a config/dev.exs — derive a byte-identical integration key and neither has any way to notice. One compromise then exposes every site that shares it.

Two installs showing the same fingerprint are using the same key.

Comparing two numbers only means something like-for-like

Three things make one site show a fingerprint that is not "its" key, and none of them are visible in the number itself, so always read the tier printed next to it:

  • an unreadable key store, so the fallback key is being fingerprinted;
  • a dedicated key rejected as too short, same effect;
  • encryption disabled, in which case there is no fingerprint at all.

There is also an environment trap. mix phoenix_kit.doctor resolves the key in the task's environment; a key delivered by an env var read in runtime.exs may differ from what the running server holds. A task and an admin page on the same site can therefore disagree. Compare admin page with admin page, or task with task.

What it gives away

Domain-separated from derive_key/1 and truncated, so it is not the key and cannot be turned back into one. It IS a verifier: anyone holding it can test candidate secrets offline.

There is no salt, and there cannot be one — a per-install salt would make two installs with the same key show different numbers, destroying the only thing this is for. What can be raised is the cost per guess, so the digest is iterated (PBKDF2-HMAC-SHA256, 100000 iterations) instead of the two plain hashes it used to be. The prefix is global to PhoenixKit, so a table over common secret_key_base values works against every install at once; iteration makes building that table that many times more expensive, and nothing more. The fingerprint is no stronger than the secret behind it — against a weak, guessable secret it is a verification oracle, which is exactly the situation this feature exists to surface.

Accordingly it is shown on the admin-only system page, and by mix phoenix_kit.doctor only when explicitly asked for: a task's output ends up in CI logs, whose readership is wider than the page's.

key_report()

@spec key_report() :: key_report()

The complete report for the current state.

key_report(signals)

@spec key_report(key_signals()) :: key_report()

The verdict for a set of signals — total over the signal space.

Public because the acceptance criterion here is an enumeration: every combination rendered whole and read for self-contradiction. That needs a seam that takes the state rather than discovering it.

Ordered by the tier first, and deliberately

Three rounds ordered the clauses by fault — unreadable store, then short key, then tier — and each round found a state where the fault clause fired over a tier that was working, and announced a fallback that was not happening. The fix each time was to move one clause; the next round found the next one.

The tier is the ground truth: it is which key is protecting the data right now. A fault is a modifier on that, never a replacement for it. So the tier is matched first and the fault second, which has two consequences worth stating:

  • no combination of signals can produce a report claiming a fallback while the signals say a dedicated key is in use — not because that combination is argued to be unreachable, but because no clause can say it;
  • the verdict is total. Every point in the signal space renders, and the test walks all of them rather than filtering by a reachability rule. Reachability rules were themselves the defect twice: they were reasoned out by whoever reasoned out the code, and they excluded states the code could actually reach.

Within a tier, the faults are ordered by which one an operator must clear first. For the weaker tiers a rejected key outranks an unreadable store, because dedicated_candidate/2 never consults the store while config answers — so repairing the store cannot change anything until the short key is gone.

key_report_message(report)

@spec key_report_message(key_report()) :: String.t()

The report rendered as one sentence, for a log line.

Never includes the fingerprint: a log's readership is wider than the admin page's, and the fingerprint is a verifier against candidate secrets.

key_signals()

@spec key_signals() :: key_signals()

Reads every signal the key verdict depends on, in one pass.

min_dedicated_key_length()

@spec min_dedicated_key_length() :: pos_integer()

Shortest secret accepted as a dedicated key.

Public so mix phoenix_kit.integrations.rotate_key can refuse a --new-key this module would later reject: without the check the rotation "succeeds", the data is re-encrypted, and the app silently drops to a weaker tier on the next restart.

sensitive_fields()

@spec sensitive_fields() :: [String.t()]

Returns the list of field keys that are encrypted.

status()

@spec status() :: key_status()

Reports which secret currently backs the encryption key. See key_status/0.

Never touches the database, and safe to call from a boot hook or a LiveView mount/3. It is no longer pure config introspection: when a key store is configured it may read from it (memoised after the first success). A host-supplied store cannot crash this call — PhoenixKit.Integrations.KeyStore turns a raising store into an error tuple.

warn_if_insecure()

@spec warn_if_insecure() :: :ok

Logs a one-time warning when integration credentials are not protected by a dedicated key — called once at boot by PhoenixKit.boot/1. Deliberately silent (no log line) for the healthy :dedicated case; the common, correctly-configured install must produce zero noise here.

An :integrations_encryption_key shorter than the minimum length gets its OWN message rather than being folded into the "no dedicated key" wording below — an operator who set one, just too short, needs different advice than one who never set it, and telling them "no dedicated key is configured" when they configured one is simply false.

Never raises — returns :ok unconditionally.