0%

Passwörter sicher speichern · Abschlussprojekt

Abschlussprojekt: Projekt: nirgends ein lesbares Passwort

Die in passwords.py sind fertig und getestet. Der Server benutzt sie noch nicht: sample_state() enthält immer noch zwei Passwörter im Klartext, und login_user vergleicht sie immer noch mit !=. Dieses Projekt verbindet beides und schließt die ersten Befunde des Kurses.

Die Anforderungen:

  • sample_state() speichert ANA_RECORD und BEN_RECORD unter "users" und kein Passwort.

  • login_user entscheidet über passwords.check_login.

  • Nach einer korrekten Anmeldung wird ein Datensatz, der zu niedrigeren Kosten als passwords.COST_N erzeugt wurde, durch einen neuen zu den heutigen Kosten ersetzt.

  • Ein falsches Passwort und ein unbekannter Benutzer bekommen dieselbe 401 nach derselben Anzahl von scrypt-Aufrufen, und unter Datensätzen zu den heutigen Kosten auch zu denselben Kosten.

  • Kein Passwort und kein Datensatz erscheint in einer Antwort, in einer oder in ausgegebenem Text.

  • Ein neuer Angriffstest, und die Zeilen F1 und F2 von SECURITY.md geschlossen.

„Nirgends ein lesbares Passwort“ betrifft das, was der Dienst speichert und sendet. Die zwei Beispielpasswörter stehen weiterhin in deiner Testdatei, in pl_sec.py und im Kommentar über ANA_RECORD in reading_server.py. Es sind Testwerte, und ein Test muss sie kennen. Ein echter Dienst hat keinen solchen Kommentar: Niemand außer dem Benutzer kennt das Passwort hinter einem Datensatz.

Meilenstein 1: Datensätze im Zustand

Ändere eine Zeile von sample_state() in reading_server.py, sodass "users" ana auf ANA_RECORD und ben auf BEN_RECORD abbildet. Beide Konstanten stehen weit oben in der Datei. Sie sind Datensätze für die zwei Beispielpasswörter, erzeugt mit deinem Datensatzformat.

Führ python -m pytest -q aus. Der Test aus Kapitel 1 schlägt immer noch fehl, und zwar anders: bei seiner ersten Zusicherung, mit assert 401 == 200. Die Anmeldung vergleicht immer noch das Passwort, das ana sendet, mit dem Gespeicherten, und gespeichert ist jetzt ein Datensatz. Niemand kann sich anmelden.

Genau wegen dieses Fehlschlags sichert der Test zuerst den Status zu. Ohne diese Zusicherung würde der Test jetzt durchlaufen, auf einem Server, auf dem das Passwort verschwunden ist, weil die Anmeldung kaputt ist.

Meilenstein 2: die Anmeldung

Schreib login_user(state, credentials) neu:

  • wenn credentials None ist, gib None zurück;

  • nimm den Namen und das Passwort aus credentials;

  • wenn passwords.check_login(state["users"], name, password) False ist, gib None zurück;

  • andernfalls gib den Namen zurück.

Führ die Tests aus. test_attack_backup_reveals_no_password läuft durch, zum ersten Mal seit Kapitel 1. Die ganze Datei ist grün.

Meilenstein 3: ben auf den neuen Stand bringen

BEN_RECORD wurde bei n=4096 erzeugt, einem Viertel der heutigen Kosten. Er steht für einen Datensatz aus der Zeit, bevor der Dienst seine Einstellungen erhöht hat.

Sieh dir nach einer korrekten Anmeldung den gespeicherten Datensatz an. Wenn passwords.needs_rehash(record, n=passwords.COST_N) True ist, ersetze ihn in state["users"] durch passwords.make_record(password, n=passwords.COST_N, r=passwords.COST_R, p=passwords.COST_P).

Tu das erst, nachdem check_login True zurückgegeben hat. In diesem Moment hat der Server das echte Passwort des Benutzers. Ein Datensatz, der vor der Prüfung ersetzt wird, ließe jeden bens Passwort mit einem falschen Versuch überschreiben.

Bis ben sich anmeldet, kostet ein falsches Passwort für ben ein Viertel dessen, was die Dummy-Prüfung kostet, also könnte eine Uhr seinen Namen von einem unbekannten unterscheiden. Das ist ein zweiter Grund, alte Datensätze auf den neuen Stand zu bringen.

Wo gehört das Upgrade eines schwachen Datensatzes hin?

Meilenstein 4: F2

F2 hat gesagt, dass die Anmeldung ein Geheimnis mit != vergleicht. Sieh dir dein neues login_user an: Es vergleicht nichts. Der eine Vergleich eines Geheimnisses findet jetzt in verify_password statt, mit hmac.compare_digest.

Schreib den Test, der das sagt, über den Server. Schreib in tests/test_attacks.py test_attack_login_compares_with_compare_digest: Bau reading_server.sample_state() auf, starte helpers.count_compare_digest(), sende über reading_server.handle(...) POST /login für ana mit einem falschen Passwort und sichere zu, dass der Status 401 ist und dass counter["calls"] mindestens 1 ist.

