/** * Have-I-Been-Pwned password lookup using `k`-anonymity. * * Sends only the first five hex characters of the SHA-1 hash to the HIBP * API. The full hash and plaintext never leave the device. Designed to be * called from a debounced password-input change handler — see `debounce` * below. */ import { Platform } from "react-native"; import * as Crypto from "expo-crypto"; import { fetchWithTimeout } from "../utils/fetch"; import { getCachedPolicy, type HibpPolicy } from "./hibp-cache"; export interface PwnedPasswordResult { /** `true` = breached, `false` = safe, `null` = check failed (unknown). */ isPwned: boolean | null; count: number; error?: string; } export async function checkPasswordPwned(password: string): Promise { try { const hash = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA1, password, { encoding: Crypto.CryptoEncoding.HEX, }); const hashUpperCase = hash.toUpperCase(); const prefix = hashUpperCase.substring(0, 5); const suffix = hashUpperCase.substring(5); const response = await fetchWithTimeout( `https://api.pwnedpasswords.com/range/${prefix}`, { method: "GET", headers: { // User-Agent is a forbidden header in browsers and cannot be set // via fetch — the browser sends its own. On native, expo handles it. ...(Platform.OS !== "web" ? { "User-Agent": "Entropy Softworks Client" } : {}), "Add-Padding": "true", }, }, 10_000 ); if (!response.ok) { throw new Error(`HIBP API returned status ${response.status}`); } const text = await response.text(); const hashes = text.split("\r\n").filter((line) => line.length > 0); for (const hashLine of hashes) { const [hashSuffix, count] = hashLine.split(":"); if (hashSuffix === suffix && count) { return { isPwned: true, count: parseInt(count, 10) }; } } return { isPwned: false, count: 0 }; } catch { return { isPwned: null, count: 0, error: "Unable to verify password security. Please try again later.", }; } } export interface PwnedMessage { title: string; message: string; severity: "error" | "warning" | "info"; } /** * Build a user-friendly message for a HIBP result. The policy comes from * the shared cache populated by the consumer (see `hibp-cache.ts`). If no * policy is cached, falls back to a non-enforcing default. */ export function getPwnedMessage(result: PwnedPasswordResult): PwnedMessage { if (result.error) { return { title: "Unable to Verify", message: result.error, severity: "info", }; } if (result.isPwned === false) { return { title: "Password Is Safe", message: "This password has not been found in any known data breaches.", severity: "info", }; } const policy: HibpPolicy = getCachedPolicy() ?? { enabled: true, enforce: false, block_threshold: 0, }; if (policy.enforce && result.count > policy.block_threshold) { return { title: "Password Compromised!", message: `This password has appeared in ${result.count.toLocaleString()} known data breaches. Please choose a different password.`, severity: "error", }; } return { title: "Password Found in Breach", message: `This password has appeared ${result.count.toLocaleString()} time${result.count > 1 ? "s" : ""} in data breaches. Consider choosing a different password.`, severity: "warning", }; } // `debounce` moved to `utils/debounce`; re-exported here for back-compat // with existing `@entropy-softworks/ui/services/hibp` consumers. export { debounce } from "../utils/debounce";