Files
HealthLog/docs/ops/encryption-key-rotation.md
T
Marc-André Bombeck fa7e1ee739 Bring the self-hosting guides in line with the shipped code
- 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
2026-08-27 12:21:27 +02:00

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 v1 is required. Legacy ciphertexts have no key id, so the only way to identify them is "no . in the value". If you remove the v1 entry from ENCRYPTION_KEYS before 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

  1. Generate the new key on a machine with openssl:
    openssl rand -hex 32
    
  2. Update environment variables. Keep the existing ENCRYPTION_KEY in place — it's still needed to decrypt legacy rows during the transition:
    ENCRYPTION_KEY="<old key, unchanged>"
    ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}'
    ENCRYPTION_ACTIVE_KEY_ID="v2"
    
    Restart the app. New writes are now encrypted under v2; existing v1-keyed and legacy bare rows still decrypt because the v1 entry is retained.
  3. Run the rotation script to re-encrypt every existing encrypted column under the new active key:
    pnpm dlx tsx scripts/rotate-encryption-key.ts v2
    
    The script is idempotent — running it again is a no-op for rows already prefixed with v2.. It rotates every registered encrypted column across all models, plus the two non-*Encrypted members: IntegrationStatus.lastError and CoachMessage.encryptedContent (Bytes). The canonical registry lives in src/lib/crypto/encrypted-columns.ts; a guard test fails CI if any *Encrypted column in the schema is missing from the registry or not referenced by the script. The summary line at the end shows scanned, rotated, and errors per table+field; treat errors > 0 as a hard failure and re-run after fixing the cause.
  4. Drop the old key (optional, recommended). Once the script has run cleanly:
    ENCRYPTION_KEYS='{"v2":"<new key>"}'
    ENCRYPTION_ACTIVE_KEY_ID="v2"
    # ENCRYPTION_KEY can now be removed
    
    Restart. The legacy single-key fallback is now disconnected; only v2 exists.

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 calling decrypt() 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 contains v1.-prefixed rows but v1 was removed from ENCRYPTION_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. Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_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 in ENCRYPTION_ACTIVE_KEY_ID. The guard prevents accidental re-encryption to a non-current key.