Files
qrcode-generator/src/lib/auth/password.ts
T
Florian Wilde 75252b32b4 Anmeldung und Sitzungen nach OWASP durchgesehen und gehaertet
Zehn Befunde, alle behoben. Die wichtigsten:

scrypt lief mit N=2^15; das OWASP Password Storage Cheat Sheet nennt 2^17.
Jetzt 2^17, alte Hashes bleiben gueltig und werden beim naechsten Anmelden
still erneuert — das ist der einzige Moment, in dem das Klartextpasswort
vorliegt.

Passwoerter hatten keine Obergrenze. Ein Passwort von mehreren Megabyte haette
scrypt beschaeftigt, und zwar an einem Endpunkt ohne Anmeldung. Jetzt 128
Zeichen, geprueft vor dem Hashen.

Als CSRF-Schutz gab es nur SameSite=Lax. Das Merkblatt sagt ausdruecklich,
dass das allein nicht genuegt — besonders beim Upload, der multipart
entgegennimmt und damit von einem fremden Formular erreichbar waere. Jetzt
wird bei jedem schreibenden Zugriff der Origin gegen den Host der Anfrage
geprueft (nicht gegen BASE_URL, die Anwendung laeuft unter mehreren Domains).

Es gab keinen einzigen Schutzheader — die Verwaltung liess sich einbetten und
per Clickjacking fernsteuern. Jetzt frame-ancestors none, X-Frame-Options,
nosniff, Referrer-Policy und Permissions-Policy; X-Powered-By ist weg.

Sitzungen hatten keine absolute Obergrenze: eine gestohlene Sitzung haette
durch regelmaessige Nutzung unbegrenzt weitergelebt. Jetzt SESSION_MAX_DAYS
neben SESSION_IDLE_DAYS, beide einstellbar.

Dazu: Secure auch hinter einem TLS-Proxy, weniger Auskunft ueber fremde
Konten bei der Anmeldung, eine Bremse je Absender gegen breites
Durchprobieren, Ersteinrichtung ohne Wettlauf, und abgelaufene Sitzungen
werden aufgeraeumt.

Die Datei heisst proxy.ts statt middleware.ts — Next 16 hat die Konvention
umbenannt. /api/uploads bleibt davon ausgenommen: Der Proxy puffert
Anfragekoerper nur bis 10 MB, eine Datei an der Groessengrenze kaeme sonst
unvollstaendig an. Die Herkunftspruefung macht die Route dort selbst.

Befund, Begruendungen und was bewusst offen bleibt: docs/sicherheit.md
2026-08-15 08:31:29 +02:00

143 lines
4.0 KiB
TypeScript

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<Buffer> {
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<string> {
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<boolean> {
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.`;
}