0%

Wachtwoorden veilig opslaan · oefening

Een record dat zijn eigen instellingen meedraagt

Het commando dat je aan het eind van les 3 uitvoerde, drukte bij elke run andere bytes af. Om volgende week een wachtwoord te controleren, moet de server dezelfde bytes opnieuw berekenen, en daarvoor zijn dezelfde en dezelfde n, r en p nodig. Alle vier moeten met de hash worden opgeslagen. Deze les slaat ze op de eenvoudigst mogelijke manier op: in één regel tekst.

Eén regel tekst

Een wachtwoordrecord ziet er in deze cursus zo uit. Het is één regel tekst; hier is hij na de salt afgebroken zodat hij op de pagina past:

scrypt$16384$8$1$Iiddrf4ZE7Bp5ctR/ENjYg==$
SedCTnOCi3DaMFK3PxyvJVqZ0Bt9WC7EjAgXnWwxjuQ=

Hij heeft zes delen, verbonden door $:

  • het label scrypt, dat zegt welke het record maakte. Een latere versie van de dienst kan dan een andere functie toevoegen en oude records nog steeds van nieuwe onderscheiden;

  • n, r en p, als gewone gehele getallen: de kosten waarmee dit record gemaakt is;

  • de salt;

  • de hash.

De salt en de hash zijn bytes, en een regel tekst kan geen willekeurige bytes bevatten. Daarom gaan ze erin als , met base64.b64encode(...).decode("ascii"). Standaard-base64 gebruikt letters, cijfers, +, / en =, en nooit $, dus het scheidingsteken kan niet binnen een deel voorkomen.

Andere wachtwoordbibliotheken schrijven records in dezelfde geest. Als je ooit een opgeslagen ziet die begint met $2b$12$, is dat een record gemaakt door een andere wachtwoordhash, bcrypt, met zijn kosten in het tweede deel.

make_record(password, *, n, r, p) bouwt deze regel: trek een nieuwe salt met new_salt(), hash met hash_password(...), en zet de zes delen bij elkaar. Een doet het in één regel.

Let op wat het record niet bevat: het wachtwoord. Alles erin kun je veilig naast een gebruikersnaam opslaan. De salt is bewust openbaar, de instellingen zijn openbaar, en de hash is de uitvoer van de trage functie. Een dief die het record leest, moet raden, en elke gok kost één scrypt-aanroep bij de instellingen van het record.

Verifiëren

verify_password(password, record) gaat de andere kant op.

  1. Splits het record op $. Als dat geen zes delen geeft, of het eerste deel niet scrypt is, is de tekst geen record van deze soort, en het antwoord is False.

  2. Decodeer de salt en de opgeslagen hash met base64.b64decode(...).

  3. Hash het wachtwoord met hash_password(...), met de salt en de drie getallen uit het record. Gebruik int(...) op de getallen: ze komen als tekst uit de split.

  4. Vergelijk de nieuwe hash met de opgeslagen hash, en geef het antwoord terug.

Stap 3 bevat een valkuil. De instellingen die je moet gebruiken, zijn die van het record zelf, niet de COST_N van vandaag. Een record dat bij n=4096 gemaakt is, kan alleen bij n=4096 gereproduceerd worden.

Stap 4 is de tweede plek in dit project waar een geheim één verwachte waarde ontmoet, na de tag in signing.py. Dus de regel uit hoofdstuk 2 geldt: vergelijk de twee bytestrings met hmac.compare_digest, en geef terug wat die teruggeeft.

Een record is hier vertrouwde data. Het komt uit de eigen state van de server, niet uit een verzoek, dus verify_password verdedigt zich niet tegen een record met absurde getallen erin. In de woorden van hoofdstuk 1: een record gaat de vertrouwensgrens nooit naar binnen over. Het wachtwoord wel, bij elke login.

Een record is gemaakt bij n=4096. Vandaag is COST_N 16384. Welke n geeft verify_password voor dit record aan scrypt mee?

Kosten gaan omhoog

wordt elk jaar sneller, dus een dienst verhoogt af en toe zijn kosten. Records die vóór de verandering gemaakt zijn, blijven op de oude kosten. Daarom staan de kosten in het record: de dienst kan records van verschillende sterkte naast elkaar bewaren, en kan zien welke welke is.

