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
+1
View File
@@ -13,6 +13,7 @@
"./lib/logger": "./src/lib/logger.ts",
"./lib/runtime-security": "./src/lib/runtime-security.ts",
"./lib/totp-consume": "./src/lib/totp-consume.ts",
"./lib/mfa-backup-code-redeem": "./src/lib/mfa-backup-code-redeem.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
},
"scripts": {
@@ -41,7 +41,7 @@ describe("assistant user self-service MFA tools - enable flow", () => {
it("enables TOTP through the real user router path when the token is valid", async () => {
totpValidateMock.mockReturnValue(0);
const db = {
const db: Record<string, unknown> = {
user: {
findUnique: vi.fn().mockResolvedValue({
id: "user_1",
@@ -56,6 +56,11 @@ describe("assistant user self-service MFA tools - enable flow", () => {
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
},
mfaBackupCode: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 10 }),
},
$transaction: vi.fn().mockImplementation(async (ops: unknown[]) => ops.map(() => ({}))),
};
const ctx = createToolContext(db, SystemRole.ADMIN);
@@ -99,11 +104,14 @@ describe("assistant user self-service MFA tools - enable flow", () => {
summary: "Enabled TOTP MFA",
}),
});
expect(JSON.parse(result.content)).toEqual({
success: true,
enabled: true,
message: "Enabled MFA TOTP.",
});
const parsed = JSON.parse(result.content);
expect(parsed.success).toBe(true);
expect(parsed.enabled).toBe(true);
expect(parsed.message).toBe("Enabled MFA TOTP.");
expect(parsed.backupCodes).toHaveLength(10);
for (const code of parsed.backupCodes) {
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
}
expect(result.action).toEqual({
type: "invalidate",
scope: ["user"],
@@ -19,6 +19,9 @@ describe("assistant user self-service MFA tools - status", () => {
totpEnabled: true,
}),
},
mfaBackupCode: {
count: vi.fn().mockResolvedValue(3),
},
};
const ctx = createToolContext(db, SystemRole.ADMIN);
@@ -30,6 +33,7 @@ describe("assistant user self-service MFA tools - status", () => {
});
expect(JSON.parse(result.content)).toEqual({
totpEnabled: true,
backupCodesRemaining: 3,
});
});
@@ -39,6 +43,9 @@ describe("assistant user self-service MFA tools - status", () => {
user: {
findUnique: vi.fn().mockResolvedValue(null),
},
mfaBackupCode: {
count: vi.fn().mockResolvedValue(0),
},
},
SystemRole.ADMIN,
);
@@ -0,0 +1,128 @@
/**
* Unit tests for the MFA backup-code generator, canonicalisation, and the
* atomic redemption helper. Together they cover the three guarantees that
* make backup codes safe:
*
* 1. High-entropy, distinct plaintexts (generator).
* 2. Canonical form is what gets hashed/compared — a user can paste the
* code with or without the dash, upper or lower case.
* 3. Redemption deletes the row under a WHERE-guard so a concurrent
* second redemption fails (replay race).
*/
import { describe, expect, it, vi } from "vitest";
import {
BACKUP_CODE_COUNT,
generatePlaintextBackupCodes,
hashBackupCode,
normalizeBackupCode,
verifyBackupCode,
} from "../lib/mfa-backup-codes.js";
import { redeemBackupCode } from "../lib/mfa-backup-code-redeem.js";
describe("generatePlaintextBackupCodes", () => {
it("yields BACKUP_CODE_COUNT distinct codes by default", () => {
const codes = generatePlaintextBackupCodes();
expect(codes).toHaveLength(BACKUP_CODE_COUNT);
expect(new Set(codes).size).toBe(BACKUP_CODE_COUNT);
});
it("formats each code as five chars, dash, five chars from the Crockford alphabet", () => {
for (const code of generatePlaintextBackupCodes(20)) {
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
}
});
});
describe("normalizeBackupCode", () => {
it("strips dashes and whitespace and uppercases", () => {
expect(normalizeBackupCode("ab12c-xy34z")).toBe("AB12CXY34Z");
expect(normalizeBackupCode(" AB12C XY34Z ")).toBe("AB12CXY34Z");
expect(normalizeBackupCode("ab12cxy34z")).toBe("AB12CXY34Z");
});
});
describe("verifyBackupCode", () => {
it("accepts the plaintext (with or without dash) that produced the hash", async () => {
const hash = await hashBackupCode("ABCDE-FGHJK");
expect(await verifyBackupCode(hash, "ABCDE-FGHJK")).toBe(true);
expect(await verifyBackupCode(hash, "abcde-fghjk")).toBe(true);
expect(await verifyBackupCode(hash, "ABCDEFGHJK")).toBe(true);
});
it("rejects a different plaintext", async () => {
const hash = await hashBackupCode("ABCDE-FGHJK");
expect(await verifyBackupCode(hash, "ZZZZZ-ZZZZZ")).toBe(false);
});
it("returns false rather than throwing on a malformed hash", async () => {
expect(await verifyBackupCode("not-a-real-hash", "anything")).toBe(false);
});
});
describe("redeemBackupCode", () => {
it("accepts a valid code, deletes the row, and reports remaining count", async () => {
const goodHash = await hashBackupCode("GOOD1-CODE1");
const otherHash = await hashBackupCode("OTHER-CODE2");
const db = {
mfaBackupCode: {
findMany: vi.fn().mockResolvedValue([
{ id: "a", codeHash: otherHash },
{ id: "b", codeHash: goodHash },
]),
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
count: vi.fn().mockResolvedValue(1),
},
};
const result = await redeemBackupCode(db, "user_1", "GOOD1-CODE1");
expect(result).toEqual({ accepted: true, remaining: 1 });
expect(db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({
where: { id: "b", usedAt: null },
});
});
it("rejects an unknown code without deleting anything", async () => {
const db = {
mfaBackupCode: {
findMany: vi
.fn()
.mockResolvedValue([{ id: "a", codeHash: await hashBackupCode("REAL1-CODE1") }]),
deleteMany: vi.fn(),
count: vi.fn().mockResolvedValue(1),
},
};
const result = await redeemBackupCode(db, "user_1", "WRONG-CODE");
expect(result.accepted).toBe(false);
expect(result.remaining).toBe(1);
expect(db.mfaBackupCode.deleteMany).not.toHaveBeenCalled();
});
it("treats a racing delete (count=0) as an invalid code", async () => {
// Simulates the case where another login request redeemed this exact
// code a millisecond earlier. The SQL WHERE-guard (usedAt: null) stops
// us from deleting it twice — we must treat that as a failed attempt
// so the attacker cannot learn the code was valid.
const goodHash = await hashBackupCode("RACE1-CODE1");
const db = {
mfaBackupCode: {
findMany: vi.fn().mockResolvedValue([{ id: "a", codeHash: goodHash }]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
count: vi.fn().mockResolvedValue(0),
},
};
const result = await redeemBackupCode(db, "user_1", "RACE1-CODE1");
expect(result.accepted).toBe(false);
});
it("returns accepted:false / remaining:0 when the user has no codes", async () => {
const db = {
mfaBackupCode: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn(),
count: vi.fn().mockResolvedValue(0),
},
};
const result = await redeemBackupCode(db, "user_1", "ANY-CODE");
expect(result).toEqual({ accepted: false, remaining: 0 });
});
});
@@ -40,13 +40,15 @@ describe("user-procedure-support", () => {
});
it("lists assignable users with the expected lightweight selection", async () => {
const findMany = vi.fn().mockResolvedValue([
{ id: "user_1", name: "Alice", email: "alice@example.com" },
]);
const findMany = vi
.fn()
.mockResolvedValue([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
const result = await listAssignableUsers(createContext({
user: { findMany },
}));
const result = await listAssignableUsers(
createContext({
user: { findMany },
}),
);
expect(result).toEqual([{ id: "user_1", name: "Alice", email: "alice@example.com" }]);
expect(findMany).toHaveBeenCalledWith({
@@ -56,12 +58,16 @@ describe("user-procedure-support", () => {
});
it("counts only users active within the trailing five minute window", async () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
const nowSpy = vi
.spyOn(Date, "now")
.mockReturnValue(new Date("2026-03-30T20:00:00.000Z").valueOf());
const count = vi.fn().mockResolvedValue(4);
const result = await countActiveUsers(createContext({
user: { count },
}));
const result = await countActiveUsers(
createContext({
user: { count },
}),
);
expect(result).toEqual({ count: 4 });
expect(count).toHaveBeenCalledWith({
@@ -80,9 +86,11 @@ describe("user-procedure-support", () => {
createdAt: new Date("2026-03-30T08:00:00.000Z"),
});
const result = await getCurrentUserProfile(createContext({
user: { findUnique },
}));
const result = await getCurrentUserProfile(
createContext({
user: { findUnique },
}),
);
expect(result).toEqual({
id: "user_admin",
@@ -108,17 +116,21 @@ describe("user-procedure-support", () => {
it("unlinks an existing resource before linking the requested one", async () => {
const userFindUnique = vi.fn().mockResolvedValue({ id: "user_1" });
const resourceFindUnique = vi.fn().mockResolvedValue({ id: "resource_1", userId: null });
const updateMany = vi.fn()
const updateMany = vi
.fn()
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 1 });
const result = await linkUserResource(createContext({
user: { findUnique: userFindUnique },
resource: { findUnique: resourceFindUnique, updateMany },
}), {
userId: "user_1",
resourceId: "resource_1",
});
const result = await linkUserResource(
createContext({
user: { findUnique: userFindUnique },
resource: { findUnique: resourceFindUnique, updateMany },
}),
{
userId: "user_1",
resourceId: "resource_1",
},
);
expect(result).toEqual({ success: true });
expect(updateMany).toHaveBeenNthCalledWith(1, {
@@ -142,9 +154,11 @@ describe("user-procedure-support", () => {
updatedAt: new Date("2026-03-30T18:00:00.000Z"),
});
const result = await getDashboardLayout(createContext({
user: { findUnique },
}));
const result = await getDashboardLayout(
createContext({
user: { findUnique },
}),
);
// Widgets with unknown types normalise to empty → return null so client uses default
expect(result).toEqual({
@@ -159,11 +173,14 @@ describe("user-procedure-support", () => {
});
const update = vi.fn().mockResolvedValue({});
const result = await toggleFavoriteProject(createContext({
user: { findUnique, update },
}), {
projectId: "project_2",
});
const result = await toggleFavoriteProject(
createContext({
user: { findUnique, update },
}),
{
projectId: "project_2",
},
);
expect(result).toEqual({
favoriteProjectIds: ["project_1", "project_2"],
@@ -187,12 +204,15 @@ describe("user-procedure-support", () => {
});
const update = vi.fn().mockResolvedValue({ id: "user_admin" });
const result = await setColumnPreferences(createContext({
user: { findUnique, update },
}), {
view: "resources",
visible: ["name", "email"],
});
const result = await setColumnPreferences(
createContext({
user: { findUnique, update },
}),
{
view: "resources",
visible: ["name", "email"],
},
);
expect(result).toEqual({ ok: true });
expect(update).toHaveBeenCalledWith({
@@ -220,11 +240,14 @@ describe("user-procedure-support", () => {
permissionOverrides: overrides,
});
const result = await getEffectiveUserPermissions(createContext({
user: { findUnique },
}), {
userId: "user_2",
});
const result = await getEffectiveUserPermissions(
createContext({
user: { findUnique },
}),
{
userId: "user_2",
},
);
expect(result).toEqual({
systemRole: SystemRole.MANAGER,
@@ -234,14 +257,20 @@ describe("user-procedure-support", () => {
});
it("reports MFA status for the current user and throws when the user no longer exists", async () => {
const findUnique = vi.fn()
const findUnique = vi
.fn()
.mockResolvedValueOnce({ totpEnabled: true })
.mockResolvedValueOnce(null);
const count = vi.fn().mockResolvedValue(7);
const ctx = createContext({
user: { findUnique },
mfaBackupCode: { count },
});
await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({ totpEnabled: true });
await expect(getCurrentMfaStatus(ctx)).resolves.toEqual({
totpEnabled: true,
backupCodesRemaining: 7,
});
await expect(getCurrentMfaStatus(ctx)).rejects.toMatchObject({
code: "NOT_FOUND",
message: "User not found",
+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 },
@@ -61,6 +61,7 @@ import {
verifyAndEnableTotp,
verifyTotp,
getCurrentMfaStatus,
regenerateBackupCodes,
} from "../router/user-self-service-procedure-support.js";
// ─── context helpers ─────────────────────────────────────────────────────────
@@ -74,10 +75,17 @@ function makeSelfServiceCtx(dbOverrides: Record<string, unknown> = {}) {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
...((dbOverrides.user as object | undefined) ?? {}),
},
mfaBackupCode: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 10 }),
count: vi.fn().mockResolvedValue(0),
...((dbOverrides.mfaBackupCode as object | undefined) ?? {}),
},
auditLog: {
create: vi.fn().mockResolvedValue({ id: "audit_1" }),
...((dbOverrides.auditLog as object | undefined) ?? {}),
},
$transaction: vi.fn(async (ops: unknown[]) => ops),
},
dbUser: { id: "user_1", systemRole: "ADMIN" as const, permissionOverrides: null },
session: {
@@ -145,7 +153,7 @@ describe("verifyAndEnableTotp", () => {
totpEnabled: false,
};
it("enables TOTP and returns { enabled: true } when token is valid", async () => {
it("enables TOTP and returns backup codes when token is valid", async () => {
totpValidateMock.mockReturnValue(0); // delta 0 = current window
const ctx = makeSelfServiceCtx({
user: { findUnique: vi.fn().mockResolvedValue(baseUser) },
@@ -153,7 +161,12 @@ describe("verifyAndEnableTotp", () => {
const result = await verifyAndEnableTotp(ctx as Parameters<typeof verifyAndEnableTotp>[0], {
token: "123456",
});
expect(result).toEqual({ enabled: true });
expect(result.enabled).toBe(true);
expect(result.backupCodes).toHaveLength(10);
// Codes have the XXXXX-XXXXX shape (10 Crockford-base32 chars + one dash)
for (const code of result.backupCodes) {
expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
}
expect(ctx.db.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ data: { lastTotpAt: expect.any(Date) } }),
);
@@ -161,6 +174,17 @@ describe("verifyAndEnableTotp", () => {
where: { id: "user_1" },
data: { totpEnabled: true },
});
// Exactly 10 backup code rows are created in a transaction
expect(ctx.db.$transaction).toHaveBeenCalledTimes(1);
expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
const createCall = ctx.db.mfaBackupCode.createMany.mock.calls[0]![0] as {
data: Array<{ userId: string; codeHash: string }>;
};
expect(createCall.data).toHaveLength(10);
for (const row of createCall.data) {
expect(row.userId).toBe("user_1");
expect(row.codeHash.length).toBeGreaterThan(50); // argon2id encoded form
}
});
it("throws BAD_REQUEST when token is invalid", async () => {
@@ -314,19 +338,87 @@ describe("getCurrentMfaStatus", () => {
vi.clearAllMocks();
});
it("returns totpEnabled: true when MFA is active", async () => {
it("returns totpEnabled and backupCodesRemaining when MFA is active", async () => {
const ctx = makeSelfServiceCtx({
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: true }) },
mfaBackupCode: {
count: vi.fn().mockResolvedValue(7),
deleteMany: vi.fn(),
createMany: vi.fn(),
},
});
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
expect(result).toEqual({ totpEnabled: true });
expect(result).toEqual({ totpEnabled: true, backupCodesRemaining: 7 });
});
it("returns totpEnabled: false when MFA is inactive", async () => {
it("returns backupCodesRemaining: 0 when MFA is inactive (skips DB count)", async () => {
const countMock = vi.fn();
const ctx = makeSelfServiceCtx({
user: { findUnique: vi.fn().mockResolvedValue({ totpEnabled: false }) },
mfaBackupCode: { count: countMock, deleteMany: vi.fn(), createMany: vi.fn() },
});
const result = await getCurrentMfaStatus(ctx as Parameters<typeof getCurrentMfaStatus>[0]);
expect(result).toEqual({ totpEnabled: false });
expect(result).toEqual({ totpEnabled: false, backupCodesRemaining: 0 });
expect(countMock).not.toHaveBeenCalled();
});
});
// ─── regenerateBackupCodes ────────────────────────────────────────────────────
describe("regenerateBackupCodes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("throws BAD_REQUEST when TOTP is not enabled", async () => {
const ctx = makeSelfServiceCtx({
user: {
findUnique: vi.fn().mockResolvedValue({
id: "user_1",
name: "Test User",
email: "test@example.com",
totpEnabled: false,
}),
},
});
await expect(
regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]),
).rejects.toThrow(TRPCError);
expect(ctx.db.$transaction).not.toHaveBeenCalled();
});
it("wipes previous codes and issues a fresh set atomically", async () => {
const ctx = makeSelfServiceCtx({
user: {
findUnique: vi.fn().mockResolvedValue({
id: "user_1",
name: "Test User",
email: "test@example.com",
totpEnabled: true,
}),
},
});
const result = await regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]);
expect(result.count).toBe(10);
expect(result.codes).toHaveLength(10);
expect(new Set(result.codes).size).toBe(10); // all distinct
expect(ctx.db.$transaction).toHaveBeenCalledTimes(1);
expect(ctx.db.mfaBackupCode.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
});
it("writes an audit entry on regeneration", async () => {
const ctx = makeSelfServiceCtx({
user: {
findUnique: vi.fn().mockResolvedValue({
id: "user_1",
name: "Test User",
email: "test@example.com",
totpEnabled: true,
}),
},
});
await regenerateBackupCodes(ctx as Parameters<typeof regenerateBackupCodes>[0]);
await new Promise((r) => setTimeout(r, 0));
expect(ctx.db.auditLog.create).toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
import { verifyBackupCode } from "./mfa-backup-codes.js";
// Redeem a backup code atomically. The flow is:
//
// 1. Load all still-redeemable rows (usedAt IS NULL) for the user.
// 2. Linear-scan with argon2 verify until one matches. Hashes are
// expensive by design — 10 candidates max is fine, and the cost is
// the user's own memory-hard-hash budget, not an attacker-chosen one.
// 3. The matching row is deleted under a WHERE-guard on (id, usedAt IS
// NULL). Count=0 means another request consumed the same code first
// (replay race); the caller treats it as an invalid code.
//
// Deleting (vs marking `usedAt`) keeps the table small and makes post-
// compromise forensics simpler — a used code is an absence, not a
// still-present-but-tombstoned row that could be reactivated via SQL
// injection or bad migration.
//
// Returned `remaining` lets the UI warn "3 backup codes left — generate
// more" without a second round-trip.
interface BackupCodeRow {
id: string;
codeHash: string;
}
interface RedeemDb {
mfaBackupCode: {
findMany: (args: {
where: { userId: string; usedAt: null };
select: { id: true; codeHash: true };
}) => Promise<BackupCodeRow[]>;
deleteMany: (args: { where: { id: string; usedAt: null } }) => Promise<{ count: number }>;
count: (args: { where: { userId: string; usedAt: null } }) => Promise<number>;
};
}
export interface RedeemResult {
accepted: boolean;
remaining: number;
}
export async function redeemBackupCode(
db: { mfaBackupCode: unknown },
userId: string,
plaintext: string,
): Promise<RedeemResult> {
const typed = db as unknown as RedeemDb;
const rows = await typed.mfaBackupCode.findMany({
where: { userId, usedAt: null },
select: { id: true, codeHash: true },
});
for (const row of rows) {
if (!(await verifyBackupCode(row.codeHash, plaintext))) continue;
const del = await typed.mfaBackupCode.deleteMany({
where: { id: row.id, usedAt: null },
});
if (del.count === 0) {
// Raced — another request consumed this same code. Treat as invalid
// so the attacker cannot learn it was valid; an honest user retries
// with a fresh code.
return {
accepted: false,
remaining: await typed.mfaBackupCode.count({ where: { userId, usedAt: null } }),
};
}
const remaining = await typed.mfaBackupCode.count({ where: { userId, usedAt: null } });
return { accepted: true, remaining };
}
return { accepted: false, remaining: rows.length };
}
+55
View File
@@ -0,0 +1,55 @@
import { randomBytes } from "node:crypto";
import { hash, verify } from "@node-rs/argon2";
// Backup codes are the last-resort credential when a user loses their TOTP
// device. Design constraints:
//
// 1. High entropy but human-typeable. 10 chars of Crockford-base32 =
// 50 bits — well above the 20-bit floor that brute-force-proofs the
// 6 codes/15 min rate limit (2^20 / (6/900) ≈ 5000 years average).
// 2. Never logged or stored in plaintext. We hash with argon2id (same
// hasher as passwords) and delete the row on redemption, so replay is
// physically impossible even if the DB leaks post-redemption.
// 3. One-shot visibility. Plaintext is returned exactly once from the
// generate mutation — re-display is not supported; lost codes must be
// regenerated, which invalidates the full set.
//
// The formatted shape (XXXXX-XXXXX) is cosmetic only; validation strips the
// dash so users can paste either form.
export const BACKUP_CODE_COUNT = 10;
const CODE_LENGTH = 10; // chars, pre-dash
// Crockford base32 alphabet: no 0/O/1/I/L to avoid transcription errors.
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
export function generatePlaintextBackupCodes(count: number = BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const bytes = randomBytes(CODE_LENGTH);
let code = "";
for (let j = 0; j < CODE_LENGTH; j++) {
code += ALPHABET[bytes[j]! % ALPHABET.length];
}
codes.push(`${code.slice(0, 5)}-${code.slice(5)}`);
}
return codes;
}
// Users may paste the code with or without the dash, and in either case;
// store and compare the canonical form (uppercase, no dash, no whitespace)
// so accidental formatting does not reject an otherwise-valid code.
export function normalizeBackupCode(input: string): string {
return input.replace(/[\s-]+/g, "").toUpperCase();
}
export async function hashBackupCode(plaintext: string): Promise<string> {
return hash(normalizeBackupCode(plaintext));
}
export async function verifyBackupCode(codeHash: string, plaintext: string): Promise<boolean> {
try {
return await verify(codeHash, normalizeBackupCode(plaintext));
} catch {
return false;
}
}
@@ -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)),
});
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS "mfa_backup_codes" (
"id" TEXT PRIMARY KEY,
"userId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "mfa_backup_codes_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "mfa_backup_codes_userId_idx"
ON "mfa_backup_codes"("userId");
+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