- geolite2: quote the real commented compose mount (./geolite2 to /opt/geolite2, the GEOLITE2_DIR default, so no env var is needed for the default path) - getting-started: healthcheck probes /api/health, not /api/version; the environment module reaches archive-api and geocoding-api .open-meteo.com, relevant for egress allowlists - mcp: the module is opt-in and off by default, but no longer the only such module - reverse-proxy: the Coach stream is SSE over plain HTTP, not WebSockets; note that TRUST_PROXY_HOPS is not on the bundled compose whitelist and must be added by hand - scaling: /api/health healthcheck; worker liveness is in-memory and invisible across a web/worker split; pool sizing now goes through getConnectionBudget with DB_CONNECTION_LIMIT as the compose knob and a 2-connection pg-boss share - sso: the native SSO handoff shipped, OIDC_ONLY works with app builds that carry it - unraid: geo databases are baked in only when the build had a licence key, or mounted; replaceable either way
5.0 KiB
Encryption-key rotation
HealthLog encrypts sensitive at-rest data (Withings tokens, AI provider keys, notification channel configs, web-push subscription secrets, VAPID private keys, etc.) with AES-256-GCM under a per-deployment key. v1.4 introduces a versioned key format so the key can be rotated without downtime and without re-encrypting every row by hand.
Format
| Layout | Marker | Where |
|---|---|---|
| Versioned (new) | <keyId>.<base64(iv || tag || ciphertext)> |
All new writes |
| Legacy (v1.3.x) | <base64(iv || tag || ciphertext)> |
Existing rows until rotated |
<keyId> matches [A-Za-z0-9_-]{1,32} and indexes into the ENCRYPTION_KEYS
JSON map. Decryption tries the versioned format first; if no . is present
or the prefix isn't a known id, the row is treated as legacy and decrypted
under v1 (the synthetic id assigned to the existing ENCRYPTION_KEY).
Why a separate
v1is required. Legacy ciphertexts have no key id, so the only way to identify them is "no.in the value". If you remove thev1entry fromENCRYPTION_KEYSbefore every legacy row has been rotated, those rows can't be decrypted any more — the active key won't match the original ciphertext. The decrypt path now refuses to silently fall back to the active key in that scenario; it throws a clear error.
Rotating from v1.3.x to a new key
- Generate the new key on a machine with
openssl:openssl rand -hex 32 - Update environment variables. Keep the existing
ENCRYPTION_KEYin place — it's still needed to decrypt legacy rows during the transition:Restart the app. New writes are now encrypted underENCRYPTION_KEY="<old key, unchanged>" ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}' ENCRYPTION_ACTIVE_KEY_ID="v2"v2; existingv1-keyed and legacy bare rows still decrypt because thev1entry is retained. - Run the rotation script to re-encrypt every existing encrypted column
under the new active key:
The script is idempotent — running it again is a no-op for rows already prefixed with
pnpm dlx tsx scripts/rotate-encryption-key.ts v2v2.. It rotates every registered encrypted column across all models, plus the two non-*Encryptedmembers:IntegrationStatus.lastErrorandCoachMessage.encryptedContent(Bytes). The canonical registry lives insrc/lib/crypto/encrypted-columns.ts; a guard test fails CI if any*Encryptedcolumn in the schema is missing from the registry or not referenced by the script. The summary line at the end showsscanned,rotated, anderrorsper table+field; treaterrors > 0as a hard failure and re-run after fixing the cause. - Drop the old key (optional, recommended). Once the script has run
cleanly:
Restart. The legacy single-key fallback is now disconnected; only
ENCRYPTION_KEYS='{"v2":"<new key>"}' ENCRYPTION_ACTIVE_KEY_ID="v2" # ENCRYPTION_KEY can now be removedv2exists.
Adding a third key (v2 → v3)
Same procedure, just shift the labels — keep v2 in the map until the
script reports zero v2.-prefixed rows remaining.
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
ENCRYPTION_KEYS='{"v3":"<new>"}'
Rollback
Important. Once the rotation script has run, ciphertexts in the database start with
v2.(or whatever the active id is). The pre-PR v1.3.x image cannot read that prefix — it expects bare base64 — and callingdecrypt()on those rows will throw.
If you need to revert to the pre-rotation image:
- Either keep the new image. The new code reads both formats, so most rollback scenarios don't need to undo rotation.
- Or, if you must run the old code, restore a database backup taken
before the rotation script ran. There is no script to convert
v2.-prefixed rows back to legacy format — by design, rotation is a forward-only operation.
This is why we recommend running the rotation in a window where you have a fresh DB snapshot and the new image has been smoke-tested in production for at least 24 hours.
Troubleshooting
Encryption key id 'v1' is not configured— the database still containsv1.-prefixed rows butv1was removed fromENCRYPTION_KEYS. Re-add the key, run the rotation script, then remove again.Found a legacy-format ciphertext but no v1 key is configured— same cause for legacy bare-base64 rows. RestoreENCRYPTION_KEY(or add av1entry toENCRYPTION_KEYS) and run the rotation script before removing it.Refusing to rotate: argv key id ... does not match the currently active id ...— pass the same id you set inENCRYPTION_ACTIVE_KEY_ID. The guard prevents accidental re-encryption to a non-current key.