Abschnitt 14 · Lektion 1
Passwörter speichern
Die Sitzung aus 13.4 weiß, dass jemand wiederkommt. Sie weiß nur nicht, wer. Dafür braucht es Konten, und ein Konto braucht ein Passwort. Genau hier passieren die Fehler, die später in den Nachrichten stehen, und deshalb steht diese Lektion vor der Registrierung und nicht danach. Wer erst das Formular baut und sich dann Gedanken über das Speichern macht, hat die Passwörter längst im Klartext in der Tabelle.
Ein Passwort speichert man gar nicht
Die Regel lautet: In deiner Datenbank steht nie das Passwort, sondern nur etwas, das sich daraus berechnen lässt und aus dem man nicht zurückrechnen kann. Das gilt auch für die eine Zeile, die du „nur zum Testen” einbaust, denn die bleibt drin. Und es gilt selbst dann, wenn niemand außer dir an die Datenbank kommt: Der ganze Sinn der Übung ist der Tag, an dem doch jemand drankommt.
Ein Hash wie SHA-256 klingt nach der Antwort darauf. Er ist eine Einbahnstraße, aus dem Ergebnis lässt sich die Eingabe nicht zurückrechnen. Trotzdem taugt er hier nicht, und das Beispiel zeigt gleich beide Gründe.
import { createHash, scryptSync } from "node:crypto";
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
// Drei Konten, zwei davon mit demselben Passwort.
console.log(`Anna: ${sha256("geheim123")}`);
console.log(`Bernd: ${sha256("geheim123")}`);
console.log(`Clara: ${sha256("geheim124")}`);
// Wie schnell „schnell" ist. Die nackte Millisekundenzahl schwankt von
// Lauf zu Lauf, die Antwort auf die Frage nicht.
const vorher = process.hrtime.bigint();
for (let i = 0; i < 1000; i++) sha256(`versuch${i}`);
const dazwischen = process.hrtime.bigint();
scryptSync("geheim123", "irgendeinsalt", 64);
const nachher = process.hrtime.bigint();
console.log(`1000 mal sha256 geht schneller als 1 mal scrypt: ${dazwischen - vorher < nachher - dazwischen}`); Der erste Grund sind Anna und Bernd: Sie haben dasselbe Passwort, und ihre beiden Hashes sind deshalb Zeichen für Zeichen gleich. Wer die Tabelle in die Hand bekommt, sortiert nach dieser Spalte und weiß sofort, welche Konten zusammengehören. Ein einziges geknacktes Passwort öffnet dann gleich mehrere Türen.
Der zweite Grund ist die gemessene Dauer. SHA-256 ist für Geschwindigkeit gebaut, und Geschwindigkeit ist hier genau das Falsche. Ein Angreifer mit deiner Tabelle probiert nicht ein Passwort, sondern Milliarden pro Sekunde, und er probiert nicht blind, sondern der Reihe nach die Passwörter, die Menschen wirklich nehmen. Er braucht die Liste nicht einmal selbst zu rechnen, es gibt sie fertig zu kaufen.
Salt macht jede fertige Liste wertlos
Gegen beides hilft dieselbe Maßnahme: Zu jedem Passwort kommt ein zufälliger Wert dazu, der Salt, und der wird mitgespeichert. Er ist kein Geheimnis, er muss nur je Konto verschieden sein.
import { randomBytes, scryptSync } from "node:crypto";
// Verfahren, Kostenfaktor, Salt und Hash in einer Zeichenkette. Wer
// spaeter das Verfahren wechselt, erkennt an den ersten beiden Feldern,
// was mit einem alten Eintrag zu tun ist.
function speichern(passwort, salt) {
const hash = scryptSync(passwort, salt, 64);
return ["scrypt", 16384, salt.toString("hex"), hash.toString("hex")].join("$");
}
const anna = speichern("geheim123", randomBytes(16));
const bernd = speichern("geheim123", randomBytes(16));
console.log(`Anna und Bernd, jeder mit eigenem Salt, gleich? ${anna === bernd ? "ja" : "nein"}`);
// Nur fuer dieses Beispiel ein fester Wert, damit unten immer dasselbe
// steht. In deinem Code kommt der Salt aus randomBytes, sonst faellt
// der ganze Nutzen wieder weg.
const festerSalt = Buffer.from("immer-derselbe-salt");
const einmal = speichern("geheim123", festerSalt);
const nochmal = speichern("geheim123", festerSalt);
console.log(`Zweimal mit demselben Salt, gleich? ${einmal === nochmal ? "ja" : "nein"}`);
const [verfahren, kosten, saltHex, hashHex] = einmal.split("$");
console.log("");
console.log(`Verfahren: ${verfahren}`);
console.log(`Kosten: ${kosten}`);
console.log(`Salt: ${saltHex}`);
console.log(`Hash: ${hashHex.slice(0, 32)} ... insgesamt ${hashHex.length} Zeichen`); Damit sehen zwei gleiche Passwörter verschieden aus, und eine fertige Liste hilft nicht mehr, weil
sie für jeden Salt neu gerechnet werden müsste. Das Verfahren heißt hier scrypt und kommt aus
node:crypto, du brauchst also kein Paket dafür. Es ist absichtlich langsam und absichtlich
speicherhungrig, und der Kostenfaktor sagt, wie sehr. 16384 ist die Vorgabe und kostet auf einem
normalen Rechner den Bruchteil einer Sekunde. Für dich bei der Anmeldung ist das nicht zu merken,
für jemanden, der Milliarden Versuche fahren will, ist es das Ende der Rechnung.
Beachte, was in der gespeicherten Zeichenkette alles drinsteht: das Verfahren, der Kostenfaktor, der Salt und erst dann der Hash. Alle vier brauchst du beim Prüfen ohnehin wieder, und die ersten beiden sind deine Versicherung für später. Wenn in fünf Jahren 16384 zu wenig ist, erkennst du an jedem Eintrag, mit welchen Einstellungen er entstanden ist, und kannst ihn beim nächsten Anmelden still auf den neuen Stand heben. Ohne diese Angaben bliebe dir nur, alle Konten zurückzusetzen.
Prüfen heißt nachrechnen, nicht nachschlagen
Beim Anmelden nimmst du das eingetippte Passwort, holst Salt und Kostenfaktor aus dem gespeicherten Eintrag, rechnest damit neu und vergleichst die beiden Ergebnisse. Nur beim Vergleichen selbst gibt es noch eine Feinheit.
import { scryptSync, timingSafeEqual } from "node:crypto";
const salt = Buffer.from("immer-derselbe-salt");
const gespeichert = scryptSync("geheim123", salt, 64);
// So nicht: === vergleicht Zeichen fuer Zeichen und hoert beim ersten
// Unterschied auf. Wer die Antwortzeit misst, erfaehrt daraus, wie weit
// er richtig geraten hat.
const eingetippt = scryptSync("geheim123", salt, 64);
console.log(`mit === ${eingetippt.toString("hex") === gespeichert.toString("hex")}`);
// So: timingSafeEqual sieht sich immer alle Bytes an, egal wo der
// Unterschied liegt.
console.log(`timingSafeEqual ${timingSafeEqual(eingetippt, gespeichert)}`);
console.log(`bei falschem Wort ${timingSafeEqual(scryptSync("geheim124", salt, 64), gespeichert)}`);
// Und die Falle: zwei ungleich lange Puffer geben kein false, sondern
// werfen. Reich also nie das eingetippte Passwort selbst hinein,
// sondern immer erst seinen Hash.
try {
timingSafeEqual(Buffer.from("geheim123"), gespeichert);
} catch (fehler) {
console.error(`${fehler.constructor.name}: ${fehler.message}`);
} Ein === hört beim ersten unterschiedlichen Byte auf. Es antwortet also ein winziges bisschen
schneller, wenn schon das erste Byte nicht passt, als wenn die ersten zwanzig stimmen. Wer diese
Zeiten oft genug misst, kann sich Byte für Byte an den richtigen Wert herantasten. timingSafeEqual
sieht sich immer alles an und braucht deshalb immer gleich lang.
Zugegeben, über eine echte Leitung ist dieser Angriff schwer zu fahren. Trotzdem ist es die richtige Gewohnheit, denn sie kostet dich nichts: eine Funktion statt eines Operators.
Die eine Sache, auf die du achten musst, steht am Ende des Beispiels, und sie ist der Grund für die
rote Zeile im Terminal: Ungleich lange Puffer sind kein false, sondern ein Wurf. Hier
fängt ihn ein catch ab und schreibt RangeError: Input buffers must have the same byte length auf
den Fehlerkanal; in deinem Anmeldeweg gäbe es das catch nicht, und der Wurf wäre ein Serverfehler.
Gib also nie das eingetippte Passwort direkt hinein, sondern immer erst dessen Hash, und der ist dann
von sich aus genauso lang wie der gespeicherte.
Was daraus für dein Konto folgt
Zwei Funktionen reichen für alles Weitere: eine, die aus einem Passwort einen speicherbaren Wert macht, und eine, die einen Versuch dagegen prüft. Genau die baust du in der Challenge, und ab 14.2 liegen sie als fertige Datei daneben, weil Registrierung und Anmeldung beide darauf zugreifen. Was du dagegen nirgends brauchst, ist eine Funktion, die aus dem gespeicherten Wert das Passwort zurückholt. Die gibt es nicht, und dass es sie nicht gibt, ist der Punkt.
Zum Mitnehmen
Ein Passwort wird nie gespeichert, sondern immer nur etwas, das sich daraus berechnen lässt und nicht zurück. Und das Verfahren dafür muss absichtlich langsam sein.
Jetzt du
Basis Konto, kostenlosZu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.
Was in dieser Lektion steckt
-
Artikel mit 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.