Make every login refusal cost the same verification

Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.

`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 13:59:42 +02:00
1 parent 6ef139c7b2
commit 6a5fb338b1
8 files changed
+332 -17

No files matched your search

@@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
verifyPasswordOrDummy: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -142,8 +142,8 @@ describe("login MFA branch", () => {
});
it("wrong password never reaches the MFA branch", async () => {
const { verifyPassword } = await import("@/lib/auth/password");
vi.mocked(verifyPassword).mockResolvedValueOnce(false);
const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: new Date(),