needs_rehash(record, *, n) beantwoordt één vraag: is dit record gemaakt met lagere kosten dan n? Lees de n van het record uit het tweede deel en vergelijk. Vergelijk getallen, geen tekst:

Wat drukt dit af?

print("4096" < "16384", 4096 < 16384)

De vergelijking is “lager dan”, niet “anders dan”. Een record dat sterker is dan de instelling van vandaag, mag nooit door een zwakker worden vervangen.

Een zwak record vervangen door een nieuw record met de kosten van vandaag heet rehashen, en het heeft een addertje: een record maken vereist het wachtwoord, en de server heeft dat niet. Het eenvoudigste moment om te upgraden is daarom een geslaagde login, als de gebruiker net het wachtwoord heeft gestuurd. Les 6 doet dat. Een dienst die niet kan wachten tot elke gebruiker inlogt, heeft een andere manier, die deze cursus noemt en niet bouwt: hij kan elke oude hash meteen in de sterkere functie verpakken, en het verpakte record bij de volgende login door een schoon record vervangen.

Je opdracht

Schrijf in passwords.py make_record, verify_password en needs_rehash zoals hierboven beschreven.

Schrijf daarna één aanvalstest. De aanval is de waarneming uit les 1: in de gelekte tabel verraadden gelijke digests dat twee gebruikers hetzelfde wachtwoord hadden. Schrijf in tests/test_attacks.py, onder het commentaar van hoofdstuk 3, test_attack_equal_passwords_get_different_records. Maak met passwords.make_record(...) twee records voor hetzelfde wachtwoord bij n=16, r=8, p=1, en controleer dat ze verschillen. De lage kosten houden de test snel, en daar zijn de kostenparameters voor.

Voer de tests uit:

python -m pytest -q

De run eindigt met 1 failed, 5 passed. De enige mislukking is nog steeds de test uit hoofdstuk 1. Die wordt groen in les 6.

Klik daarna op Check my work. Een record draagt nu alles mee wat nodig is om er een wachtwoord tegen te controleren. De volgende les bekijkt wat een login verraadt als er geen record is om tegen te controleren.

Opdracht

Schrijf in passwords.py drie .

  • make_record(password, *, n, r, p) trekt een met new_salt(), hasht met hash_password(...), en geeft de tekst scrypt$n$r$p$salt$hash terug, waarin n, r, p de getallen zijn en de salt en de hash base64.b64encode(...).decode("ascii") zijn.

  • verify_password(password, record) geeft False terug als het record niet in zes delen splitst op $ of het eerste deel niet scrypt is. Anders hasht hij het wachtwoord met de salt van het record en de n, r en p van het record, en geeft hij hmac.compare_digest(...) van de twee bytestrings terug.

  • needs_rehash(record, *, n) geeft True terug als de n van het record, als getal, lager is dan n.

Schrijf daarna in tests/test_attacks.py test_attack_equal_passwords_get_different_records: maak met passwords.make_record(...) twee records voor hetzelfde wachtwoord bij n=16, r=8, p=1, en controleer dat ze verschillen.

Voer python -m pytest -q uit in de : het moet eindigen met 1 failed, 5 passed, waarbij de enige mislukking de test uit hoofdstuk 1 is.

Klik daarna op Check my work. Die roept de drie functies aan met wachtwoorden en kosten die je niet hebt gezien, terwijl hij de aanroepen van hashlib.scrypt, hmac.compare_digest en secrets.token_bytes vastlegt. Een record moet de zes delen hebben, een salt uit één aanroep van secrets.token_bytes(16), en de hash die scrypt voor die salt en die kosten geeft. verify_password() moet één scrypt-aanroep doen met de eigen n van het record, beslissen via hmac.compare_digest, en False teruggeven voor vier teksten die geen records zijn. needs_rehash() wordt gecontroleerd voor een record bij n=4096 tegen hogere, gelijke en lagere kosten. Als laatste voert hij je test op naam uit met passwords.make_record vervangen door andere versies: hij moet slagen tegen een correcte en falen tegen een die elke keer dezelfde salt gebruikt en een die een gewone digest opslaat. 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.