import crypto from 'crypto' const cache = globalThis.__HTC_HIBP_CACHE__ || new Map() globalThis.__HTC_HIBP_CACHE__ = cache function nowMs() { return Date.now() } function sha1UpperHex(input) { return crypto.createHash('sha1').update(String(input), 'utf8').digest('hex').toUpperCase() } function parseRangeResponse(text) { // Format: "SUFFIX:COUNT" per line const map = new Map() for (const line of String(text || '').split('\n')) { const trimmed = line.trim() if (!trimmed) continue const [suffix, count] = trimmed.split(':') if (suffix && count) map.set(suffix.trim().toUpperCase(), Number(count.trim()) || 0) } return map } async function fetchWithTimeout(url, { timeoutMs = 4000, headers = {} } = {}) { const ctrl = new AbortController() const t = setTimeout(() => ctrl.abort(), timeoutMs) try { return await fetch(url, { headers, signal: ctrl.signal }) } finally { clearTimeout(t) } } /** * Prüft Passwort gegen HIBP Pwned Passwords (k-Anonymity). * Gibt zurück: { pwned: boolean, count: number } */ export async function checkPasswordPwned(password) { const enabled = (process.env.HIBP_ENABLED || '').toLowerCase() === 'true' if (!enabled) return { pwned: false, count: 0 } const hash = sha1UpperHex(password) const prefix = hash.slice(0, 5) const suffix = hash.slice(5) // Cache pro Prefix (TTL) const ttlMs = Number(process.env.HIBP_CACHE_TTL_MS || 6 * 60 * 60 * 1000) // 6h const cached = cache.get(prefix) const now = nowMs() if (cached && cached.expiresAt > now && cached.map) { const count = cached.map.get(suffix) || 0 return { pwned: count > 0, count } } const ua = process.env.HIBP_USER_AGENT || 'harheimertc' const url = `https://api.pwnedpasswords.com/range/${prefix}` const res = await fetchWithTimeout(url, { timeoutMs: Number(process.env.HIBP_TIMEOUT_MS || 4000), headers: { 'User-Agent': ua, // HIBP empfiehlt optional diesen Header für Padding; wir schalten ihn per default ein. 'Add-Padding': 'true' } }) if (!res.ok) { const failClosed = (process.env.HIBP_FAIL_CLOSED || '').toLowerCase() === 'true' if (failClosed) { throw createError({ statusCode: 503, statusMessage: 'Passwortprüfung derzeit nicht verfügbar. Bitte später erneut versuchen.' }) } // fail-open return { pwned: false, count: 0 } } const text = await res.text() const map = parseRangeResponse(text) cache.set(prefix, { expiresAt: now + ttlMs, map }) const count = map.get(suffix) || 0 return { pwned: count > 0, count } } export async function assertPasswordNotPwned(password) { const { pwned } = await checkPasswordPwned(password) if (pwned) { throw createError({ statusCode: 400, message: 'Dieses Passwort wurde in bekannten Datenleaks gefunden. Bitte wählen Sie ein anderes Passwort.' }) } }