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

@@ -0,0 +1,155 @@
/**
* Every login outcome reaches the verifier.
*
* The three refusals sign-in can produce — no such account, an account with
* no password hash, a wrong password — return the identical 401 body, and
* used to take three very different amounts of time to produce it, because
* the first two returned before any hashing happened. Argon2id at 19 MiB and
* t=2 against one indexed SELECT is a gap a caller can read over a network.
*
* The passkey-only account is the case that matters most. If it kept the
* cheap path, the channel would still answer "this account exists and has no
* password" — which is exactly what the removed discovery endpoint published
* in a field.
*
* What this proves is that the call happens on all three arms, not that the
* three durations match: the verifier is mocked here, and wall-clock equality
* is not something a unit test can assert. `password-dummy-verify.test.ts`
* carries the other half, that the verifier really does Argon2id work at the
* real cost parameters when there is no stored hash.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(() => "aa".repeat(32)),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "someone@example.com",
password: "a-typed-password",
}),
});
}
beforeEach(() => {
vi.mocked(verifyPasswordOrDummy).mockReset();
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
});
describe("POST /api/auth/login — constant work across the refusals", () => {
it("verifies even when no account matches the identifier", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1);
expect(verifyPasswordOrDummy).toHaveBeenCalledWith(
undefined,
"a-typed-password",
);
});
it("verifies for a passkey-only account, which carries no password hash", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-1",
passwordHash: null,
totpConfirmedAt: null,
mfaEnforced: false,
} as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1);
expect(verifyPasswordOrDummy).toHaveBeenCalledWith(
null,
"a-typed-password",
);
});
it("verifies for an account that does carry a password hash", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
id: "user-2",
passwordHash: "$argon2id$stored",
totpConfirmedAt: null,
mfaEnforced: false,
} as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1);
expect(verifyPasswordOrDummy).toHaveBeenCalledWith(
"$argon2id$stored",
"a-typed-password",
);
});
it("answers the three refusals with the identical status and body", async () => {
const bodies: string[] = [];
const statuses: number[] = [];
const accounts = [
null,
{ id: "user-1", passwordHash: null },
{ id: "user-2", passwordHash: "$argon2id$stored" },
];
for (const account of accounts) {
vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never);
const res = await POST(makeRequest());
statuses.push(res.status);
bodies.push(await res.text());
}
expect(new Set(statuses)).toEqual(new Set([401]));
expect(new Set(bodies).size).toBe(1);
});
});
@@ -14,7 +14,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn(),
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/mfa-enrollment", () => ({
@@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import {
checkRateLimit,
checkAuthSurfaceRateLimit,
@@ -108,7 +108,7 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
id: "rt-1",
@@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn(),
verifyPasswordOrDummy: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
@@ -71,7 +71,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import {
checkRateLimit,
@@ -93,7 +93,7 @@ function makeRequest(): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
vi.mocked(verifyPassword).mockResolvedValue(false);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
@@ -175,7 +175,7 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
const res = await POST(makeRequest());
// Falls through to normal password verification (which fails here
// since verifyPassword is mocked to return false) rather than 403 —
// since verifyPasswordOrDummy is mocked to return false) rather than 403 —
// a half-set OIDC group must never lock everyone out.
expect(res.status).toBe(401);
});
@@ -8,7 +8,7 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ verifyPasswordOrDummy: vi.fn() }));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn(async () => new Response(null, { status: 200 })),
@@ -58,7 +58,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
@@ -88,7 +88,7 @@ beforeEach(() => {
describe("POST /api/auth/login — trusted device", () => {
it("skips the second factor when the device is trusted (no challenge minted)", async () => {
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true);
vi.mocked(consumeTrustedDevice).mockResolvedValue(true);
const res = await POST(req());
@@ -103,7 +103,7 @@ describe("POST /api/auth/login — trusted device", () => {
});
it("falls back to the MFA challenge when the device is not trusted", async () => {
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true);
vi.mocked(consumeTrustedDevice).mockResolvedValue(false);
const res = await POST(req());
@@ -115,7 +115,7 @@ describe("POST /api/auth/login — trusted device", () => {
});
it("still requires the password — a wrong password never consults the trusted device", async () => {
vi.mocked(verifyPassword).mockResolvedValue(false);
vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false);
const res = await POST(req());
expect(res.status).toBe(401);
+13 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { verifyPasswordOrDummy } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
@@ -84,6 +84,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// One verification for every outcome. An unknown identifier and an
// account carrying no password hash (a passkey-only account) used to
// return here before any hashing, so the three answers cost one indexed
// SELECT, one indexed SELECT, and a full Argon2id verification
// respectively — identical bodies, three different durations, which is a
// readable answer to "is this address registered, and does it have a
// password". `verifyPasswordOrDummy` verifies against a fixed dummy hash
// minted at the same cost parameters when there is no stored hash, and
// returns false. Keep the call ahead of the branch: moving it back under
// an `if` restores the channel.
const valid = await verifyPasswordOrDummy(user?.passwordHash, password);
if (!user || !user.passwordHash) {
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
// the audit row — `reason` already tells the operator what
@@ -98,7 +110,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid credentials", 401);
}
const valid = await verifyPassword(user.passwordHash, password);
if (!valid) {
await auditLog("auth.login.failed", {
userId: user.id,