0%

Wachtwoorden veilig opslaan · oefening

Hetzelfde antwoord voor elke verkeerde login

Een loginformulier dat voor de ene naam “no such user” zegt en voor een andere “wrong password”, heeft een aanvaller al iets verteld voordat hij één wachtwoord raadt: welke namen echte accounts zijn. Uitzoeken welke gebruikersnamen bestaan, heet gebruikersenumeratie (user enumeration). Met een van echte namen wordt elke volgende aanval goedkoper, omdat geen gok verspild wordt aan een account dat er niet is.

Deze les controleert of je login dat verraadt. Het antwoord heeft twee helften.

De antwoorden zijn al gelijk

Dit is de die vandaag in je server over een login beslist:

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"]

Een naam die niet in state["users"] staat en een verkeerd wachtwoord voor een echte naam eindigen allebei in return None. handle() beantwoordt beide met hetzelfde antwoord: status 401, dezelfde headers en dezelfde body, {"error": "credentials are missing or wrong"}. Niets in het antwoord onderscheidt de twee gevallen.

Dat was een keuze die with Python maakte, en die blijft. De eerste helft is in orde.

Het werk niet

De tweede helft verschijnt zodra wachtwoorden als records worden opgeslagen. Schrijf eerst de voor de hand liggende check_login(users, name, password) in passwords.py:

  • zoek het record op met users.get(name);

  • als er geen is, geef False terug;

  • geef anders verify_password(password, record) terug.

Voer daarna het scenario van het hulpprogramma van de cursus voor deze les uit:

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

Een verkeerd wachtwoord voor ana kost één scrypt-aanroep. Een login voor zoe, die niet bestaat, kost er geen, omdat er geen record is om tegen te hashen. Beide antwoorden zijn False, en een ervan kostte een scrypt-aanroep langer om te maken.

Deze site kan dat verschil niet timen, dus pl_sec.py telt de aanroepen en drukt de n van elke aanroep af. Een echte aanvaller heeft een klok. Een scrypt-aanroep is met opzet traag, en dat maakt hem een van de makkelijker te meten verschillen: antwoorden die snel terugkomen, horen bij namen die niet bestaan.

Wat is er met de voor de hand liggende check_login anders tussen een login voor een naam die bestaat (verkeerd wachtwoord) en een voor een naam die niet bestaat?

De aanvalstest, rood

Schrijf de test vóór de oplossing. Net als in hoofdstuk 2 heeft de aanval zelf een klok nodig, dus de test controleert wat hem tegenhoudt. helpers.count_scrypt() werkt als zijn tweelingbroer voor compare_digest: hij geeft een terug waarvan de vermelding "calls" vanaf dan de aanroepen van hashlib.scrypt telt. Hij heeft ook een vermelding "n": een lijst met de kosten n van elke aanroep, op volgorde.

Schrijf in tests/test_attacks.py test_attack_unknown_user_costs_the_same_work:

  1. Maak een users-dict met één record, bij lage kosten.

  2. Start daarna de teller. Door hem na stap 1 te starten, blijft de scrypt-aanroep van make_record buiten de telling.

  3. Roep passwords.check_login(...) aan voor een naam die niet in de dict staat.

  4. Controleer drie dingen: het antwoord is False, counter["calls"] is 1, en counter["n"] is [passwords.COST_N].

De derde assert doet er net zoveel toe als de tweede. Eén scrypt-aanroep bij n=2 is één aanroep en bijna geen werk. De eigenschap die je wilt, is gelijk werk, dus de test noemt ook de kosten.

Voer python -m pytest -q uit. Je nieuwe test faalt met assert 0 == 1: nul aanroepen.

Een dummyrecord

De oplossing is een naam die niet bestaat iets te geven om tegen te hashen. passwords.py heeft daar een constante voor, DUMMY_RECORD. Het is een echt record, gemaakt bij COST_N, COST_R en COST_P, voor een wachtwoord dat niemand kent.

Roep voor een naam die niet in users staat verify_password(password, DUMMY_RECORD) aan, dezelfde functie waar een echte controle doorheen gaat, en geef daarna False terug, wat die ook antwoordde. De aanroep is er voor zijn kosten. Zijn antwoord wordt weggegooid.

Voor een naam die niet in users staat, geeft verify_password(password, DUMMY_RECORD) een of ander antwoord. Wat geeft check_login terug?

Voer de tests en het scenario opnieuw uit. De test slaagt, en alle drie de regels van login-cost eindigen nu op hashlib.scrypt calls: 1 (n=16384), met het resultaat a wrong password and an unknown user cost the same: 1 scrypt call at n=16384 each.

Wat dit wel en niet gelijktrekt

Twee beperkingen, eerlijk gezegd.

De kosten zijn gelijk tussen records die met de kosten van vandaag gemaakt zijn. Een record dat met oudere, lagere kosten gemaakt is, is goedkoper te controleren dan de dummy, dus een klok zou de naam van die gebruiker nog steeds kunnen onderscheiden van een onbekende. Het record van ben is zo’n record, en les 6 upgradet het.

Een verzoek zonder enige inloggegevens wordt beantwoord zonder scrypt-aanroep. Dat verraadt niets, omdat het geen gebruiker noemt.

Je opdracht

  1. Schrijf in tests/test_attacks.py test_attack_unknown_user_costs_the_same_work en zie hem falen tegen de voor de hand liggende check_login.

  2. Maak in passwords.py check_login(users, name, password) zo af dat elke aanroep precies één scrypt-aanroep doet.

  3. Voer python -m pytest -q uit. Het eindigt met 1 failed, 6 passed: alleen de test uit hoofdstuk 1 is nog rood.

Klik daarna op Check my work. Elk onderdeel van de wachtwoordcontrole bestaat nu en is apart getest. Het project in de volgende les zet ze achter de loginroute.

Opdracht

Laat een login voor een onbekende naam hetzelfde werk kosten als elke andere, en bewijs het met een test.

Schrijf in tests/test_attacks.py test_attack_unknown_user_costs_the_same_work:

  • maak users = {"ana": passwords.make_record("correct horse", n=16, r=8, p=1)};

  • start daarna counter = helpers.count_scrypt();

  • roep passwords.check_login(users, "zoe", "any guess") aan;

  • controleer dat die False teruggaf, dat counter["calls"] 1 is, en dat counter["n"] [passwords.COST_N] is.

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

  • hij geeft alleen True terug als name in users staat en password tegen dat record geverifieerd wordt;

  • voor een naam die niet in users staat, roept hij verify_password(password, DUMMY_RECORD) aan en geeft hij False terug;

  • elke aanroep van check_login doet precies één scrypt-aanroep.

Voer python pl_sec.py login-cost en python -m pytest -q uit in de .

Klik daarna op Check my work. Die roept check_login() acht keer aan met gebruikers en wachtwoorden die je niet hebt gezien, één keer zonder gebruikers, met een eigen record op de plaats van DUMMY_RECORD, en legt elke aanroep van hashlib.scrypt vast: elke login moet precies True of False teruggeven en precies één aanroep doen, en voor een naam die niet in users staat, moet die aanroep de eigen n, r, p en van het dummyrecord gebruiken. Daarna voert hij je test op naam uit met passwords.check_login vervangen door andere versies: hij moet slagen tegen een correcte, falen tegen een die een onbekende naam beantwoordt zonder scrypt-aanroep, en falen tegen een waarvan de aanroep voor een onbekende naam bij n=2 draait. Er wordt niets getimed, behalve dat een test van jou die langer dan 3 seconden draait, wordt gestopt, en niets uit je terminal wordt beoordeeld.