Zum Inhalt
wannacode

Passwörter

Kurz gesagt: Passwörter werden als langsamer Hash mit Salt gespeichert (bcrypt oder argon2id), nicht verschlüsselt und nicht mit schnellem SHA-256, damit ein Datenbank-Leak die Passwörter selbst nicht preisgibt.

Problem

Das Backup, das öffentlich wurde

Der Shop speichert seine Käufer in einer Tabelle users: eine E-Mail und ein Passwort. Jemand lädt ein Datenbank-Backup in einen öffentlichen Ordner hoch, und eine Woche später ist es im Internet. Was die Käufer verlieren, hängt nur davon ab, wie das Passwort gespeichert war:

  • als Text: Jedes Passwort liegt offen. Viele benutzen überall dasselbe Passwort, also leaken auch ihre E-Mail-Konten;
  • verschlüsselt mit einem Schlüssel, der neben der Datenbank liegt: dasselbe. Der Schlüssel steht in der Konfiguration desselben Servers und leakt mit;
  • als SHA-256 ohne Salt: Die Passwörter sind nicht zu sehen, aber gleiche Passwörter ergeben gleiche Hashes. Die Tabelle selbst zeigt, wer mit wem ein Passwort teilt. Und wer die Tabelle hat, kann eine Liste häufiger Passwörter nehmen, jedes hashen und nach Treffern suchen: Das ist ein Wörterbuchangriff, und SHA-256 wurde gebaut, um schnell zu sein.

Einen Leak kann man nie ganz ausschließen. Also wird das Passwort so gespeichert, dass ein Leak die Passwörter nicht herausgibt.

Die Idee

Behalte den Fingerabdruck, nicht das Passwort

Der Server muss das Passwort nicht kennen. Er muss nur prüfen, ob das eingegebene Passwort dasselbe ist. Dafür gibt es einen kryptografischen Hash: eine Funktion, die Text in einen Fingerabdruck fester Länge verwandelt und nicht umkehrbar ist. Dieselbe Eingabe ergibt denselben Fingerabdruck, und der Fingerabdruck führt nicht zur Eingabe zurück.

Das ist nicht der Hash aus der Lektion über Map und Set: Der ist schnell, kurz und kollidiert oft, weil er für Buckets gemacht ist. Ein kryptografischer Hash ist etwas anderes.

Der alte Code des Shops speichert das SHA-256 des Passworts:

const sha256 = (text) =>
createHash("sha256").update(text).digest("hex");
// Der alte Code des Shops: SHA-256 ohne Salt.
function register(email, password) {
return { email, salt: "", hash: sha256(password) };
}

Vier Käufer; ana und vera haben dasselbe Passwort. Die Funktion sharedRows zählt die Zeilen, deren Hash mehr als einmal vorkommt:

function sharedRows(users) {
const perHash = new Map();
for (const { hash } of users) {
perHash.set(hash, (perHash.get(hash) ?? 0) + 1);
}
return users.filter((row) => perHash.get(row.hash) > 1).length;
}
const users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
console.log("Die Tabelle users:");
for (const row of users) {
console.log(row.email.padEnd(20) + row.hash.slice(0, 16) + "…");
}
console.log("Zeilen mit gemeinsamem Hash:", sharedRows(users));
Ausgabe
Die Tabelle users:
ana@example.com 65e84be33532fb78…
boris@example.com 6191711a1871a6bd…
vera@example.com 65e84be33532fb78…
georgi@example.com 9cbd9835924cf733…
Zeilen mit gemeinsamem Hash: 2

Die Zeilen von ana und vera sind gleich. Ohne etwas zu brechen, sieht jeder mit der Tabelle, dass die beiden ein Passwort haben. Gibt eine von ihnen es preis, gibt sie das der anderen mit preis.

Die Idee

Ein Salt: jede Zeile anders

Gegen gleiche Zeilen hilft ein Salt: zufällige Bytes, für jede Zeile anders, die vor dem Hash neben das Passwort kommen. Der Salt ist kein Geheimnis. Er steht offen in der Zeile, weil die Anmeldung ihn wieder braucht.

// Ein eigener zufälliger Salt für jede Zeile.
function register(email, password) {
const salt = randomBytes(16).toString("hex");
return { email, salt, hash: sha256(salt + password) };
}

