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
+19
View File
@@ -205,6 +205,7 @@ model User {
activeSessions ActiveSession[]
reportTemplates ReportTemplate[]
assistantApprovals AssistantApproval[]
mfaBackupCodes MfaBackupCode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -212,6 +213,24 @@ model User {
@@map("users")
}
// One row per still-redeemable backup code. We store argon2id(code) — never
// the plaintext — and delete the row on redemption so replay is physically
// impossible. Generation wipes and recreates the whole set (kick-oldest
// strategy not used here: recovery codes are all-or-nothing, a partial
// set is worse than none).
model MfaBackupCode {
id String @id @default(cuid())
userId String
codeHash String
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("mfa_backup_codes")
}
enum AssistantApprovalStatus {
PENDING
APPROVED