0%

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 echter Namen wird jeder spätere Angriff billiger, weil kein Versuch an ein Konto verschwendet wird, das es nicht gibt.

Diese Lektion prüft, ob deine Anmeldung das verrät. Die Antwort hat zwei Hälften.

Die Antworten stimmen schon überein

Das ist die , die heute in deinem Server über eine Anmeldung entscheidet:

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 with Python, und sie bleibt. Die erste Hälfte ist in Ordnung.

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 False zurü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 zurück, dessen Eintrag "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:

  1. Erzeuge ein Benutzer-Dictionary mit einem Datensatz, zu niedrigen Kosten.

  2. Starte dann den Zähler. Wenn du ihn nach Schritt 1 startest, bleibt der scrypt-Aufruf von make_record aus der Zählung heraus.

  3. Ruf passwords.check_login(...) für einen Namen auf, der nicht im Dictionary steht.

  4. Sichere drei Dinge zu: Die Antwort ist False, counter["calls"] ist 1, und counter["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

  1. Schreib in tests/test_attacks.py test_attack_unknown_user_costs_the_same_work und sieh zu, wie er gegen das naheliegende check_login fehlschlägt.

  2. Stell in passwords.py check_login(users, name, password) so fertig, dass jeder Aufruf genau einen scrypt-Aufruf macht.

  3. Führ python -m pytest -q aus. Es endet mit 1 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 False zurückgegeben hat, dass counter["calls"] 1 ist und dass counter["n"] [passwords.COST_N] ist.

Schreib in passwords.py check_login(users, name, password):

  • es gibt nur dann True zurück, wenn name in users steht und password gegen diesen Datensatz verifiziert wird;

  • für einen Namen, der nicht in users steht, ruft es verify_password(password, DUMMY_RECORD) auf und gibt False zurück;

  • jeder Aufruf von check_login macht 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 des Dummy-Datensatzes verwenden. Dann führt es deinen Test über seinen Namen aus, wobei 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.