feat(auth): warn on breached passwords at password set

Check a newly chosen password against HaveIBeenPwned through the k-anonymity
range API: only the first five hex characters of the SHA-1 digest leave the
host, the suffix is matched locally, and Add-Padding hides the bucket size.
A confirmed match is rejected so a fresh credential is never a known-breached
one; the check fails open, so an unreachable HIBP never blocks the operation.
Covers registration, password change, and admin reset. Existing passwords are
never retroactively blocked at login.

Also lands the six-locale strings for the password-breach notice and the
account-security surfaces that follow.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:20 +02:00
1 parent 01689f77bc
commit b882698a44
10 files changed
+427 -18

No files matched your search

@@ -13,6 +13,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(
async (
@@ -65,6 +67,16 @@ export const POST = apiHandler(
);
}
// v1.23 — reject an admin-set password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
+14
View File
@@ -19,6 +19,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -73,6 +75,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
// v1.23 — reject a newly chosen password that appears in a known breach
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
// never blocks the change. Only the user's chosen NEW password is checked —
// existing credentials are never retroactively blocked at login.
const breach = await checkPasswordBreach(newPassword);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const newHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: user.id },
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHash } from "node:crypto";
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({ getEvent: () => null }));
import { checkPasswordBreach } from "../hibp";
import { safeFetch } from "@/lib/safe-fetch";
const PASSWORD = "correct horse battery staple";
const DIGEST = createHash("sha1")
.update(PASSWORD, "utf8")
.digest("hex")
.toUpperCase();
const PREFIX = DIGEST.slice(0, 5);
const SUFFIX = DIGEST.slice(5);
function textResponse(body: string, ok = true): Response {
return {
ok,
text: async () => body,
} as unknown as Response;
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("checkPasswordBreach — k-anonymity", () => {
it("sends ONLY the 5-char prefix and never the full hash or suffix", async () => {
vi.mocked(safeFetch).mockResolvedValue(
textResponse(`${SUFFIX}:42\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:9`),
);
await checkPasswordBreach(PASSWORD);
const url = vi.mocked(safeFetch).mock.calls[0][0] as string;
expect(url).toBe(`https://api.pwnedpasswords.com/range/${PREFIX}`);
// The suffix and full digest must never leave the process.
expect(url).not.toContain(SUFFIX);
expect(url).not.toContain(DIGEST);
// Only 5 hex chars after the range/ segment.
const sent = url.split("/range/")[1];
expect(sent).toHaveLength(5);
expect(sent).toMatch(/^[0-9A-F]{5}$/);
// Add-Padding header is set so the response size doesn't leak the bucket.
const init = vi.mocked(safeFetch).mock.calls[0][1] as RequestInit;
expect((init.headers as Record<string, string>)["Add-Padding"]).toBe(
"true",
);
// requirePublicHost is pinned.
const opts = vi.mocked(safeFetch).mock.calls[0][2] as {
requirePublicHost?: boolean;
};
expect(opts.requirePublicHost).toBe(true);
});
it("flags a breached password from a matching suffix", async () => {
vi.mocked(safeFetch).mockResolvedValue(textResponse(`${SUFFIX}:1337`));
const res = await checkPasswordBreach(PASSWORD);
expect(res).toEqual({ breached: true, count: 1337 });
});
it("matches the suffix case-insensitively", async () => {
vi.mocked(safeFetch).mockResolvedValue(
textResponse(`${SUFFIX.toLowerCase()}:5`),
);
const res = await checkPasswordBreach(PASSWORD);
expect(res?.breached).toBe(true);
});
it("reports not-breached when the suffix is absent", async () => {
vi.mocked(safeFetch).mockResolvedValue(
textResponse("0000000000000000000000000000000000A:3"),
);
const res = await checkPasswordBreach(PASSWORD);
expect(res).toEqual({ breached: false, count: 0 });
});
it("fails open (null) on a non-200 response", async () => {
vi.mocked(safeFetch).mockResolvedValue(textResponse("", false));
expect(await checkPasswordBreach(PASSWORD)).toBeNull();
});
it("fails open (null) when the request throws (HIBP down)", async () => {
vi.mocked(safeFetch).mockRejectedValue(new Error("network"));
expect(await checkPasswordBreach(PASSWORD)).toBeNull();
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* v1.23 — HaveIBeenPwned password-breach check via the k-anonymity range API.
*
* Privacy contract (the whole point of k-anonymity): the password is SHA-1'd
* locally and ONLY the first 5 hex characters of the uppercase digest leave the
* server. The remaining 35-character suffix is matched against the response
* locally; the full hash and the password itself never cross the network. The
* request carries `Add-Padding: true` so the response size does not leak which
* prefix bucket was queried.
*
* Resilience: any failure (egress disabled, HIBP down, timeout, non-200,
* malformed body) returns `null` — "unknown". Callers treat `null` as
* fail-open and never block on it; only a confirmed breach (`breached: true`)
* is actionable. The check is therefore safe to run inline at password-set
* time without making HIBP a hard dependency of account management.
*/
import { createHash } from "node:crypto";
import { safeFetch } from "@/lib/safe-fetch";
import { getEvent } from "@/lib/logging/context";
const HIBP_RANGE_URL = "https://api.pwnedpasswords.com/range/";
export interface BreachCheckResult {
breached: boolean;
/** Number of times the password appears in the corpus (0 when not found). */
count: number;
}
/**
* Returns the breach status of `password`, or `null` when the check could not
* be completed (fail-open). Never throws.
*/
export async function checkPasswordBreach(
password: string,
): Promise<BreachCheckResult | null> {
try {
const digest = createHash("sha1")
.update(password, "utf8")
.digest("hex")
.toUpperCase();
const prefix = digest.slice(0, 5);
const suffix = digest.slice(5);
const res = await safeFetch(
`${HIBP_RANGE_URL}${prefix}`,
{
method: "GET",
headers: { "Add-Padding": "true" },
signal: AbortSignal.timeout(4000),
},
// The host is a fixed, app-controlled public endpoint, but pin the
// public-host / DNS-rebinding guard anyway — it is a hard egress, and the
// SSRF floor is cheap.
{ requirePublicHost: true },
);
if (!res.ok) return null;
const body = await res.text();
// Each line: "<35-hex-suffix>:<count>". Compare case-insensitively.
for (const line of body.split("\n")) {
const sep = line.indexOf(":");
if (sep < 0) continue;
const lineSuffix = line.slice(0, sep).trim().toUpperCase();
if (lineSuffix === suffix) {
const count = parseInt(line.slice(sep + 1).trim(), 10);
return { breached: true, count: Number.isFinite(count) ? count : 1 };
}
}
return { breached: false, count: 0 };
} catch (err) {
getEvent()?.addWarning(
`HIBP breach check unavailable (fail-open): ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
}