# Conflicts: # docs/api/openapi.yaml # src/lib/jobs/apple-health-import-worker.ts
9.5 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: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; existingv1-keyed and legacy bare rows still decrypt because thev1entry is retained. -
Re-encrypt the stored data under the new key. Open Admin → Encryption and press Rotate now. The button needs an admin account with a second factor, confirmed recently; it queues one background run (
POST /api/admin/encryption/rotate) on the app's own worker, so it works on the published image with nothing else installed. The same page shows the per-column counts and the result of the last run.Without a second factor on any admin account, run the rotation script instead. It imports the application code and the generated database client, so it runs from a source checkout of the same release, not inside the production container (the image has no package manager and does not contain
scripts/rotate-encryption-key.ts). It needs the sameENCRYPTION_KEYSandENCRYPTION_ACTIVE_KEY_IDas the app and aDATABASE_URLthat reaches the database:git checkout vX.Y.Z # the release the instance runs pnpm install && pnpm db:generate pnpm dlx tsx scripts/rotate-encryption-key.ts v2The bundled
dbservice publishes no port. On a stock compose stack, either publish5432for the duration, or run the checkout in a throwawaynode:22container attached to the compose network.Both paths are idempotent: running again is a no-op for rows already prefixed with
v2.. Both rotate every column in the canonical registry (src/lib/crypto/encrypted-columns.ts), which covers the*Encryptedcolumns plus the ones whose names say nothing about their contents:IntegrationStatus.lastError,CoachMessage.encryptedContent,NotificationChannel.config, the OAuthaccessToken/refreshTokencolumns, the web-pushp256dh/authsecrets, the idempotent-replayIdempotencyKey.responseBody, andDataBackup.data— the whole-account backup blob.When you used the script, read three things off its output before going further:
- the per-column line,
scanned/rotated/errors/dropped.scannedcounts the rows that hold ciphertext in that column, not every row of the table. Treaterrors > 0as a hard failure and re-run after fixing the cause. Adroppedcount is only ever a cache row the run could not read and deleted rather than leave unreadable. Columns walked: N/M registered. The two numbers must match.- a
NOT WALKEDblock, if one appears. It lists registered columns this run skipped, and the run exits non-zero. Rotation is incomplete; do not go on to step 4.
Two guard tests keep the registry honest in CI: one scans the schema for
*Encryptedcolumns, the other derives the ciphertext-bearing columns from the Prisma write payloads, which is what catches a column that holds ciphertext under an ordinary name. - the per-column line,
-
Confirm nothing is left on the old key, THEN drop it. This is the step that destroys data if it is taken on a bad signal, so check the corpus rather than the absence of complaints. Open Admin → Encryption and confirm the status view reports zero rows outside the active key, or re-run the script: a clean second pass reports
rotated=0,errors=0and a fullColumns walkedline. Both read the same registry the rotation walks.A zero only means "nothing left" when the run also says it walked every registered column. A zero from a run that skipped columns means "never looked", and dropping the old key on it makes those rows permanently undecryptable —
decrypt()is fail-closed and there is no recovery path. Only once the corpus reads clean:ENCRYPTION_KEYS='{"v2":"<new key>"}' ENCRYPTION_ACTIVE_KEY_ID="v2" # ENCRYPTION_KEY can now be removedRestart. The legacy single-key fallback is now disconnected; only
v2exists.
The rows in the database are covered. The content of a backup is not. Rotation re-encrypts every registered column, including
DataBackup.dataand the pieces inDataBackupChunk, so every stored copy opens under the new key. What it cannot change is what a disaster-recovery backup carries inside: the database's ciphertext as it was stored when the copy was taken, which a restore writes back verbatim. A copy taken before the rotation still needs the old key for its notes, documents and coach history, and so does every copy in the off-host bucket and every backup file you downloaded.So before step 4, also check Admin → Encryption → Keys the backups still need. It lists, per key id, the stored copies that need it with the oldest date, and when the last off-host copy needing it expires under the bucket's lifecycle rule. Keep the old key in
ENCRYPTION_KEYSuntil it is no longer listed there (the weekly copy replaces itself within a week; delete old uploaded copies you no longer want, and wait out the off-host retention), or accept that those copies cannot be restored. Copies written before v1.39.3 did not record their keys and are listed as such: treat them as needing every key that existed when they were written.Dropping the key early no longer fails silently: a restore, a restore preview or an upload of a copy that needs a missing key is refused with the key id named, and nothing is changed. Putting the key back makes the copy restorable again.
If you rotated on a release before v1.38.6 and dropped the previous key, the stored backups themselves are encrypted under the key you removed: put that key back into
ENCRYPTION_KEYSand re-run the rotation on this release before removing it again.
Adding a third key (v2 → v3)
Same procedure, just shift the labels — keep v2 in the map until a full
run (every registered column walked) reports zero v2.-prefixed rows
remaining.
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
# restart, then Admin → Encryption → Rotate now
# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout)
ENCRYPTION_KEYS='{"v3":"<new>"}'
Rollback
Important. Once a rotation 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 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 again, then remove it.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 again 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.