# `PhoenixKit.Integrations.Encryption`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.14.2/lib/phoenix_kit/integrations/encryption.ex#L1)

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 key** — `config :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 {: .warning}
> 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.

# `key_diagnosis`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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_explicit` — `integration_encryption_enabled: false`. New
    writes store plaintext.

# `decrypt_fields`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

```elixir
@spec key_diagnosis() :: key_diagnosis()
```

The active tier and the reason for it. See `t: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`

```elixir
@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`

```elixir
@spec key_report() :: key_report()
```

The complete report for the current state.

# `key_report`

```elixir
@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`

```elixir
@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`

```elixir
@spec key_signals() :: key_signals()
```

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

# `min_dedicated_key_length`

```elixir
@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`

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

Returns the list of field keys that are encrypted.

# `status`

```elixir
@spec status() :: key_status()
```

Reports which secret currently backs the encryption key. See `t: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`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
