PhoenixKit.Integrations.Encryption (phoenix_kit v2.13.7)

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

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).

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

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_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.

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.

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 raises, never touches the database — pure config/endpoint introspection, safe to call from a boot hook or a LiveView mount/3.

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.