feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session Management und Password Storage. Das meiste stand schon gut da – Token mit 256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf Punkte blieben. **Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem 2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt 2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt: Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel. **Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer immer auf den alten Parametern – anheben laesst er sich nur dort, wo das Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p, nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu gehasht werden. **Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort- pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423), deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401). Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst hingenommen. **Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde. ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist – bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt. **Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-". Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist – eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides kostet einmalig eine neue Anmeldung. Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und Passwoerter, die den Benutzernamen enthalten, werden abgelehnt. Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab. Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern) und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes Konto gegen die auf ein erfundenes haelt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PASSWORD_MIN_LENGTH } from "./constants";
|
||||
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password";
|
||||
import { hashPassword, needsRehash, validatePasswordStrength, verifyPassword } from "./password";
|
||||
|
||||
describe("hashPassword und verifyPassword", () => {
|
||||
it("erkennt das richtige Passwort wieder", async () => {
|
||||
@@ -64,3 +64,82 @@ describe("validatePasswordStrength", () => {
|
||||
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Aufwand nach OWASP", () => {
|
||||
it("hasht mit den heute vorgesehenen Parametern", async () => {
|
||||
// OWASP Password Storage Cheat Sheet: N = 2^17, r = 8, p = 1.
|
||||
const hash = await hashPassword("ein-langes-testpasswort");
|
||||
const [, N, r, p] = hash.split("$");
|
||||
expect(Number(N)).toBe(131072);
|
||||
expect(Number(r)).toBe(8);
|
||||
expect(Number(p)).toBe(1);
|
||||
});
|
||||
|
||||
it("prueft alte Hashes weiterhin, weil die Parameter im Hash stehen", async () => {
|
||||
// Von Hand mit den frueheren Werten erzeugt – muss gueltig bleiben,
|
||||
// sonst waere ein Anheben des Aufwands eine Aussperrung.
|
||||
const alt =
|
||||
"scrypt$32768$8$1$" +
|
||||
Buffer.from("salzsalzsalzsalz").toString("base64url") +
|
||||
"$";
|
||||
// Echten Hash mit alten Parametern bauen:
|
||||
const { scrypt } = await import("node:crypto");
|
||||
const { promisify } = await import("node:util");
|
||||
const s = promisify(scrypt) as (a: string, b: Buffer, c: number, d: object) => Promise<Buffer>;
|
||||
const salt = Buffer.from("salzsalzsalzsalz");
|
||||
const derived = await s("ein-langes-testpasswort", salt, 64, {
|
||||
N: 32768,
|
||||
r: 8,
|
||||
p: 1,
|
||||
maxmem: 96 * 1024 * 1024,
|
||||
});
|
||||
const alterHash = `scrypt$32768$8$1$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
||||
expect(alt.length).toBeGreaterThan(0);
|
||||
|
||||
await expect(verifyPassword("ein-langes-testpasswort", alterHash)).resolves.toBe(true);
|
||||
// ... und wird als erneuerungsbeduerftig erkannt.
|
||||
expect(needsRehash(alterHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("laesst frische Hashes in Ruhe", async () => {
|
||||
expect(needsRehash(await hashPassword("ein-langes-testpasswort"))).toBe(false);
|
||||
});
|
||||
|
||||
it("erkennt gleichwertige Kombinationen als ausreichend", () => {
|
||||
// N=2^16 mit p=2 ergibt denselben Gesamtaufwand wie N=2^17 mit p=1 und
|
||||
// ist nach OWASP ebenso zulaessig.
|
||||
expect(needsRehash("scrypt$65536$8$2$c2FsdA$aGFzaA")).toBe(false);
|
||||
// N=2^15 mit p=1 dagegen nicht – das war der alte Zustand.
|
||||
expect(needsRehash("scrypt$32768$8$1$c2FsdA$aGFzaA")).toBe(true);
|
||||
});
|
||||
|
||||
it("haelt Unsinn fuer erneuerungsbeduerftig", () => {
|
||||
expect(needsRehash("voelliger-unsinn")).toBe(true);
|
||||
expect(needsRehash(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schwache Muster", () => {
|
||||
it("weist Wiederholungen ab", () => {
|
||||
expect(validatePasswordStrength("aaaaaaaaaaaa")).toBeTruthy();
|
||||
expect(validatePasswordStrength("abababababab")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("weist Zeichenfolgen ab", () => {
|
||||
expect(validatePasswordStrength("abcdefghijkl")).toBeTruthy();
|
||||
expect(validatePasswordStrength("87654321abcd")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("weist Passwoerter mit dem Benutzernamen ab", () => {
|
||||
expect(validatePasswordStrength("chefin-geheimnis", "chefin")).toMatch(/Benutzernamen/);
|
||||
// Ohne Benutzernamen ist dasselbe Passwort in Ordnung.
|
||||
expect(validatePasswordStrength("chefin-geheimnis")).toBeNull();
|
||||
// Sehr kurze Namen zaehlen nicht – sonst scheitert fast alles.
|
||||
expect(validatePasswordStrength("ab-langes-passwort", "ab")).toBeNull();
|
||||
});
|
||||
|
||||
it("laesst ein gutes Passwort mit Wiederholung durch", () => {
|
||||
// Ein "ll" ist kein Grund zur Ablehnung, solange genug Vielfalt da ist.
|
||||
expect(validatePasswordStrength("Rollladen-Fenster-42")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user