randomBytes aus node:crypto ist ein kryptografischer Zufallszahlengenerator: Sein nächster Wert lässt sich nicht vorhersagen. (Warum der gewöhnliche Generator nicht reicht, steht in der Lektion über Kryptografie.)

Der Salt ist bei jedem Lauf anders, deshalb gibt das Programm nur aus, was sich nicht ändert:

const users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
console.log("Gleicher Eintrag für ana und vera:", users[0].hash === users[2].hash);
console.log("Zeilen mit gemeinsamem Hash:", sharedRows(users));
Ausgabe
Gleicher Eintrag für ana und vera: false
Zeilen mit gemeinsamem Hash: 0

Gleiche Passwörter fallen nicht mehr auf, und eine Berechnung zählt nur für eine Zeile. Aber SHA-256 ist weiter schnell: Eine Grafikkarte berechnet Milliarden pro Sekunde, der Wörterbuchangriff funktioniert also weiter, nur Zeile für Zeile. Der Hash muss auch absichtlich langsam sein.

Sieh es dir an

Die Tabelle users: ohne Salt, mit Salt, mit langsamem Hash

// Demo; auf einem echten Server 2 ** 17 (OWASP)
const N = 2 ** 12;
function hashPassword(password) {
const salt = randomBytes(16);
const hash = kdf(password, salt, N);
return ["scrypt", N, salt.toString("base64"),
hash.toString("base64")].join("$");
}
function verify(password, record) {
const [, n, salt, expected] = record.split("$");
const actual = kdf(password, Buffer.from(salt, "base64"),
Number(n));
const want = Buffer.from(expected, "base64");
return timingSafeEqual(actual, want);
}
Schritt für Schritt, in Worten
  1. Die Tabelle users mit dem alten Code des Shops: eine E-Mail und das SHA-256 des Passworts, ohne Salt.
  2. Gleiche Passwörter ergeben gleiche Hashes. ana und vera haben denselben Wert, also zeigt die Tabelle selbst, wer ein Passwort teilt: 2 Zeilen.
  3. Jetzt bekommt jede Zeile einen eigenen zufälligen Salt, und die Zeile speichert SHA-256(Salt + Passwort).
  4. Jede Zeile ist anders, auch ana und vera: 0 Zeilen teilen einen Hash. Aber SHA-256 ist schnell, deshalb ist jeder Versuch für eine Zeile weiter billig.
  5. Die Lösung ist ein langsamer Hash: scrypt mit den Kosten N = 4.096 hier (eine Demo), der auch Speicher braucht. Auf einem echten Server N = 2^17.
  6. Der Eintrag speichert alles, was man später zur Prüfung braucht: Algorithmus $ Kosten $ Salt $ Hash. Das Passwort selbst steht nicht darin.
  7. Bei der Anmeldung liest der Code die Kosten und den Salt aus dem Eintrag und hasht das eingegebene Passwort genauso.
  8. Die beiden Hashes werden in konstanter Zeit verglichen: "qwerty" ergibt ✓, "qwerty1" ergibt ✗. Gleiche Passwörter fallen nicht mehr auf, und jeder Versuch ist langsam.

Das letzte Bild fasst die drei Schritte zusammen: Die Spalte speichert einen Eintrag mit Salt und Kosten, keine zwei Zeilen sind gleich, und die Anmeldung prüft das Passwort gegen den Eintrag.

Die Idee

Ein langsamer Hash und der Eintrag

Ein langsamer Passwort-Hash wiederholt seine Arbeit viele Male. Wie oft, sind die Kosten (Work Factor). Eine Anmeldung berechnet ihn einmal und wartet den Bruchteil einer Sekunde. Wer die geleakte Tabelle hat, zahlt für jeden Versuch denselben Preis.

Die Standardbibliothek hat scrypt: Außer Zeit braucht es Speicher, und Speicher ist für Grafikkarten teuer.
const kdf = (password, salt, n) =>
scryptSync(password, salt, 32, { N: n });
scryptSync nimmt das Passwort, den Salt, die Länge in Bytes und N (die Kosten, eine Zweierpotenz). Auf einem echten Server nimm das asynchrone crypto.scrypt: Das synchrone blockiert die Event Loop für die ganze Berechnung (siehe die Lektion über async).

