import "server-only"; import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions, } from "node:crypto"; // promisify() greift bei scrypt die Überladung ohne Optionen ab — deshalb // hier von Hand eingepackt. function scryptAsync( password: string, salt: Buffer, keyLength: number, options: ScryptOptions, ): Promise { return new Promise((resolve, reject) => { scrypt(password, salt, keyLength, options, (error, derived) => { if (error) reject(error); else resolve(derived); }); }); } /** * Passwörter mit scrypt aus der Node-Standardbibliothek. * * Warum nicht Argon2id, das heute meist zuerst empfohlen wird: dafür bräuchte * es ein natives Modul, das zur Plattform des Containers passen muss. scrypt * ist ebenfalls speicherhart, gilt als solide und kostet null Abhängigkeiten * und null Build-Risiko — bei einer selbst gehosteten Anwendung wiegt das * schwerer als der Unterschied zwischen zwei guten Verfahren. * * Format: `scrypt$N$r$p$salt$hash`, beides Base64. Die Parameter stehen im * Hash, damit sie sich später erhöhen lassen, ohne alte Passwörter zu * entwerten — `needsRehash()` erkennt dann veraltete Hashes. * * N = 2^17, r = 8, p = 1 entspricht der Empfehlung des OWASP Password Storage * Cheat Sheet (Stand 2026). Das kostet rund 128 MiB Speicher je Berechnung; * `maxmem` muss darüber liegen, sonst weigert sich Node. */ const PARAMS = { N: 131072, r: 8, p: 1, keyLength: 64, saltLength: 16 }; /** 128 · N · r plus Reserve. */ const MAXMEM = 192 * 1024 * 1024; export async function hashPassword(password: string): Promise { const salt = randomBytes(PARAMS.saltLength); const derived = await scryptAsync( password.normalize("NFKC"), salt, PARAMS.keyLength, { N: PARAMS.N, r: PARAMS.r, p: PARAMS.p, // Der Standardwert reicht für dieses N nicht aus. maxmem: MAXMEM, }, ); return [ "scrypt", PARAMS.N, PARAMS.r, PARAMS.p, salt.toString("base64"), derived.toString("base64"), ].join("$"); } export async function verifyPassword( password: string, stored: string, ): Promise { const parts = stored.split("$"); if (parts.length !== 6 || parts[0] !== "scrypt") return false; const N = Number(parts[1]); const r = Number(parts[2]); const p = Number(parts[3]); if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) { return false; } let salt: Buffer; let expected: Buffer; try { salt = Buffer.from(parts[4], "base64"); expected = Buffer.from(parts[5], "base64"); } catch { return false; } let derived: Buffer; try { derived = await scryptAsync(password.normalize("NFKC"), salt, expected.length, { N, r, p, maxmem: MAXMEM, }); } catch { return false; } // Vergleich in konstanter Zeit — ein früher Abbruch würde verraten, wie // viele Bytes bereits stimmen. return derived.length === expected.length && timingSafeEqual(derived, expected); } /** * Erkennt Hashes, die mit schwächeren Parametern erzeugt wurden. * * Beim nächsten erfolgreichen Anmelden wird das Passwort dann neu gehasht — * anders kommt man nicht an das Klartextpasswort, um den Hash zu erneuern. */ export function needsRehash(stored: string): boolean { const parts = stored.split("$"); if (parts.length !== 6 || parts[0] !== "scrypt") return true; return ( Number(parts[1]) < PARAMS.N || Number(parts[2]) < PARAMS.r || Number(parts[3]) < PARAMS.p ); } /** Mindestanforderungen an ein Passwort. */ export const MIN_PASSWORD_LENGTH = 10; /** * Obergrenze — nicht aus Sicherheitsgründen, sondern gegen Überlastung: * scrypt über ein mehrere Megabyte langes „Passwort" würde den Server * beschäftigen, und das an einer Stelle, die ohne Anmeldung erreichbar ist. * 128 Zeichen sind weit jenseits jeder sinnvollen Passphrase. */ export const MAX_PASSWORD_LENGTH = 128; export function describePasswordRules(): string { return `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`; }