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
@@ -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 });
});
});