Files
harheimertc/server/utils/hibp.js

96 lines
2.8 KiB
JavaScript

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.'
})
}
}