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 dashash() aus der Lektion über dict und set: Das ist schnell, kurz und kollidiert oft, weil es für Buckets gemacht ist. Ein kryptografischer Hash ist etwas anderes.Der alte Code des Shops speichert das SHA-256 des Passworts:
class Row(NamedTuple): email: str salt: str hash: str
def sha256(text): return hashlib.sha256(text.encode()).hexdigest()
# Der alte Code des Shops: SHA-256 ohne Salt.def register(email, password): return Row(email, "", sha256(password))Vier Käufer; ana und vera haben dasselbe Passwort. Die Funktion shared_rows zählt die Zeilen, deren Hash mehr als einmal vorkommt:
def shared_rows(users): per_hash = Counter(row.hash for row in users) return sum(1 for row in users if per_hash[row.hash] > 1)users = [ register("ana@example.com", "qwerty"), register("boris@example.com", "sofia2024"), register("vera@example.com", "qwerty"), register("georgi@example.com", "Kp7#vR!q2Lm9"),]print("Die Tabelle users:")for row in users: print(f"{row.email:<20}{row.hash[:16]}…")print("Zeilen mit gemeinsamem Hash:", shared_rows(users))Die Tabelle users:ana@example.com 65e84be33532fb78…boris@example.com 6191711a1871a6bd…vera@example.com 65e84be33532fb78…georgi@example.com 9cbd9835924cf733…Zeilen mit gemeinsamem Hash: 2Die 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.def register(email, password): salt = secrets.token_hex(16) return Row(email, salt, sha256(salt + password))secrets 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:
users = [ register("ana@example.com", "qwerty"), register("boris@example.com", "sofia2024"), register("vera@example.com", "qwerty"), register("georgi@example.com", "Kp7#vR!q2Lm9"),]print("Gleicher Eintrag für ana und vera:", users[0].hash == users[2].hash)print("Zeilen mit gemeinsamem Hash:", shared_rows(users))Gleicher Eintrag für ana und vera: FalseZeilen mit gemeinsamem Hash: 0Gleiche 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
N = 2**12 # Demo; auf einem echten Server 2**17 (OWASP)
def hash_password(password): salt = secrets.token_bytes(16) digest = kdf(password, salt, N) return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record): _, n, salt, expected = record.split("$") actual = kdf(password, b64decode(salt), int(n)) return hmac.compare_digest(b64decode(expected), actual)class Row(NamedTuple): email: str salt: str hash: str
def sha256(text): return hashlib.sha256(text.encode()).hexdigest()
# Der alte Code des Shops: SHA-256 ohne Salt.def register(email, password): return Row(email, "", sha256(password))# Ein eigener zufälliger Salt für jede Zeile.def register(email, password): salt = secrets.token_hex(16) return Row(email, salt, sha256(salt + password))N = 2**12 # Demo; auf einem echten Server 2**17 (OWASP)
def hash_password(password): salt = secrets.token_bytes(16) digest = kdf(password, salt, N) return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record): _, n, salt, expected = record.split("$") actual = kdf(password, b64decode(salt), int(n)) return hmac.compare_digest(b64decode(expected), actual)Schritt für Schritt, in Worten
- Die Tabelle users mit dem alten Code des Shops: eine E-Mail und das SHA-256 des Passworts, ohne Salt.
- Gleiche Passwörter ergeben gleiche Hashes. ana und vera haben denselben Wert, also zeigt die Tabelle selbst, wer ein Passwort teilt: 2 Zeilen.
- Jetzt bekommt jede Zeile einen eigenen zufälligen Salt, und die Zeile speichert SHA-256(Salt + Passwort).
- 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.
- 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.
- Der Eintrag speichert alles, was man später zur Prüfung braucht: Algorithmus $ Kosten $ Salt $ Hash. Das Passwort selbst steht nicht darin.
- Bei der Anmeldung liest der Code die Kosten und den Salt aus dem Eintrag und hasht das eingegebene Passwort genauso.
- 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.def b64(data): return b64encode(data).decode()
def kdf(password, salt, n): return hashlib.scrypt(password.encode(), salt=salt, n=n, r=8, p=1, dklen=32)hashlib.scrypt nimmt n (die Kosten, eine Zweierpotenz), r (die Blockgröße), p (Parallelität) und dklen (die Länge in Bytes).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.
N = 2**12 # Demo; auf einem echten Server 2**17 (OWASP)
def hash_password(password): salt = secrets.token_bytes(16) digest = kdf(password, salt, N) return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record): _, n, salt, expected = record.split("$") actual = kdf(password, b64decode(salt), int(n)) return hmac.compare_digest(b64decode(expected), actual)record = hash_password("qwerty")parts = record.split("$")print(f"Algorithmus und Kosten: {parts[0]}, {parts[1]}")print("Anmeldung mit dem richtigen Passwort:", verify("qwerty", record))print("Anmeldung mit einem falschen Passwort:", verify("qwerty1", record))print("Ein Passwort, zwei Einträge, gleich:", record == hash_password("qwerty"))Algorithmus und Kosten: scrypt, 4096Anmeldung mit dem richtigen Passwort: TrueAnmeldung mit einem falschen Passwort: FalseEin Passwort, zwei Einträge, gleich: FalseDie 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?
first = hash_password("qwerty")second = hash_password("qwerty")print(first == second)print(verify("qwerty", first))print(verify("qwerty", second))Jeder Eintrag hat einen eigenen zufälligen Salt, also sind die beiden Einträge verschieden. Die Prüfung liest den Salt aus dem Eintrag, deshalb passt "qwerty" bei beiden.
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?
actual = kdf(password, b64decode(salt), int(n))
return hmac.(b64decode(expected), actual)hmac.compare_digest vergleicht alle Bytes, egal wo der erste Unterschied liegt.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.
Ein Salt für alle Zeilen ist so gut wie keiner: Gleiche Passwörter ergeben weiter gleiche Einträge, und eine Berechnung zählt weiter für die ganze Tabelle. Der Salt muss zufällig und für jede Zeile anders sein, bei jedem Aufruf von hash_password neu erzeugt.
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 hash_password die Kosten als Parameter, und jeder Eintrag speichert seine eigenen, sodass die alten Einträge weiter funktionieren.
N = 2**12 # letztes Jahr war es 2**11
def cost_of(record): return int(record.split("$")[1])
def login(account, password): if not verify(password, account.record): return False if cost_of(account.record) < N: # erst jetzt haben wir das Passwort in der Hand: Hash mit den neuen Kosten account.record = hash_password(password, N) return Trueaccount = Account(hash_password("qwerty", 2**11))print("Kosten vor der Anmeldung:", cost_of(account.record))print("Anmeldung:", login(account, "qwerty"))print("Kosten nach der Anmeldung:", cost_of(account.record))Kosten vor der Anmeldung: 2048Anmeldung: TrueKosten nach der Anmeldung: 4096Genauer, 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
maxmemangehoben; - 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 Pakete argon2-cffi und bcrypt (die Standardbibliothek hat keins von beiden). Django speichert Passwörter genau in der Form dieser Lektion: pbkdf2_sha256$<iterations>$<salt>$<hash>, und hasht bei der Anmeldung neu, wenn die Kosten steigen.
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 change_password(account, old_password, new_password), das:
- das aktuelle Passwort mit
verifyprüft und ablehnt, wenn es falsch ist; - ein neues Passwort ablehnt, das kürzer als 12 Zeichen ist;
- ein neues Passwort ablehnt, das (ohne Beachtung von Groß- und Kleinschreibung) in der Liste
password1234,qwerty123456,123456789012steht; - 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:
Falsches aktuelles PasswortDas neue Passwort ist kürzer als 12 ZeichenDieses Passwort steht auf den Listen geleakter PasswörterPasswort geändertAnmeldung mit dem neuen Passwort: TrueWeitere Aufgaben
- Ein neuer Hash bei der Anmeldung. Füge
loginaus „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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Hashcodes und Hashfunktionen · Lambdas · List Comprehensions und Generatoren · dataclass, match und Muster
- Geht weiter in: Sitzungen, Cookies und CSRF · JWT und seine Fallen · Kryptografie, die du nicht selbst schreibst
- Aufgaben zum Thema: Sicherheit für Entwickler