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
Deze les controleert of je login dat verraadt. Het antwoord heeft twee helften.
De antwoorden zijn al gelijk
Dit is de
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
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
Falseterug;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 "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:
Maak een users-dict met één record, bij lage kosten.
Start daarna de teller. Door hem na stap 1 te starten, blijft de scrypt-aanroep van
make_recordbuiten de telling.Roep
passwords.check_login(...)aan voor een naam die niet in de dict staat.Controleer drie dingen: het antwoord is
False,counter["calls"]is 1, encounter["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
Schrijf in
tests/test_attacks.pytest_attack_unknown_user_costs_the_same_worken zie hem falen tegen de voor de hand liggendecheck_login.Maak in
passwords.pycheck_login(users, name, password)zo af dat elke aanroep precies één scrypt-aanroep doet.Voer
python -m pytest -quit. Het eindigt met1 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
Falseteruggaf, datcounter["calls"]1 is, en datcounter["n"][passwords.COST_N]is.
Schrijf in passwords.py check_login(users, name, password):
hij geeft alleen
Trueterug alsnameinusersstaat enpasswordtegen dat record geverifieerd wordt;voor een naam die niet in
usersstaat, roept hijverify_password(password, DUMMY_RECORD)aan en geeft hijFalseterug;elke aanroep van
check_logindoet 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 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.