auth-breach-detection.ts82 lines · main
1import { createHash } from 'node:crypto';
2
3import { log } from '../lib/logger.js';
4
5/**
6 * Password breach detection via Have I Been Pwned (HIBP) k-anonymity API.
7 *
8 * When enabled per tenant, every password set during sign-up or password
9 * change is checked against the HIBP breach database. The check uses the
10 * k-anonymity protocol: we send only the first 5 hex chars of the SHA-1
11 * hash, and HIBP returns suffixes + breach counts. No full password or
12 * full hash ever leaves our servers.
13 *
14 * Reference: https://haveibeenpwned.com/API/v3#SearchingPwnedPasswordsByRange
15 */
16
17const HIBP_API_URL = 'https://api.pwnedpasswords.com/range';
18
19function sha1Prefix(password: string): string {
20 const hash = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
21 return hash.slice(0, 5);
22}
23
24function sha1Suffix(password: string): string {
25 const hash = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
26 return hash.slice(5);
27}
28
29export interface BreachCheckResult {
30 breached: boolean;
31 /** How many times this password appears in known breaches. 0 if not breached. */
32 breachCount: number;
33}
34
35/**
36 * Check whether a password has been breached. Returns `{breached:false}`
37 * on any network or parsing error (fail-open so HIBP downtime doesn't
38 * break sign-ups).
39 */
40export async function checkPasswordBreach(password: string): Promise<BreachCheckResult> {
41 const prefix = sha1Prefix(password);
42 const suffix = sha1Suffix(password);
43
44 try {
45 const controller = new AbortController();
46 const timeout = setTimeout(() => controller.abort(), 5000);
47
48 const response = await fetch(`${HIBP_API_URL}/${prefix}`, {
49 method: 'GET',
50 headers: {
51 'Add-Padding': 'true',
52 },
53 signal: controller.signal,
54 });
55
56 clearTimeout(timeout);
57
58 if (!response.ok) {
59 log.warn('hibp_api_error', { status: response.status });
60 return { breached: false, breachCount: 0 };
61 }
62
63 const text = await response.text();
64 const lines = text.split('\n');
65
66 for (const line of lines) {
67 const [lineSuffix, countStr] = line.split(':');
68 if (lineSuffix?.trim() === suffix) {
69 const count = parseInt(countStr?.trim() ?? '0', 10);
70 return { breached: true, breachCount: count };
71 }
72 }
73
74 return { breached: false, breachCount: 0 };
75 } catch (err) {
76 log.warn('hibp_check_failed', {
77 message: err instanceof Error ? err.message : String(err),
78 });
79 // Fail-open: don't block sign-ups because HIBP is unreachable.
80 return { breached: false, breachCount: 0 };
81 }
82}