Der Eintrag speichert alles, was die Prüfung braucht: Algorithmus, Kosten, Salt und Hash, getrennt durch $. So können die Kosten später steigen, ohne die alten Einträge zu brechen.

// Demo; auf einem echten Server 2 ** 17 (OWASP)
const N = 2 ** 12;
function hashPassword(password) {
const salt = randomBytes(16);
const hash = kdf(password, salt, N);
return ["scrypt", N, salt.toString("base64"),
hash.toString("base64")].join("$");
}
function verify(password, record) {
const [, n, salt, expected] = record.split("$");
const actual = kdf(password, Buffer.from(salt, "base64"),
Number(n));
const want = Buffer.from(expected, "base64");
return timingSafeEqual(actual, want);
}
const record = hashPassword("qwerty");
const parts = record.split("$");
console.log(`Algorithmus und Kosten: ${parts[0]}, ${parts[1]}`);
console.log("Anmeldung mit dem richtigen Passwort:", verify("qwerty", record));
console.log("Anmeldung mit einem falschen Passwort:", verify("qwerty1", record));
console.log("Ein Passwort, zwei Einträge, gleich:", record === hashPassword("qwerty"));
Ausgabe
Algorithmus und Kosten: scrypt, 4096
Anmeldung mit dem richtigen Passwort: true
Anmeldung mit einem falschen Passwort: false
Ein Passwort, zwei Einträge, gleich: false

Die Kosten im Programm sind klein, damit es schnell läuft. Auf einem echten Server empfiehlt OWASP N = 2¹⁷ (r = 8, p = 1) für scrypt. In einem echten Projekt schreibst du das meist nicht selbst, sondern nimmst bcrypt oder argon2id aus einer bewährten Bibliothek (Einzelheiten unter „Genauer, für Neugierige“).

Probier es aus

Sage die Ausgabe voraus

Ein und dasselbe Passwort, zweimal gehasht. Was gibt es aus?

const first = hashPassword("qwerty");
const second = hashPassword("qwerty");
console.log(first === second);
console.log(verify("qwerty", first));
console.log(verify("qwerty", second));
Antwort

Probier es aus

Ergänze

Bei der Anmeldung werden die beiden Hashes verglichen. Ein gewöhnlicher Vergleich hält beim ersten unterschiedlichen Byte an, und die Antwortzeit kann verraten, wie weit sie übereinstimmen. Welcher Vergleich geht immer durch alle Bytes?

const want = Buffer.from(expected, "base64");
return (actual, want);

Probier es aus

Finde den Fehler

Ein Kollege „vereinfacht“ den Code: statt eines zufälligen Salts für jede Zeile ein Salt für alle. Der langsame Hash ist derselbe. Wo liegt das Problem?

Wähle die Zeile mit dem Fehler.

Sieh es dir an

Die Kosten wachsen über die Jahre

Computer werden schneller, also reichen Kosten, die letztes Jahr genügten, nach einiger Zeit nicht mehr. Alte Einträge lassen sich nicht neu berechnen: Ein Hash geht nicht rückwärts, und der Server hat das Passwort nur im Moment einer Anmeldung. Also findet der neue Hash bei einer erfolgreichen Anmeldung statt. Hier nimmt hashPassword die Kosten als Parameter, und jeder Eintrag speichert seine eigenen, sodass die alten Einträge weiter funktionieren.

// letztes Jahr war es 2 ** 11
const N = 2 ** 12;
const costOf = (record) => Number(record.split("$")[1]);
function login(account, password) {
if (!verify(password, account.record)) return false;
if (costOf(account.record) < N) {
// erst jetzt haben wir das Passwort in der Hand: Hash mit den neuen Kosten
account.record = hashPassword(password, N);
}
return true;
}
const account = { record: hashPassword("qwerty", 2 ** 11) };
console.log("Kosten vor der Anmeldung:", costOf(account.record));
console.log("Anmeldung:", login(account, "qwerty"));
console.log("Kosten nach der Anmeldung:", costOf(account.record));
Ausgabe
Kosten vor der Anmeldung: 2048
Anmeldung: true
Kosten nach der Anmeldung: 4096
Genauer, für Neugierige

