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

+39 -3
View File
@@ -263,7 +263,8 @@
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
"passwordMinLength": "Min. 12 Zeichen",
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
"privacyPolicy": "Datenschutzerklärung"
"privacyPolicy": "Datenschutzerklärung",
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes."
},
"dashboard": {
"title": "Dashboard",
@@ -4968,7 +4969,37 @@
"polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
"security": {
"sessionsTitle": "Aktive Sitzungen",
"sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.",
"sessionsLoadError": "Aktive Sitzungen konnten nicht geladen werden.",
"sessionsEmpty": "Keine aktiven Sitzungen.",
"currentSession": "Dieses Gerät",
"lastActive": "Zuletzt aktiv {time}",
"unknownLocation": "Unbekannter Ort",
"revokeSession": "Abmelden",
"revokeError": "Anfrage konnte nicht abgeschlossen werden. Bitte erneut versuchen.",
"signOutEverywhere": "Überall sonst abmelden",
"signOutEverywhereDone": "{count} andere Sitzung(en) abgemeldet.",
"activityTitle": "Sicherheitsaktivität",
"activityDescription": "Letzte Anmeldungen, Sicherheitsänderungen und Datenexporte deines Kontos.",
"activityEmpty": "Noch keine Sicherheitsaktivität.",
"activityLoadError": "Sicherheitsaktivität konnte nicht geladen werden.",
"action": {
"newDevice": "Anmeldung von neuem Gerät",
"loginFailed": "Fehlgeschlagene Anmeldung",
"login": "Anmeldung",
"passwordChange": "Passwort geändert",
"mfa": "Zwei-Faktor-Änderung",
"sessionRevoke": "Sitzung abgemeldet",
"token": "API-Token-Änderung",
"accountDelete": "Konto gelöscht",
"dataClear": "Daten zurückgesetzt",
"export": "Datenexport",
"other": "Sicherheitsereignis"
}
}
},
"admin": {
"title": "Administration",
@@ -5955,7 +5986,12 @@
"eventMeasurementReminder": "Mess-Erinnerung",
"eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.",
"eventMedicationIntakeSync": "Einnahme-Synchronisierung",
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird."
"eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.",
"security": {
"newDeviceTitle": "Neue Anmeldung bei deinem Konto",
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
"unknownLocation": "unbekannter Ort"
}
},
"i18n": {
"maintainershipBanner": {
+39 -3
View File
@@ -263,7 +263,8 @@
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
"passwordMinLength": "Min. 12 characters",
"passwordTooShort": "Password must be at least {minLength} characters long.",
"privacyPolicy": "Privacy policy"
"privacyPolicy": "Privacy policy",
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one."
},
"dashboard": {
"title": "Dashboard",
@@ -4968,7 +4969,37 @@
"polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
"security": {
"sessionsTitle": "Active sessions",
"sessionsDescription": "Devices and browsers currently signed in to your account.",
"sessionsLoadError": "Active sessions could not be loaded.",
"sessionsEmpty": "No active sessions.",
"currentSession": "This device",
"lastActive": "Last active {time}",
"unknownLocation": "Unknown location",
"revokeSession": "Sign out",
"revokeError": "Could not complete the request. Please try again.",
"signOutEverywhere": "Sign out everywhere else",
"signOutEverywhereDone": "Signed out {count} other session(s).",
"activityTitle": "Security activity",
"activityDescription": "Recent sign-ins, security changes, and data exports on your account.",
"activityEmpty": "No security activity yet.",
"activityLoadError": "Security activity could not be loaded.",
"action": {
"newDevice": "New-device sign-in",
"loginFailed": "Failed sign-in",
"login": "Sign-in",
"passwordChange": "Password changed",
"mfa": "Two-factor change",
"sessionRevoke": "Session signed out",
"token": "API token change",
"accountDelete": "Account deleted",
"dataClear": "Data reset",
"export": "Data export",
"other": "Security event"
}
}
},
"admin": {
"title": "Administration",
@@ -5955,7 +5986,12 @@
"eventMeasurementReminder": "Measurement reminder",
"eventMeasurementReminderDesc": "Sent when a tracked measurement is due.",
"eventMedicationIntakeSync": "Medication intake sync",
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped."
"eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.",
"security": {
"newDeviceTitle": "New sign-in to your account",
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
"unknownLocation": "unknown location"
}
},
"i18n": {
"maintainershipBanner": {
+39 -3
View File
@@ -263,7 +263,8 @@
"passkeySetupLater": "Puedes configurar passkeys más tarde en los ajustes.",
"passwordMinLength": "Mín. 12 caracteres",
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
"privacyPolicy": "Política de privacidad"
"privacyPolicy": "Política de privacidad",
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra."
},
"dashboard": {
"title": "Panel",
@@ -4968,7 +4969,37 @@
"polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
"security": {
"sessionsTitle": "Sesiones activas",
"sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.",
"sessionsLoadError": "No se pudieron cargar las sesiones activas.",
"sessionsEmpty": "No hay sesiones activas.",
"currentSession": "Este dispositivo",
"lastActive": "Última actividad {time}",
"unknownLocation": "Ubicación desconocida",
"revokeSession": "Cerrar sesión",
"revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.",
"signOutEverywhere": "Cerrar sesión en los demás dispositivos",
"signOutEverywhereDone": "Se cerraron {count} sesión(es).",
"activityTitle": "Actividad de seguridad",
"activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.",
"activityEmpty": "Aún no hay actividad de seguridad.",
"activityLoadError": "No se pudo cargar la actividad de seguridad.",
"action": {
"newDevice": "Inicio desde nuevo dispositivo",
"loginFailed": "Inicio de sesión fallido",
"login": "Inicio de sesión",
"passwordChange": "Contraseña cambiada",
"mfa": "Cambio de doble factor",
"sessionRevoke": "Sesión cerrada",
"token": "Cambio de token de API",
"accountDelete": "Cuenta eliminada",
"dataClear": "Datos restablecidos",
"export": "Exportación de datos",
"other": "Evento de seguridad"
}
}
},
"admin": {
"title": "Administration",
@@ -5955,7 +5986,12 @@
"eventMeasurementReminder": "Recordatorio de medición",
"eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.",
"eventMedicationIntakeSync": "Sincronización de tomas",
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis."
"eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.",
"security": {
"newDeviceTitle": "Nuevo inicio de sesión en tu cuenta",
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
"unknownLocation": "ubicación desconocida"
}
},
"i18n": {
"maintainershipBanner": {
+39 -3
View File
@@ -263,7 +263,8 @@
"passkeySetupLater": "Tu peux configurer les passkeys plus tard dans les paramètres.",
"passwordMinLength": "Min. 12 caractères",
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
"privacyPolicy": "Politique de confidentialité"
"privacyPolicy": "Politique de confidentialité",
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre."
},
"dashboard": {
"title": "Tableau de bord",
@@ -4968,7 +4969,37 @@
"polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
"security": {
"sessionsTitle": "Sessions actives",
"sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.",
"sessionsLoadError": "Impossible de charger les sessions actives.",
"sessionsEmpty": "Aucune session active.",
"currentSession": "Cet appareil",
"lastActive": "Dernière activité {time}",
"unknownLocation": "Lieu inconnu",
"revokeSession": "Déconnecter",
"revokeError": "Impossible de traiter la demande. Veuillez réessayer.",
"signOutEverywhere": "Se déconnecter partout ailleurs",
"signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).",
"activityTitle": "Activité de sécurité",
"activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.",
"activityEmpty": "Aucune activité de sécurité pour l'instant.",
"activityLoadError": "Impossible de charger l'activité de sécurité.",
"action": {
"newDevice": "Connexion depuis un nouvel appareil",
"loginFailed": "Échec de connexion",
"login": "Connexion",
"passwordChange": "Mot de passe modifié",
"mfa": "Modification de la double authentification",
"sessionRevoke": "Session déconnectée",
"token": "Modification de jeton API",
"accountDelete": "Compte supprimé",
"dataClear": "Données réinitialisées",
"export": "Export de données",
"other": "Événement de sécurité"
}
}
},
"admin": {
"title": "Administration",
@@ -5955,7 +5986,12 @@
"eventMeasurementReminder": "Rappel de mesure",
"eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.",
"eventMedicationIntakeSync": "Synchronisation des prises",
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée."
"eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.",
"security": {
"newDeviceTitle": "Nouvelle connexion à votre compte",
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
"unknownLocation": "lieu inconnu"
}
},
"i18n": {
"maintainershipBanner": {
+39 -3
View File
@@ -263,7 +263,8 @@
"passkeySetupLater": "Puoi configurare le passkey più tardi dalle impostazioni.",
"passwordMinLength": "Min. 12 caratteri",
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
"privacyPolicy": "Informativa sulla privacy"
"privacyPolicy": "Informativa sulla privacy",
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra."
},
"dashboard": {
"title": "Cruscotto",
@@ -4968,7 +4969,37 @@
"polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
"security": {
"sessionsTitle": "Sessioni attive",
"sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.",
"sessionsLoadError": "Impossibile caricare le sessioni attive.",
"sessionsEmpty": "Nessuna sessione attiva.",
"currentSession": "Questo dispositivo",
"lastActive": "Ultima attività {time}",
"unknownLocation": "Posizione sconosciuta",
"revokeSession": "Disconnetti",
"revokeError": "Impossibile completare la richiesta. Riprova.",
"signOutEverywhere": "Disconnetti ovunque",
"signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.",
"activityTitle": "Attività di sicurezza",
"activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.",
"activityEmpty": "Nessuna attività di sicurezza.",
"activityLoadError": "Impossibile caricare l'attività di sicurezza.",
"action": {
"newDevice": "Accesso da nuovo dispositivo",
"loginFailed": "Accesso non riuscito",
"login": "Accesso",
"passwordChange": "Password modificata",
"mfa": "Modifica autenticazione a due fattori",
"sessionRevoke": "Sessione disconnessa",
"token": "Modifica token API",
"accountDelete": "Account eliminato",
"dataClear": "Dati azzerati",
"export": "Esportazione dati",
"other": "Evento di sicurezza"
}
}
},
"admin": {
"title": "Administration",
@@ -5955,7 +5986,12 @@
"eventMeasurementReminder": "Promemoria misurazione",
"eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.",
"eventMedicationIntakeSync": "Sincronizzazione assunzioni",
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata."
"eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.",
"security": {
"newDeviceTitle": "Nuovo accesso al tuo account",
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
"unknownLocation": "posizione sconosciuta"
}
},
"i18n": {
"maintainershipBanner": {
+39 -3
View File
@@ -263,7 +263,8 @@
"passkeySetupLater": "Passkey możesz skonfigurować później w ustawieniach.",
"passwordMinLength": "Min. 12 znaków",
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
"privacyPolicy": "Polityka prywatności"
"privacyPolicy": "Polityka prywatności",
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne."
},
"dashboard": {
"title": "Pulpit",
@@ -4968,7 +4969,37 @@
"polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
"security": {
"sessionsTitle": "Aktywne sesje",
"sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.",
"sessionsLoadError": "Nie udało się załadować aktywnych sesji.",
"sessionsEmpty": "Brak aktywnych sesji.",
"currentSession": "To urządzenie",
"lastActive": "Ostatnia aktywność {time}",
"unknownLocation": "Nieznana lokalizacja",
"revokeSession": "Wyloguj",
"revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.",
"signOutEverywhere": "Wyloguj na pozostałych urządzeniach",
"signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.",
"activityTitle": "Aktywność zabezpieczeń",
"activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.",
"activityEmpty": "Brak aktywności zabezpieczeń.",
"activityLoadError": "Nie udało się załadować aktywności zabezpieczeń.",
"action": {
"newDevice": "Logowanie z nowego urządzenia",
"loginFailed": "Nieudane logowanie",
"login": "Logowanie",
"passwordChange": "Zmieniono hasło",
"mfa": "Zmiana uwierzytelniania dwuskładnikowego",
"sessionRevoke": "Wylogowano sesję",
"token": "Zmiana tokenu API",
"accountDelete": "Konto usunięte",
"dataClear": "Zresetowano dane",
"export": "Eksport danych",
"other": "Zdarzenie zabezpieczeń"
}
}
},
"admin": {
"title": "Administracja",
@@ -5955,7 +5986,12 @@
"eventMeasurementReminder": "Przypomnienie o pomiarze",
"eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.",
"eventMedicationIntakeSync": "Synchronizacja przyjęć leku",
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta."
"eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.",
"security": {
"newDeviceTitle": "Nowe logowanie do Twojego konta",
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
"unknownLocation": "nieznana lokalizacja"
}
},
"i18n": {
"maintainershipBanner": {
@@ -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;
}
}