Passwörter sicher speichern · Übung
Dieselbe Antwort für jede falsche Anmeldung
Ein Anmeldeformular, das bei einem Namen „no such user“ sagt und bei einem anderen „wrong password“, hat einem Angreifer etwas verraten, bevor er ein einziges Passwort rät: welche Namen echte Konten sind. Herauszufinden, welche Benutzernamen existieren, nennt man Benutzer-Enumeration (user enumeration). Mit einer
Diese Lektion prüft, ob deine Anmeldung das verrät. Die Antwort hat zwei Hälften.
Die Antworten stimmen schon überein
Das ist die
def login_user(state, credentials):
"""Return the user name when credentials hold a user's own password, or None."""
# Lesson 6 rewrites this function. It still compares plain text.
if credentials is None:
return None
if state["users"].get(credentials["user"]) != credentials["password"]:
return None
return credentials["user"]
Ein Name, der nicht in state["users"] steht, und ein falsches Passwort für einen echten Namen enden beide in return None. handle() beantwortet beide mit derselben Antwort: Status 401, dieselben Header und derselbe Body, {"error": "credentials are missing or wrong"}. Nichts in der Antwort unterscheidet die beiden Fälle.
Das war eine Entscheidung von
Die Arbeit stimmt nicht überein
Die zweite Hälfte zeigt sich, sobald Passwörter als Datensätze gespeichert werden. Schreib zuerst das naheliegende check_login(users, name, password) in passwords.py:
schlag den Datensatz mit
users.get(name)nach;wenn es keinen gibt, gib
Falsezurück;andernfalls gib
verify_password(password, record)zurück.
Führ dann das Szenario des Kurs-Hilfsprogramms für diese Lektion aus:
python pl_sec.py login-cost
pl_sec: login-cost, your passwords.py (no server, no socket)
--- three logins through your check_login() ---
ana, with the right password -> True hashlib.scrypt calls: 1 (n=16384)
ana, with a wrong password -> False hashlib.scrypt calls: 1 (n=16384)
zoe, who is not a user -> False hashlib.scrypt calls: 0
--- result ---
an unknown user costs 0 scrypt calls and a known one 1: the work tells them apart
Ein falsches Passwort für ana kostet einen scrypt-Aufruf. Eine Anmeldung für zoe, die es nicht gibt, kostet keinen, weil es keinen Datensatz gibt, gegen den gehasht werden könnte. Beide Antworten sind False, und die Erzeugung einer davon hat einen scrypt-Aufruf länger gedauert.
Diese Website kann diesen Unterschied nicht messen, also zählt pl_sec.py die Aufrufe und gibt das n jedes Aufrufs aus. Ein echter Angreifer hat eine Uhr. Ein scrypt-Aufruf ist absichtlich langsam, was ihn zu einem der leichter messbaren Unterschiede macht: Antworten, die schnell zurückkommen, gehören zu Namen, die es nicht gibt.
Was unterscheidet mit dem naheliegenden check_login eine Anmeldung für einen existierenden Namen (falsches Passwort) von einer für einen Namen, den es nicht gibt?
Der Angriffstest, rot
Schreib den Test vor der Behebung. Wie in Kapitel 2 braucht der Angriff selbst eine Uhr, also prüft der Test, was ihn aufhält. helpers.count_scrypt() funktioniert wie sein Zwilling für compare_digest: Es gibt ein "calls" ab da die Aufrufe von hashlib.scrypt zählt. Es hat außerdem einen Eintrag "n": eine Liste mit den Kosten n jedes Aufrufs, in der Reihenfolge der Aufrufe.
Schreib in tests/test_attacks.py test_attack_unknown_user_costs_the_same_work:
Erzeuge ein Benutzer-Dictionary mit einem Datensatz, zu niedrigen Kosten.
Starte dann den Zähler. Wenn du ihn nach Schritt 1 startest, bleibt der scrypt-Aufruf von
make_recordaus der Zählung heraus.Ruf
passwords.check_login(...)für einen Namen auf, der nicht im Dictionary steht.Sichere drei Dinge zu: Die Antwort ist
False,counter["calls"]ist 1, undcounter["n"]ist[passwords.COST_N].
Die dritte Zusicherung zählt so viel wie die zweite. Ein scrypt-Aufruf bei n=2 ist ein Aufruf und fast keine Arbeit. Die Eigenschaft, die du willst, ist gleiche Arbeit, also nennt der Test auch die Kosten.
Führ python -m pytest -q aus. Dein neuer Test schlägt mit assert 0 == 1 fehl: null Aufrufe.
Ein Dummy-Datensatz
Die Behebung ist, einem Namen, den es nicht gibt, etwas zu geben, gegen das gehasht werden kann. passwords.py hat dafür eine Konstante, DUMMY_RECORD. Sie ist ein echter Datensatz, erzeugt mit COST_N, COST_R und COST_P, für ein Passwort, das niemand kennt.
Ruf für einen Namen, der nicht in users steht, verify_password(password, DUMMY_RECORD) auf, dieselbe Funktion, durch die eine echte Prüfung geht, und gib dann False zurück, egal was sie geantwortet hat. Der Aufruf ist wegen seiner Kosten da. Seine Antwort wird weggeworfen.
Für einen Namen, der nicht in users steht, gibt verify_password(password, DUMMY_RECORD) irgendeine Antwort zurück. Was gibt check_login zurück?
Führ die Tests und das Szenario noch einmal aus. Der Test läuft durch, und alle drei Zeilen von login-cost enden jetzt mit hashlib.scrypt calls: 1 (n=16384), mit dem Ergebnis a wrong password and an unknown user cost the same: 1 scrypt call at n=16384 each.
Was das angleicht und was nicht
Zwei Grenzen, klar gesagt.
Die Kosten sind unter Datensätzen gleich, die zu den heutigen Kosten erzeugt wurden. Ein Datensatz, der zu älteren, niedrigeren Kosten erzeugt wurde, ist billiger zu prüfen als der Dummy, also könnte eine Uhr den Namen dieses Benutzers immer noch von einem unbekannten unterscheiden. Bens Datensatz ist so einer, und Lektion 6 bringt ihn auf den neuen Stand.
Eine Anfrage ganz ohne Zugangsdaten wird ohne jeden scrypt-Aufruf beantwortet. Das verrät nichts, weil sie keinen Benutzer nennt.
Deine Aufgabe
Schreib in
tests/test_attacks.pytest_attack_unknown_user_costs_the_same_workund sieh zu, wie er gegen das naheliegendecheck_loginfehlschlägt.Stell in
passwords.pycheck_login(users, name, password)so fertig, dass jeder Aufruf genau einen scrypt-Aufruf macht.Führ
python -m pytest -qaus. Es endet mit1 failed, 6 passed: Nur der Test aus Kapitel 1 ist noch rot.
Klicke dann auf Check my work. Jedes Stück der Passwortprüfung existiert jetzt und ist einzeln getestet. Das Projekt in der nächsten Lektion setzt sie hinter die Anmelderoute.
Aufgabe
Sorg dafür, dass eine Anmeldung für einen unbekannten Namen dieselbe Arbeit kostet wie jede andere, und beweise es mit einem Test.
Schreib in tests/test_attacks.py test_attack_unknown_user_costs_the_same_work:
erzeuge
users = {"ana": passwords.make_record("correct horse", n=16, r=8, p=1)};starte dann
counter = helpers.count_scrypt();ruf
passwords.check_login(users, "zoe", "any guess")auf;sichere zu, dass es
Falsezurückgegeben hat, dasscounter["calls"]1 ist und dasscounter["n"][passwords.COST_N]ist.
Schreib in passwords.py check_login(users, name, password):
es gibt nur dann
Truezurück, wennnameinuserssteht undpasswordgegen diesen Datensatz verifiziert wird;für einen Namen, der nicht in
userssteht, ruft esverify_password(password, DUMMY_RECORD)auf und gibtFalsezurück;jeder Aufruf von
check_loginmacht genau einen scrypt-Aufruf.
Führ im python pl_sec.py login-cost und python -m pytest -q aus.
Klicke dann auf Check my work. Es ruft check_login() achtmal mit Benutzern und Passwörtern auf, die du noch nicht gesehen hast, einmal ganz ohne Benutzer, mit einem eigenen Datensatz anstelle von DUMMY_RECORD, und hält jeden Aufruf von hashlib.scrypt fest: Jede Anmeldung muss genau True oder False zurückgeben und genau einen Aufruf machen, und für einen Namen, der nicht in users steht, muss dieser Aufruf die eigenen n, r, p und das passwords.check_login durch andere Versionen ersetzt wird: Er muss gegen eine korrekte durchlaufen, gegen eine fehlschlagen, die einen unbekannten Namen ohne scrypt-Aufruf beantwortet, und gegen eine fehlschlagen, deren Aufruf für einen unbekannten Namen bei n=2 läuft. 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.