Files
backend/docs/2fa-key-rotation.md

62 lines
2.2 KiB
Markdown
Raw Permalink Normal View History

fix(security): resolve audit findings — secrets, env contract, migrations Removes hardcoded fallback secrets and makes a misconfigured deploy fail loudly instead of silently falling back to development defaults. - Remove insecure JWT fallback secrets (messages.module, configuration) - Remove the 'default-secret' fallback for the 2FA TOTP encryption key and allow a dedicated TWO_FACTOR_ENCRYPTION_KEY so rotating JWT_SECRET no longer locks out every 2FA user (see docs/2fa-key-rotation.md) - Require EMAIL_API_URL; drop the hardcoded vendor email endpoint - Drive WebSocket CORS from CORS_ORIGINS instead of origin:'*' - Load .env before any Nest module is imported (src/load-env.ts). Decorator arguments evaluate at import time, so the gateway previously froze its CORS config to the localhost fallback even when CORS_ORIGINS was set - Add boot-time env validation: missing required vars, weak JWT_SECRET, and inverted access/refresh token lifetimes now abort startup - Enable Redis TLS certificate verification - Require ADMIN_EMAIL/ADMIN_PASSWORD for the seed; remove the published default super-admin credentials and stop printing them - Add the initial Prisma migration and stop gitignoring prisma/migrations - Make .env.example an accurate configuration contract (admin bootstrap, REDIS_TLS, S3_ENDPOINT, 2FA key, Firebase path; drop the dead SMTP block) - Add handover documentation: architecture, ER model, sequence and data-flow diagrams, 2FA key rotation runbook Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:30:36 +05:30
# Rotating JWT_SECRET without locking out 2FA users
## The problem
`TwoFactorService` encrypts each user's TOTP secret (`User.twoFactorSecret`)
with a key derived from a passphrase:
```
encryptionKey = scryptSync(TWO_FACTOR_ENCRYPTION_KEY ?? JWT_SECRET, 'salt', 32)
```
Historically only `JWT_SECRET` was used. That means **rotating `JWT_SECRET`
changes the 2FA encryption key**, and every already-stored `twoFactorSecret`
becomes undecryptable. Affected users can still enter their password but every
TOTP code is rejected at `POST /auth/2fa/verify` — they are locked out of their
accounts, and support cannot recover it without disabling 2FA per user.
This matters because the handover requires `JWT_SECRET` to be rotated.
## Rule
**Set `TWO_FACTOR_ENCRYPTION_KEY` to the value `JWT_SECRET` had when the 2FA
secrets were encrypted, then rotate `JWT_SECRET` freely.**
Once `TWO_FACTOR_ENCRYPTION_KEY` is set, the two keys are independent and this
problem cannot recur.
## Procedure
1. Before rotating, record the current `JWT_SECRET`.
2. Add to the production environment:
```
TWO_FACTOR_ENCRYPTION_KEY=<the OLD JWT_SECRET value>
```
3. Set the new `JWT_SECRET`.
4. Deploy and restart. Existing 2FA secrets still decrypt; all sessions issued
under the old `JWT_SECRET` are invalidated (users log in again — expected).
5. Verify with a real 2FA-enabled account before announcing the change.
## Checking the blast radius first
```sql
SELECT count(*) FROM "users" WHERE "twoFactorEnabled" = true;
```
If this is `0`, no user is affected: set `TWO_FACTOR_ENCRYPTION_KEY` to a fresh
random value and rotate `JWT_SECRET` independently.
## Re-keying to a fresh 2FA key later
There is no bulk re-encryption script. To move to a brand-new
`TWO_FACTOR_ENCRYPTION_KEY` after secrets already exist, either:
- write a one-off script that decrypts with the old key and re-encrypts with
the new one (`encrypt`/`decrypt` in `src/auth/two-factor/two-factor.service.ts`,
format `iv:authTag:ciphertext`, AES-256-GCM), or
- clear 2FA for all users and have them re-enrol:
```sql
UPDATE "users" SET "twoFactorEnabled" = false, "twoFactorSecret" = NULL;
```
(Disruptive — every 2FA user must re-scan their QR code.)