fe79810a85
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>
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
/**
|
|
* 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 });
|
|
});
|
|
});
|