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 das hash() 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))
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.
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))
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

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
  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.
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"))
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?

first = hash_password("qwerty")
second = hash_password("qwerty")
print(first == second)
print(verify("qwerty", first))
print(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?

actual = kdf(password, b64decode(salt), int(n))
return hmac.(b64decode(expected), actual)

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 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 True
account = 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))
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 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:

  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 ·