„Mindestens 1“ ist Absicht. Eine Anmeldung, die mit == vergleicht, macht gar keinen solchen Aufruf, also erwischt der Test sie. Und ein späteres Kapitel kann auf dieser Route einen weiteren sorgfältigen Vergleich hinzufügen, ohne deinen Test kaputt zu machen.

Meilenstein 5: wieder der Dieb

Führ das Szenario aus Kapitel 1 aus:

python pl_sec.py stolen-backup

Die Kopie des Zustands zeigt jetzt zwei Datensätze, und der nächste Schritt des Diebs scheitert. Jeder Datensatz ist hier nach den ersten Zeichen seines Hashs abgeschnitten:

users     'ana': 'scrypt$16384$8$1$Iiddrf4ZE7Bp5ctR/ENjYg==$SedCTnOCi...
users     'ben': 'scrypt$4096$8$1$QKGmY5EvluaM3abQzz8CUQ==$OhHmwNl+T...
log in as ana with the value stored for ana  -> 401  refused
log in as ben with the value stored for ben  -> 401  refused

Die Ergebniszeile lautet 0 of 2 stored values worked as a password. python pl_sec.py login-cost zeigt weiterhin auf allen drei Zeilen einen scrypt-Aufruf bei n=16384.

Öffne jetzt SECURITY.md. Setz den Status von F1 und F2 auf closed in 3.6 und trag test_attack_login_compares_with_compare_digest in die Zelle Test von F2 ein. Eine Zeile ist erst geschlossen, wenn ihr Test durchläuft, und beide tun das.

Ein Dieb kopiert den Zustand nach dieser Lektion. Was kann er mit anas Datensatz anfangen?

Was noch offen ist

Sei genau darin, was du geschlossen hast. Ein Dieb mit dieser Kopie kann weiterhin raten, zu einem scrypt-Aufruf pro Versuch und pro Benutzer. Deshalb zählen die Kosten, und deshalb verwendet ein echter Dienst höhere als dieser Kurs. Ein Passwort wie correct horse würde immer noch einer zum Opfer fallen, die es enthält.

Die Anmeldung erlaubt außerdem beliebig viele Versuche. Bis Kapitel 6 begrenzt nichts Versuche, die als Anfragen ankommen.

Und die Kopie enthält mehr als Passwörter: zwei Tokens, die nie ablaufen, und eine aktive Session-ID. Der Server hat noch kein Log, also ist die Anforderung zu ausgegebenem Text vorerst alles, was es zu Logs zu sagen gibt. Kapitel 6 kommt auch darauf zurück.

Führ ein letztes Mal python -m pytest -q aus. Es endet mit 8 passed. Klicke dann auf Check my work. Kapitel 4 beginnt mit der Session, die nie endet.

Aufgabe

Setz die Passwortfunktionen hinter die Anmeldung und schließ F1 und F2.

In reading_server.py:

  • Sorg dafür, dass sample_state() ANA_RECORD und BEN_RECORD unter "users" speichert.

  • Schreib login_user(state, credentials) neu. Gib None zurück, wenn credentials None ist oder wenn passwords.check_login(state["users"], name, password) False ist. Wenn nach einer korrekten Anmeldung passwords.needs_rehash(record, n=passwords.COST_N) für den gespeicherten Datensatz True ist, ersetze ihn durch passwords.make_record(password, n=passwords.COST_N, r=passwords.COST_R, p=passwords.COST_P). Gib den Benutzernamen zurück.

Schreib in tests/test_attacks.py test_attack_login_compares_with_compare_digest: Bau reading_server.sample_state() auf, starte counter = helpers.count_compare_digest(), sende über reading_server.handle(...) POST /login für ana mit einem falschen Passwort und sichere den Status 401 zu und dass counter["calls"] mindestens 1 ist.

Setz in SECURITY.md den Status von F1 und F2 auf closed in 3.6 und nenne diesen Test in der Zelle Test von F2.

Führ im python -m pytest -q (es muss mit 8 passed enden) und python pl_sec.py stolen-backup aus.

Klicke dann auf Check my work. Es ruft handle() mit Zuständen auf, deren Benutzer und Datensätze du noch nicht gesehen hast, einer davon bei n=4096 erzeugt, und hält jeden Aufruf von hashlib.scrypt und hmac.compare_digest fest. Ein richtiges Passwort muss mit 200 antworten, eine starten und kein Passwort und keinen Datensatz in der Antwort, der Session oder dem ausgegebenen Text hinterlassen. Ein falsches Passwort und ein unbekannter Benutzer müssen dieselbe 401 nach je einem scrypt-Aufruf bekommen, mit denselben n, r und p. Die Entscheidung muss über hmac.compare_digest gehen. Der Datensatz bei n=4096 muss nach einem richtigen Passwort ersetzt und nach einem falschen in Ruhe gelassen werden, und ein Datensatz, der schon bei COST_N ist, muss bleiben. Dann führt es vier deiner Tests über ihre Namen aus (den aus Kapitel 1, die zwei aus den Lektionen 4 und 5 und den neuen) gegen einen korrekten Dienst, wo alle durchlaufen müssen, und gegen sechs kaputte, wo der jeweils passende Test fehlschlagen muss. Zuletzt liest es die Zeilen F1 und F2 von SECURITY.md. 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.