security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled

Adds a one-time-use backup code set so users with a lost authenticator are not
locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and
redeemed under a WHERE-guarded delete so a concurrent replay race fails closed.

- New MfaBackupCode model + migration
- Issue 10 codes inside the enable transaction; show plaintext exactly once
- Sign-in page accepts TOTP or backup code, reporting remaining count
- regenerateBackupCodes tRPC mutation wipes + reissues atomically
- Unit coverage for generator, normalizer, verify, redeem, and race path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 18:47:18 +02:00
parent 9dc1ffd3ad
commit fe79810a85
16 changed files with 890 additions and 136 deletions
+14 -2
View File
@@ -55,6 +55,12 @@ function createAdminCaller(db: Record<string, unknown>) {
// Individual tests can override by passing their own `activeSession` key.
const dbWithDefaults = {
activeSession: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
mfaBackupCode: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 10 }),
count: vi.fn().mockResolvedValue(0),
},
$transaction: vi.fn(async (ops: unknown[]) => ops),
...db,
};
return createCaller({
@@ -735,7 +741,8 @@ describe("user profile and TOTP self-service", () => {
const result = await caller.verifyAndEnableTotp({ token: "123456" });
expect(result).toEqual({ enabled: true });
expect(result.enabled).toBe(true);
expect(result.backupCodes).toHaveLength(10);
// lastTotpAt is written atomically by updateMany (the replay guard);
// user.update only toggles the enabled flag after the CAS succeeds.
expect(updateMany).toHaveBeenCalledWith(
@@ -1035,11 +1042,16 @@ describe("user column preferences and MFA status", () => {
user: {
findUnique,
},
mfaBackupCode: {
deleteMany: vi.fn(),
createMany: vi.fn(),
count: vi.fn().mockResolvedValue(4),
},
});
const result = await caller.getMfaStatus();
expect(result).toEqual({ totpEnabled: true });
expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 4 });
expect(findUnique).toHaveBeenCalledWith({
where: { id: "user_admin" },
select: { totpEnabled: true },