Playbook: Encryption Key Rotation (Last Resort — Confirmed Exposure Only)
Covers: SUPABASE_JWT_SECRET · IAM_ENC_KEY · N8N_ENCRYPTION_KEY · WEBHOOK_SECRET
THESE ARE LAST-RESORT ROTATIONS.
Do NOT rotate on schedule. These keys are designated static per
credential-rotation-policy.md. Rotate only on confirmed exposure (value seen in logs, committed to git, or exposed in chat).Impact of rotation:
SUPABASE_JWT_SECRET— all user sessions invalidated immediately (signing key; no data at rest)IAM_ENC_KEY— encrypts data at rest; a bare key-swap orphans ALL existing ciphertext. Requires a dual-key read code change + a re-encryption pass FIRST — see its dedicated section below (#6561/#6615)N8N_ENCRYPTION_KEY— all n8n stored credentials become unreadable; all workflows haltWEBHOOK_SECRET— webhook HMAC validation fails until both producer and consumer are updated
Overview
| Key | SOPS file | Impact |
|---|---|---|
SUPABASE_JWT_SECRET | whatsup | All signed JWTs for p24-wa-a invalidated; users logged out |
IAM_ENC_KEY | whatsup | pgcrypto passphrase for data at rest — bare swap orphans all p24_wap_user_storage ciphertext; needs dual-key read + re-encryption pass first |
N8N_ENCRYPTION_KEY | n8n-bms4 | All n8n stored credentials (OAuth tokens, API keys) unreadable |
WEBHOOK_SECRET | whatsup | In-flight webhook calls fail HMAC validation |
Tier: ❌ Tier 3 Manual — keys are generated locally but require a maintenance window and coordinated rollout.
Pre-rotation requirements (all keys)
- Open a GH issue:
sec: rotate KEY_NAME — confirmed exposure YYYY-MM-DD(label:critical) - Maintenance window scheduled and stakeholders notified
- Rollback plan confirmed before making any change
- For
N8N_ENCRYPTION_KEY: n8n credential vault exported (see Step 1 below)
SUPABASE_JWT_SECRET
Effect: All active p24-wa-a user sessions are immediately terminated. Users must re-authenticate.
This key only signs JWTs — it does not encrypt any data at rest, so a straight
update → redeploy is safe. (Contrast IAM_ENC_KEY below, which encrypts data at rest and must
not be rotated this way.)
Steps
-
Schedule maintenance window — notify users: “All sessions will terminate at
<time>.” -
Generate new key:
# 32-byte random value — never print this variable
$env:NEW_JWT = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
# Store to .env.local for handoff; clear $env:NEW_JWT after SOPS update-
Distribute: Update
secrets/whatsup.env.sops— key:SUPABASE_JWT_SECRET. Followdocs/playbooks/sops-edit-operations.md. -
Deploy: Commit → merge →
secrets-sync.yml(or manualwrangler secret put/ Vercel env for the whatsup consumers —whatsup.env.sopsis excluded fromsecrets-sync.yml’s automated push) → restart all JWT-signing consumers:
docker compose restart p24-wa-a # or equivalent service name-
Verify: New user login succeeds. Old session tokens return
401. -
Rollback if broken:
git revertthe SOPS commit → re-deploy → restart.
IAM_ENC_KEY
⛔ DO NOT “just update SOPS and restart”.
IAM_ENC_KEYis not a signing key — it is the pgcrypto passphrase that encrypts data at rest. Every provisioned user’s Wasabi IAM credentials are stored as pgcrypto ciphertext (pgp_sym_encrypt) inp24_wap_user_storage.iam_access_key/.iam_secret_key. Swapping the key without a re-encryption pass first makespgp_sym_decrypt(..., NEW_KEY)throw “Wrong key or corrupt data” on every existing row — permanently orphaning all ciphertext (the OLD key is gone once SOPS is overwritten). This is irreversible user-data loss, not a session reset. The previous version of this playbook lumpedIAM_ENC_KEYin withSUPABASE_JWT_SECRETunder a bare “update SOPS → restart” recipe; applied literally that recipe is the outage, not the fix (#6561, #6615).
Consumers
- Live read/write:
p24-wa-app-apiCloudflare Worker (reporadieu/whatsup-android-chat-puller), deployed viawrangler secret put IAM_ENC_KEY. It decrypts via the Postgres accessorp24_wap_get_user_iam_keys(pgcryptopgp_sym_decrypt). - Data at rest:
p24_wap_user_storagerows in shared Supabase projectmwkqmgadqnkkihjdeqsi. whatsup.env.sopsis excluded fromsecrets-sync.yml’s automated push — deployment is manual.
Prerequisite: a code change must land FIRST (dual-key read)
Do not attempt this rotation until p24-wa-app-api supports a dual-key read: try the current
IAM_ENC_KEY first, fall back to IAM_ENC_KEY_OLD on a decrypt failure. Without it, the cutover has
an unavoidable window where either the old or the new ciphertext cannot be read, forcing a hard
maintenance window and making rollback impossible once rows are re-encrypted.
- Tracking (whatsup): radieu/whatsup-android-chat-puller — “Add dual-key
IAM_ENC_KEYread (NEW then_OLDfallback) towa-app-api” (opened as the #6561 spinoff). - Why it matters for automation: with the dual-key read shipped, the rotation drops from IRREDUCIBLY-HUMAN (hard maintenance window, no clean rollback) to NEEDS-CODE-CHANGE-FIRST → AUTONOMOUS (zero-downtime, rollback-safe): during the window both the OLD and NEW keys are valid decrypt paths, so re-encryption can proceed row-by-row with no user-facing read failures and can be aborted at any point.
Method — pgcrypto re-encryption inside a single Postgres transaction
The re-encryption never lets plaintext leave Postgres. Both passphrases are passed transiently as
SQL arguments (bound parameters / SET LOCAL, never logged, never written to a column). The whole
pass is one transaction that only commits if 100 % of rows verify under the NEW key.
⛔
apply_migration/supabase db pushare BANNED against shared projectmwkqmgadqnkkihjdeqsi(#5653/#5645) — they corrupt the sharedschema_migrationsledger. The re-encryption RPC/migration therefore must ship through the whatsup per-repo migration ledger inradieu/whatsup-android-chat-puller(its ownsupabase/migrations/+ CI runner), not as a p24-infrasecret-managerjob and not via any p24-infra migration path. Seesupabase-per-repo-migration-ledger.md.
Phase 0 — ciphertext backup (zero mutation, Gate A).
Dump the current iam_access_key / iam_secret_key ciphertext columns (still OLD-key-encrypted) for
every row in p24_wap_user_storage to an off-DB backup. This is the restore point — keep the OLD key
retained until the window closes and Phase 3 has verified.
Phase 1 — dry-run decrypt under OLD key (zero mutation, Gate A).
For all N rows, run pgp_sym_decrypt(col, OLD_KEY) inside a read-only transaction and count successes.
Gate A: proceed only if all N rows decrypt cleanly under the OLD key. Any failure here means
the OLD key or a row is already corrupt — STOP and investigate before mutating anything.
Phase 2 — re-encrypt OLD → NEW in one transaction (the only mutating step).
BEGIN;
-- OLD_KEY / NEW_KEY supplied transiently as bound args — never inlined into logged SQL, never printed.
UPDATE p24_wap_user_storage
SET iam_access_key = pgp_sym_encrypt(pgp_sym_decrypt(iam_access_key, :OLD_KEY), :NEW_KEY),
iam_secret_key = pgp_sym_encrypt(pgp_sym_decrypt(iam_secret_key, :OLD_KEY), :NEW_KEY)
WHERE iam_access_key IS NOT NULL;
-- Phase 3 verify (same transaction): every row must decrypt under NEW_KEY.
-- Count rows where NEW-key decrypt fails; must be 0.
SELECT count(*) AS failures
FROM p24_wap_user_storage
WHERE iam_access_key IS NOT NULL
AND (pgp_sym_decrypt(iam_access_key, :NEW_KEY) IS NULL
OR pgp_sym_decrypt(iam_secret_key, :NEW_KEY) IS NULL);
-- COMMIT only if failures = 0; otherwise ROLLBACK — the whole pass is atomic.
COMMIT; -- or: ROLLBACK;Because it is one transaction, a partial failure rolls back to the exact pre-rotation ciphertext — no half-migrated table.
Phase 3 — verify (inside the same transaction, before COMMIT).
The failures count above must be exactly 0 across all N rows. COMMIT only then; any non-zero
count → ROLLBACK and abort. (With the dual-key read live, a rollback is invisible to users — the
Worker keeps reading via the still-valid OLD key.)
Full ordered runbook
- Land + verify the dual-key read (
IAM_ENC_KEY→IAM_ENC_KEY_OLDfallback) inp24-wa-app-api. Gate — do not start the rotation until this is deployed. - Generate
NEW_IAM(32-byte random, never printed):
$env:NEW_IAM = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))- Set
IAM_ENC_KEY_OLD= the current live value andIAM_ENC_KEY=NEW_IAMinsecrets/whatsup.env.sops(secret-manager role; followsops-edit-operations.md), thenwrangler secret putboth top24-wa-app-api. The Worker now reads NEW, falls back to OLD → all existing (OLD-encrypted) rows still decrypt. Zero downtime. - Phase 0 + Phase 1 (backup + Gate A dry-run) via the whatsup per-repo migration ledger.
- Phase 2 + Phase 3 — atomic re-encrypt + verify; COMMIT only on 100 %.
- Retire the OLD key: once Phase 3 has committed and the Worker is confirmed reading via NEW,
remove
IAM_ENC_KEY_OLDfromwhatsup.env.sopsandwrangler secret delete IAM_ENC_KEY_OLD. - Rollback (only before step 6 completes): the dual-key read means the Worker still serves reads
via
IAM_ENC_KEY_OLD;ROLLBACK/revert the re-encryption, restore ciphertext from the Phase 0 backup if needed, keep OLD live. After step 6 there is no clean rollback — that is why OLD is retained through the whole window.
Blockers checklist (all must be true before rotating)
- Dual-key read shipped + verified in
p24-wa-app-api(whatsup repo) - Re-encryption migration authored in the whatsup per-repo ledger (NOT
apply_migrationonmwkqmgadqnkkihjdeqsi) - Phase 0 ciphertext backup taken; OLD key retained for the whole window
- Someone who can reach/verify the
p24-wa-app-apiCF Worker deploy is coordinating the cutover
N8N_ENCRYPTION_KEY
Effect: All stored n8n credentials become unreadable. Every workflow using a stored credential fails immediately. This is a major operation requiring full credential re-entry.
Steps
- Export credential vault BEFORE changing the key:
ssh root@100.86.202.85
docker exec -it n8n n8n export:credentials --all \
--output=/home/node/creds-backup-$(date +%Y%m%d).json
docker cp n8n:/home/node/creds-backup-*.json /opt/backups/Keep this backup — it is encrypted with the OLD key.
- Generate new encryption key:
NEW_KEY=$(openssl rand -hex 16) # 32-char hex — never echo-
Update
secrets/n8n-bms4.env.sops— key:N8N_ENCRYPTION_KEY. Followdocs/playbooks/sops-edit-operations.md. -
Deploy and restart n8n:
ssh root@100.86.202.85 "cd /opt/n8n && docker compose stop n8n && docker compose up -d n8n"-
Re-enter all credentials in n8n UI. All existing credentials will show as broken. Use the exported JSON (decryptable with the old key) as a reference for the original values. Re-enter each credential manually via n8n UI → Credentials.
-
Verify: Trigger each critical workflow manually; confirm credentials load without errors.
-
Rollback if n8n fails to start: Revert SOPS commit → redeploy → credentials from backup.
WEBHOOK_SECRET
Effect: All in-flight webhook calls fail HMAC validation until producer and consumer are both updated.
Steps
-
Identify all producer and consumer endpoints for this secret before proceeding.
-
Generate new secret:
$env:NEW_WH = ([BitConverter]::ToString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))).Replace("-","").ToLower()-
Coordinate atomic rollout — during low-traffic window:
- Update
secrets/whatsup.env.sops— key:WEBHOOK_SECRET - Restart consumer first, then producer to minimize validation failures
- Update
-
Verify: Send a test webhook and confirm successful HMAC validation.
Log rotation
| YYYY-MM-DD HH:MM UTC | #ISSUE | N8N_ENCRYPTION_KEY | confirmed exposure | radieu | n8n-bms4.env.sops — full credential migration completed |
References
secret-rotation-access-matrix.md— Tier 3 danger notesdocs/playbooks/sops-edit-operations.md— SOPS write patterndocs/playbooks/static-api-key-incident-rotation.md— exposure incident responsedocs/playbooks/credential-rotation-policy.md— why these keys are static