7.0 KiB
At-rest credential encryption
Silo encrypts third-party integration credentials and other server-owned secrets
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
Audiobookshelf-compat signing key, and the sensitive server_settings entries
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
protects them lives outside the database, so a full DB dump or compromise
does not expose the secrets.
How it works
- Cipher: AES-256-GCM with a random 12-byte nonce per value. Implemented in
internal/secret. - Key derivation: the data key is HKDF-SHA256–derived from the
SECRET_KEYenvironment variable with a versioned domain label.SECRET_KEYis the encryption root; it is never stored in the database. - Envelope: every ciphertext is stored as
enc:v1:<base64url(nonce‖sealed)>. Theenc:v1:prefix is the version marker (see Key rotation below). - Row binding (AAD): each ciphertext is GCM-bound to its logical row via
additional-authenticated data —
table:column:<pk>for per-row columns,server_settings:<key>for settings. A DB-write attacker therefore cannot move a credential blob from one row/column to another: decrypting under a different binding fails authentication. - Read path: an empty value stays empty; a non-
enc:v1:value is treated as legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps working); anenc:v1:value is decrypted and any failure (wrong key, tampering, truncation) is surfaced as an error — it is never silently used as a credential.
SECRET_KEY
SECRET_KEY is required. The server refuses to start without it (it fatals
in config.LoadBootstrap), and it must be at least 32 characters.
Generate one with:
openssl rand -base64 48
Set it in the deployment environment (or in .env for source/dev runs, which
godotenv loads at startup). See .env.example.
Back it up — separately from the database
Treat SECRET_KEY like a CA private key:
- Store it outside your database backups. A DB dump contains only ciphertext; the value of at-rest encryption is entirely lost if the key travels with the dump.
- Keep it in a secrets manager / sealed secret / password manager, not in the repo or in the same bucket as your Postgres backups.
- Rotating the deployment (new host, restored DB) requires the same
SECRET_KEY. Restoring a database backup onto a node with a differentSECRET_KEYmakes every encrypted secret unreadable.
Key loss
If SECRET_KEY is lost, the encrypted secrets are unrecoverable — that is the
point of keeping the key out of the database. Recovery means re-entering the
affected credentials:
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections, history-import tokens, subtitle provider credentials, and plugin runtime configuration.
auth.jwt_secretbecomes unreadable, so all existing sessions are invalid and users must log in again (a new secret is generated on next boot if the row is cleared — see below).
Rollout (automatic backfill)
On the first boot after deploying this change, the primary (migration-running) node runs an idempotent, best-effort startup backfill that encrypts any remaining plaintext in place:
- sensitive
server_settingskeys, - the per-table credential columns (subtitles, watch-sync, webhook-sync, history-import, including temporary server-list credentials stored in session JSON),
- the two arr
api_key_refcolumns — these are resolved-then-encrypted: a legacy row that held aserver_settingsreference (e.g.requests.radarr.api_key) is collapsed to the real credential before being encrypted, - whole
plugin_runtime_configs.config_valueobjects. Plugin config is intentionally opaque to the host storage layer: plugins may write undeclared keys and manifest annotations may be unavailable or change over time, so manifest-selected field encryption cannot fail closed.
The backfill is safe to run repeatedly: already-encrypted values are skipped, and a per-row guard makes concurrent multi-node boots converge without double-encrypting. A failed row is logged and left as plaintext (no new exposure) rather than blocking boot. Proxy/transcode nodes skip the backfill; they read whatever the primary encrypted. No manual steps are required.
Rollback / downgrade hazard
Downgrading to a binary that predates this change is not safe while secrets
are encrypted, because the old binary has no read path: it would read
enc:v1:auth.jwt_secret as a literal JWT secret (invalidating all sessions) and
read enc:v1:-prefixed integration keys as garbage credentials. It would also
pass the reserved plugin-config envelope object to plugins instead of their
configuration.
To downgrade safely you must first return the affected values to plaintext, for example:
- Decrypt the sensitive
server_settingsback to plaintext (run a one-off that reads each via the encrypting repo and writes the plaintext via the raw repo), or - At minimum, clear
auth.jwt_secretso the older binary regenerates a fresh plaintext one (this still logs everyone out), and re-enter any integration credentials as plaintext.
There is no automatic "decrypt everything" downgrade path in this release; plan downgrades accordingly.
Key rotation (future)
The enc:v1: envelope and the HKDF domain label are versioned. A future rotation
would introduce enc:v2: with a new derived key (and Decrypt already dispatches
on the version, returning an explicit error for an unknown version). Rotation is
out of scope for this release; the versioning exists so it can be added without a
data migration of the envelope format.
Scope and known gaps
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
server_settings, watch-sync tokens, webhook-sync access_token,
history-import admin/session tokens and temporary server-list credentials,
subtitle provider api_key/password,
the Jellyfin-compat session's bridged Silo access/refresh tokens
(jellycompat_sessions.streamapp_access_token / streamapp_refresh_token), and
the ABS signing key. Plugin runtime configuration is encrypted as one opaque
row-bound envelope rather than by manifest field. Consequently, runtime code
must use RuntimeConfigStore; database JSON-member queries and indexes are not
supported for plugin_runtime_configs.config_value.
Deliberately not encrypted (tracked as follow-ups):
- Equality-looked-up secrets — these are matched by exact value
(
WHERE … = $1), and AES-GCM is randomized, so encrypting them would break the lookup. They need a deterministic blind-index hash column instead:api_keys.api_key,webhook_sync_connections.webhook_secret,jellycompat_sessions.token, andwatch_together_rooms.join_token. Excluded (not a gap):plex_sync_connections.*is a dead table (zero Go references);oauth_completion.token_ciphertextis already AES-GCM;users.password_hashand the*_hashcolumns are already hashed.