Skip to content

Rotate encryption keys

Every Kumiko app with crypto-shredding enabled runs on two keys, and they rotate very differently:

KeyEnv varWhat it protectsRotation cost
Platform KEKPLATFORM_KEKWraps every person’s data key (DEK) in the subject-keys storeRe-wrap of key rows, zero-downtime, needs the old KEK
Blind-index keyKUMIKO_BLIND_INDEX_KEYHMAC fingerprints that keep equality lookups (login by email) working on encrypted columnsCheap — 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.

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.

  1. Back up the subject-keys database. A failed re-wrap must be recoverable within your backup window.

  2. Generate the new KEK (openssl rand -base64 32) and store it in your password manager before configuring it anywhere.

  3. 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.

  4. 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 failures with an authentication error — that is expected and non-destructive. Your pass criterion is then: rewrapped equals your own subject count and failures contains exactly the other apps’ subjects.

  5. Verify — all your subjects on the new generation:

    SELECT kek_version, count(*) FROM kumiko_subject_keys
    WHERE 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.

  6. Retire the old generation. Remove previousKeks from 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.

No generations needed — blind-index columns are derived data:

  1. Set the new KUMIKO_BLIND_INDEX_KEY, redeploy.
  2. Immediately rebuild the entity projections (rebuildProjection per lookupable entity, or your app’s PII backfill script). The rebuild recomputes every fingerprint with the new key.
  3. Between deploy and rebuild, equality lookups on existing rows miss (a login fails like an unknown email) — rotate off-peak and rebuild right away.
  4. Verify: no encrypted rows without a fingerprint, plus a login smoke test.