diff --git a/messages/de.json b/messages/de.json index 523158b31..8f7d31981 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 16ec52b02..5da217315 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 8bea74277..4548ca6b3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index d943773ff..fe6010b7f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 38c204fbb..8b3867b5c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 566625aa1..0232877e1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index add5d7cd5..2da8adf47 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -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 }, diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 828304e8a..38cf11b42 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -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 }, diff --git a/src/lib/auth/__tests__/hibp.test.ts b/src/lib/auth/__tests__/hibp.test.ts new file mode 100644 index 000000000..5d81bd3da --- /dev/null +++ b/src/lib/auth/__tests__/hibp.test.ts @@ -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)["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(); + }); +}); diff --git a/src/lib/auth/hibp.ts b/src/lib/auth/hibp.ts new file mode 100644 index 000000000..6bc7ec6df --- /dev/null +++ b/src/lib/auth/hibp.ts @@ -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 { + 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>:". 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; + } +}