Rotate encryption keys
Every Kumiko app with crypto-shredding enabled runs on two keys, and they rotate very differently:
| Key | Env var | What it protects | Rotation cost |
|---|---|---|---|
| Platform KEK | PLATFORM_KEK | Wraps every person’s data key (DEK) in the subject-keys store | Re-wrap of key rows, zero-downtime, needs the old KEK |
| Blind-index key | KUMIKO_BLIND_INDEX_KEY | HMAC fingerprints that keep equality lookups (login by email) working on encrypted columns | Cheap, fingerprints are recomputed from data |
In plain words: rotating the KEK re-wraps each person’s data key with a new KEK. The data itself does not change, and the app stays available during the rotation. Keep every generation in your password manager until it is provably retired. Losing the old KEK makes the wrapped keys unrecoverable.
Rotate the platform KEK (zero-downtime)
Section titled “Rotate the platform KEK (zero-downtime)”The adapter supports key generations: every wrapped key row records
which KEK generation wrapped it (kek_version). During a rotation the app
reads old and new generations, but writes only with the new one.
Order matters: first roll out the app so it can read both generations, then re-wrap. The other way around, an old pod can still write old-generation rows while you re-wrap, leaving stragglers behind.
-
Back up the subject-keys database. A failed re-wrap must be recoverable within your backup window.
-
Generate the new KEK (
openssl rand -base64 32) and store it in your password manager before configuring it anywhere. -
Deploy the “reads both, writes new” configuration:
createPgKmsAdapter({databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,platformKek: NEW_KEK,kekVersion: 2,previousKeks: { 1: OLD_KEK },})From this deploy on, no new generation-1 wraps are created; existing rows stay readable through
previousKeks. -
Re-wrap the estate, dry-run first, then for real:
import { rewrapSubjectKeys } from "@cosmicdrift/kumiko-framework/crypto";const result = await rewrapSubjectKeys({databaseUrl: SUBJECT_KEYS_URL,fromKeks: { 1: OLD_KEK },toKek: NEW_KEK,toKekVersion: 2,dryRun: true, // inspect the numbers, then run again with false});The run is idempotent, skips erased tombstones, and never writes a row it could not decrypt. If several apps share one subject-keys store, rows wrapped by the other apps’ KEKs show up in
failureswith an authentication error, that is expected and non-destructive. Your pass criterion is then:rewrappedequals your own subject count andfailurescontains exactly the other apps’ subjects. -
Verify, all your subjects on the new generation:
SELECT kek_version, count(*) FROM kumiko_subject_keysWHERE cipher_key IS NOT NULL GROUP BY 1;Strongest proof: unwrap a key with an adapter configured only with the new KEK (no
previousKeks), that simulates the end state. Then a login smoke test, which exercises the full blind-index + DEK path. -
Retire the old generation. Remove
previousKeksfrom the config and redeploy. Mark the old KEK as rotated in your password manager, but keep it until your subject-keys backup retention window has passed, a restore inside that window can bring back old-generation rows.
Rotate the blind-index key
Section titled “Rotate the blind-index key”No generations needed, blind-index columns are derived data:
- Set the new
KUMIKO_BLIND_INDEX_KEY, redeploy. - Immediately rebuild the entity projections (
rebuildProjectionper lookupable entity, or your app’s PII backfill script). The rebuild recomputes every fingerprint with the new key. - Between deploy and rebuild, equality lookups on existing rows miss (a login fails like an unknown email), rotate off-peak and rebuild right away.
- Verify: no encrypted rows without a fingerprint, plus a login smoke test.
See also
Section titled “See also”- Crypto-shredding, why the keys exist and what erasure means