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
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user