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