96 lines
2.8 KiB
JavaScript
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.'
|
|
})
|
|
}
|
|
}
|
|
|
|
|