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
@@ -5,6 +5,11 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import {
BACKUP_CODE_COUNT,
generatePlaintextBackupCodes,
hashBackupCode,
} from "../lib/mfa-backup-codes.js";
import { consumeTotpWindow } from "../lib/totp-consume.js";
import { totpRateLimiter } from "../middleware/rate-limit.js";
import type { TRPCContext } from "../trpc.js";
@@ -251,6 +256,21 @@ export async function verifyAndEnableTotp(
data: { totpEnabled: true },
});
// Issue the initial backup-code set as part of the enable flow. Doing
// this here (vs making it a separate opt-in step) avoids the common
// footgun of users enabling MFA, losing their device, and being locked
// out — one of the explicit motivations for #43 part 2.
const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT);
const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p)));
await ctx.db.$transaction([
(ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({
where: { userId: user.id },
}),
(ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({
data: hashes.map((codeHash) => ({ userId: user.id, codeHash })),
}),
]);
void createAuditEntry({
db: ctx.db,
entityType: "User",
@@ -262,7 +282,7 @@ export async function verifyAndEnableTotp(
summary: "Enabled TOTP MFA",
});
return { enabled: true };
return { enabled: true, backupCodes: plaintexts };
}
export async function verifyTotp(
@@ -330,5 +350,70 @@ export async function getCurrentMfaStatus(ctx: UserSelfServiceContext) {
"User",
);
return { totpEnabled: user.totpEnabled };
const backupCodesRemaining = user.totpEnabled
? await (
ctx.db as unknown as {
mfaBackupCode: {
count: (args: { where: { userId: string; usedAt: null } }) => Promise<number>;
};
}
).mfaBackupCode.count({
where: { userId: ctx.dbUser!.id, usedAt: null },
})
: 0;
return { totpEnabled: user.totpEnabled, backupCodesRemaining };
}
// Generate (or regenerate) a user's backup-code set. Returns the plaintext
// codes exactly once — the caller MUST display them immediately; there is
// no re-display endpoint. Regeneration wipes the previous set atomically
// (deleteMany + createMany in a transaction), so a partially-regenerated
// state — some old codes still valid, some new codes issued — is not
// observable to either the user or an attacker.
//
// Requires TOTP to already be enabled: the codes are a *backup* for an
// existing second factor, not a way to bootstrap MFA.
export async function regenerateBackupCodes(ctx: UserSelfServiceContext) {
const user = await findUniqueOrThrow(
ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpEnabled: true },
}),
"User",
);
if (!user.totpEnabled) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Enable TOTP before generating backup codes.",
});
}
const plaintexts = generatePlaintextBackupCodes(BACKUP_CODE_COUNT);
const hashes = await Promise.all(plaintexts.map((p) => hashBackupCode(p)));
// Transaction guarantees all-or-nothing replacement: a failure after
// deleteMany but before createMany would otherwise leave the user with
// zero backup codes and a UI that thinks they have 10.
await ctx.db.$transaction([
(ctx.db as unknown as { mfaBackupCode: { deleteMany: Function } }).mfaBackupCode.deleteMany({
where: { userId: user.id },
}),
(ctx.db as unknown as { mfaBackupCode: { createMany: Function } }).mfaBackupCode.createMany({
data: hashes.map((codeHash) => ({ userId: user.id, codeHash })),
}),
]);
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: user.id,
entityName: `${user.name} (${user.email})`,
action: "UPDATE",
userId: user.id,
source: "ui",
summary: "Regenerated MFA backup codes",
});
return { codes: plaintexts, count: plaintexts.length };
}
+4
View File
@@ -42,6 +42,7 @@ import {
saveDashboardLayout,
SetColumnPreferencesInputSchema,
setColumnPreferences,
regenerateBackupCodes,
ToggleFavoriteProjectInputSchema,
toggleFavoriteProject,
verifyAndEnableTotp as verifyAndEnableTotpSelfService,
@@ -152,4 +153,7 @@ export const userRouter = createTRPCRouter({
/** Get MFA status for the current user. */
getMfaStatus: protectedProcedure.query(({ ctx }) => getCurrentMfaStatus(ctx)),
/** Generate a fresh set of MFA backup codes, invalidating any previous set. */
regenerateBackupCodes: protectedProcedure.mutation(({ ctx }) => regenerateBackupCodes(ctx)),
});