62 lines
2.2 KiB
Markdown
62 lines
2.2 KiB
Markdown
|
|
# 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.)
|