Passwörter sicher speichern · Übung
Ein Datensatz, der seine eigenen Einstellungen trägt
Der Befehl, den du am Ende von Lektion 3 ausgeführt hast, hat bei jedem Lauf andere Bytes ausgegeben. Um ein Passwort nächste Woche zu prüfen, muss der Server dieselben Bytes noch einmal berechnen, und dafür braucht er dasselbe n, r und p. Alle vier müssen mit dem Hash gespeichert werden. Diese Lektion speichert sie auf die einfachste Weise, die es gibt: in einer Textzeile.
Eine Textzeile
Ein Passwort-Datensatz (password record) sieht in diesem Kurs so aus. Er ist eine Textzeile; hier ist er nach dem Salt umbrochen, damit er auf die Seite passt:
scrypt$16384$8$1$Iiddrf4ZE7Bp5ctR/ENjYg==$
SedCTnOCi3DaMFK3PxyvJVqZ0Bt9WC7EjAgXnWwxjuQ=
Er hat sechs Teile, verbunden durch $:
die Bezeichnung
scrypt, die sagt, welcheden Datensatz erzeugt hat. Eine spätere Version des Dienstes kann dann eine andere Funktion hinzufügen und alte Datensätze trotzdem von neuen unterscheiden; n,rundpals einfache ganze Zahlen: die Kosten, mit denen dieser Datensatz erzeugt wurde;das Salt;
der Hash.
Salt und Hash sind Bytes, und eine Textzeile kann keine beliebigen Bytes aufnehmen. Also kommen sie als base64.b64encode(...).decode("ascii"). Standard-Base64 verwendet Buchstaben, Ziffern, +, / und = und nie $, also kann das Trennzeichen nicht innerhalb eines Teils vorkommen.
Andere Passwort-Bibliotheken schreiben Datensätze nach demselben Prinzip. Wenn du je einen gespeicherten $2b$12$ beginnt, ist das ein Datensatz, den ein anderer Passwort-Hash erzeugt hat, bcrypt, mit seinen Kosten im zweiten Teil.
make_record(password, *, n, r, p) baut diese Zeile: mit new_salt() ein neues Salt ziehen, mit hash_password(...) hashen und die sechs Teile zusammensetzen. Ein
Beachte, was der Datensatz nicht enthält: das Passwort. Alles darin kann man gefahrlos neben einem Benutzernamen speichern. Das Salt ist absichtlich öffentlich, die Einstellungen sind öffentlich, und der Hash ist die Ausgabe der langsamen Funktion. Ein Dieb, der den Datensatz liest, muss raten, und jeder Versuch kostet einen scrypt-Aufruf mit den Einstellungen des Datensatzes.
Verifizieren
verify_password(password, record) geht den umgekehrten Weg.
Teile den Datensatz an
$. Wenn das nicht sechs Teile ergibt oder der erste Teil nichtscryptist, ist der Text kein Datensatz dieser Art, und die Antwort istFalse.Dekodiere das Salt und den gespeicherten Hash mit
base64.b64decode(...).Hashe das Passwort mit
hash_password(...), mit dem Salt und den drei Zahlen aus dem Datensatz. Verwendeint(...)für die Zahlen: Sie kommen als Text aus dem Teilen heraus.Vergleiche den neuen Hash mit dem gespeicherten und gib die Antwort zurück.
Schritt 3 hat eine Falle. Die Einstellungen, die du verwenden musst, sind die des Datensatzes, nicht das heutige COST_N. Ein Datensatz, der mit n=4096 erzeugt wurde, lässt sich nur mit n=4096 reproduzieren.
Schritt 4 ist nach dem Tag in signing.py die zweite Stelle in diesem Projekt, an der ein Geheimnis auf einen erwarteten Wert trifft. Also gilt die Regel aus Kapitel 2: Vergleiche die beiden Byte-hmac.compare_digest und gib zurück, was es zurückgibt.
Ein Datensatz gilt hier als vertrauenswürdig. Er kommt aus dem eigenen Zustand des Servers, nicht aus einer Anfrage, also verteidigt sich verify_password nicht gegen einen Datensatz mit absurden Zahlen darin. In den Worten von Kapitel 1: Ein Datensatz überquert die Vertrauensgrenze nie nach innen. Das Passwort tut es, bei jeder Anmeldung.
Ein Datensatz wurde mit n=4096 erzeugt. Heute ist COST_N 16384. Welches n übergibt verify_password für diesen Datensatz an scrypt?
Die Kosten steigen
needs_rehash(record, *, n) beantwortet eine Frage: Wurde dieser Datensatz mit niedrigeren Kosten als n erzeugt? Lies das n des Datensatzes aus seinem zweiten Teil und vergleiche. Vergleiche Zahlen, keinen Text:
Was gibt das aus?
print("4096" < "16384", 4096 < 16384)
Der Vergleich ist „niedriger als“, nicht „verschieden von“. Ein Datensatz, der stärker ist als die heutige Einstellung, darf nie durch einen schwächeren ersetzt werden.
Einen schwachen Datensatz durch einen frischen zu den heutigen Kosten zu ersetzen nennt man Rehashing, und es hat einen Haken: Um einen Datensatz zu erzeugen, braucht man das Passwort, und der Server hat es nicht. Der einfachste Moment für das Upgrade ist deshalb eine korrekte Anmeldung, wenn der Benutzer das Passwort gerade gesendet hat. Lektion 6 tut das. Ein Dienst, der nicht warten kann, bis sich jeder Benutzer anmeldet, hat einen anderen Weg, den dieser Kurs nennt und nicht baut: Er kann jeden alten Hash sofort in die stärkere Funktion einwickeln und den eingewickelten Datensatz bei der nächsten Anmeldung durch einen sauberen ersetzen.
Deine Aufgabe
Schreib in passwords.py make_record, verify_password und needs_rehash wie oben beschrieben.
Schreib dann einen Angriffstest. Der Angriff ist die Beobachtung aus Lektion 1: In der geleakten Tabelle haben gleiche Digests verraten, dass zwei Benutzer dasselbe Passwort hatten. Schreib in tests/test_attacks.py unter dem Kommentar für Kapitel 3 test_attack_equal_passwords_get_different_records. Erzeuge mit passwords.make_record(...) bei n=16, r=8, p=1 zwei Datensätze für dasselbe Passwort und sichere zu, dass sie sich unterscheiden. Die niedrigen Kosten halten den Test schnell, und genau dafür sind die Kostenparameter da.
Führ die Tests aus:
python -m pytest -q
Der Lauf endet mit 1 failed, 5 passed. Der eine Fehlschlag ist immer noch der Test aus Kapitel 1. Er wird in Lektion 6 grün.
Klicke dann auf Check my work. Ein Datensatz trägt jetzt alles, was nötig ist, um ein Passwort gegen ihn zu prüfen. Die nächste Lektion sieht sich an, was eine Anmeldung verrät, wenn es keinen Datensatz zum Prüfen gibt.
Aufgabe
Schreib in passwords.py drei
make_record(password, *, n, r, p)zieht mitnew_salt()ein, hasht mit hash_password(...)und gibt den Textscrypt$n$r$p$salt$hashzurück, wobein,r,pdie Zahlen sind und Salt und Hashbase64.b64encode(...).decode("ascii").verify_password(password, record)gibtFalsezurück, wenn sich der Datensatz an$nicht in sechs Teile teilen lässt oder sein erster Teil nichtscryptist. Andernfalls hasht es das Passwort mit dem Salt des Datensatzes und dessenn,rundpund gibthmac.compare_digest(...)der beiden Byte-zurück. needs_rehash(record, *, n)gibtTruezurück, wenn dasndes Datensatzes, als Zahl, niedriger ist alsn.
Schreib dann in tests/test_attacks.py test_attack_equal_passwords_get_different_records: Erzeuge mit passwords.make_record(...) bei n=16, r=8, p=1 zwei Datensätze für dasselbe Passwort und sichere zu, dass sie sich unterscheiden.
Führ im python -m pytest -q aus: Es muss mit 1 failed, 5 passed enden, wobei der eine Fehlschlag der Test aus Kapitel 1 ist.
Klicke dann auf Check my work. Es ruft die drei Funktionen mit Passwörtern und Kosten auf, die du noch nicht gesehen hast, und hält dabei die Aufrufe von hashlib.scrypt, hmac.compare_digest und secrets.token_bytes fest. Ein Datensatz muss die sechs Teile haben, ein Salt aus einem Aufruf von secrets.token_bytes(16) und den Hash, den scrypt für dieses Salt und diese Kosten liefert. verify_password() muss einen scrypt-Aufruf mit dem eigenen n des Datensatzes machen, über hmac.compare_digest entscheiden und für vier Texte, die keine Datensätze sind, False zurückgeben. needs_rehash() wird für einen Datensatz mit n=4096 gegen höhere, gleiche und niedrigere Kosten geprüft. Zuletzt führt es deinen Test über seinen Namen aus, wobei passwords.make_record durch andere Versionen ersetzt wird: Er muss gegen eine korrekte durchlaufen und gegen eine fehlschlagen, die jedes Mal dasselbe Salt verwendet, und gegen eine, die einen einfachen Digest speichert. Nichts wird gemessen, außer dass ein Test von dir, der länger als 3 Sekunden läuft, gestoppt wird, und nichts aus deinem Terminal wird bewertet.