Welche Einstellungen (OWASP, Password Storage Cheat Sheet):

  • argon2id: 19 MiB Speicher, 2 Iterationen, Parallelität 1. Die erste Wahl für ein neues Projekt;
  • scrypt: N = 2¹⁷, r = 8, p = 1. Es braucht 128 MiB Speicher, und sowohl in Python als auch in Node liegt das Standardlimit bei 32 MiB, also wird es mit maxmem angehoben;
  • bcrypt: Kosten 10 oder mehr. Es benutzt nur die ersten 72 Bytes des Passworts;
  • PBKDF2-HMAC-SHA256: 600.000 Iterationen, wenn ein FIPS-zugelassener Algorithmus verlangt wird.

Was echte Projekte benutzen. Du schreibst das selten selbst; du nimmst die Bibliothek des Frameworks: Die npm-Pakete argon2 und bcrypt (Node hat keins von beiden eingebaut). Sie bieten asynchrones hash und verify und schreiben Algorithmus und Kosten in den Eintrag.

Pepper. Manche fügen außerdem einen geheimen Wert hinzu, gemeinsam für alle Zeilen, der außerhalb der Datenbank lebt (in einem Secret Store, siehe die Lektion über Geheimnisse). Ein Leak allein der Datenbank reicht dann nicht. Das hilft, ersetzt aber weder den Salt noch den langsamen Hash.

Die Passwortrichtlinie. Ein langes Passwort (mindestens 12 Zeichen) und ein Abgleich mit Listen geleakter Passwörter helfen mehr als Regeln wie „ein Großbuchstabe und eine Ziffer“. Das ist auch die Aufgabe unten.

Wie es in den anderen Sprachen ist: Java und C# haben PBKDF2 in der Standardbibliothek, aber kein scrypt; Python und Node haben beide (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). Keine der vier hat argon2id oder bcrypt eingebaut.

Übung

Das Passwort ändern

Löse die Aufgabe in deinem Editor.

Das Konto des Käufers hat das Passwort "qwerty". Schreibe changePassword(account, oldPassword, newPassword), das:

  1. das aktuelle Passwort mit verify prüft und ablehnt, wenn es falsch ist;
  2. ein neues Passwort ablehnt, das kürzer als 12 Zeichen ist;
  3. ein neues Passwort ablehnt, das (ohne Beachtung von Groß- und Kleinschreibung) in der Liste password1234, qwerty123456, 123456789012 steht;
  4. sonst einen neuen Eintrag mit neuem Salt speichert und „Passwort geändert“ zurückgibt.

Rufe es mit ("qwerty1", "new-pass-2026!"), ("qwerty", "short1"), ("qwerty", "Qwerty123456") und ("qwerty", "tram-9-to-vitosha") auf und prüfe dann die Anmeldung mit dem neuen Passwort:

Ausgabe
Falsches aktuelles Passwort
Das neue Passwort ist kürzer als 12 Zeichen
Dieses Passwort steht auf den Listen geleakter Passwörter
Passwort geändert
Anmeldung mit dem neuen Passwort: true

Weitere Aufgaben

  • Ein neuer Hash bei der Anmeldung. Füge login aus „Die Kosten wachsen über die Jahre“ hinzu und prüfe, dass ein altes Konto mit niedrigeren Kosten aus der Anmeldung mit den neuen herauskommt.
  • Dieselbe Zeit bei einer falschen E-Mail. Existiert die E-Mail nicht, antwortet die Anmeldung sofort; existiert sie, berechnet sie den langsamen Hash. Wie würdest du dafür sorgen, dass beide Fälle gleich lang dauern?

Test

Teste dich

1. Warum werden Passwörter nicht mit einem Schlüssel verschlüsselt gespeichert?
2. Was macht ein Salt, und was macht er NICHT?
3. Warum hilft ein langsamer Hash, statt zu schaden?
4. Warum wird der Hash bei der Anmeldung in konstanter Zeit verglichen?
5. Du erhöhst die Kosten von 10.000 auf 20.000 Iterationen. Was passiert mit den alten Einträgen?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·