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: the KEK is the key to the key cabinet. Rotating it means taking every person’s key out of its old lock and putting it into a new one — the data itself is never touched, and nobody notices while it happens. Losing the KEK is the one thing rotation cannot fix: without the old key, the cabinet stays shut forever. Keep every generation in your password manager until it is provably retired.
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