0%

Store Passwords Safely · practice

A Record That Carries Its Own Settings

The command you ran at the end of Lesson 3 printed different bytes on every run. To check a password next week, the server must compute the same bytes again, and that takes the same and the same n, r, and p. All four have to be stored with the hash. This lesson stores them in the simplest way there is: in one line of text.

One line of text

A password record in this course looks like this. It is one line of text; here it is broken after the salt to fit the page:

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

It has six parts, joined by $:

  • the label scrypt, which says which made the record. A later version of the service can then add another function and still tell old records from new ones;

  • n, r, and p, as plain whole numbers: the cost this record was made with;

  • the salt;

  • the hash.

The salt and the hash are bytes, and a line of text cannot hold arbitrary bytes. So they go in as , with base64.b64encode(...).decode("ascii"). Standard base64 uses letters, digits, +, /, and =, and never $, so the separator cannot appear inside a part.

Other password libraries write records in the same spirit. If you ever see a stored that starts $2b$12$, that is a record made by another password hash, bcrypt, with its cost in the second part.

make_record(password, *, n, r, p) builds this line: draw a new salt with new_salt(), hash with hash_password(...), and put the six parts together. An does it in one line.

Notice what the record does not contain: the password. Everything in it is safe to store next to a user name. The salt is public by design, the settings are public, and the hash is the output of the slow function. A thief who reads the record has to guess, and every guess costs one scrypt call at the record’s settings.

Verify

verify_password(password, record) goes the other way.

  1. Split the record on $. If that does not give six parts, or the first part is not scrypt, the text is not a record of this kind, and the answer is False.

  2. Decode the salt and the stored hash with base64.b64decode(...).

  3. Hash the password with hash_password(...), using the salt and the three numbers from the record. Use int(...) on the numbers: they come out of the split as text.

  4. Compare the new hash with the stored one, and return the answer.

Step 3 has a trap. The settings to use are the record’s own, not today’s COST_N. A record made at n=4096 can only be reproduced at n=4096.

Step 4 is the second place in this project where a secret meets one expected value, after the tag in signing.py. So the rule of Chapter 2 applies: compare the two byte with hmac.compare_digest, and return what it returns.

A record is trusted data here. It comes from the server’s own state, not from a request, so verify_password does not defend against a record with absurd numbers in it. In the words of Chapter 1, a record never crosses the trust boundary inward. The password does, on every login.

A record was made at n=4096. Today COST_N is 16384. Which n does verify_password pass to scrypt for this record?

Costs go up

gets faster every year, so a service raises its cost from time to time. Records made before the change stay at the old cost. That is why the cost is in the record: the service can hold records of different strength side by side, and can tell which is which.

needs_rehash(record, *, n) answers one question: was this record made at a lower cost than n? Read the record’s n from its second part and compare. Compare numbers, not text:

What does this print?

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

The comparison is “lower than”, not “different from”. A record that is stronger than today’s setting must never be replaced by a weaker one.

Replacing a weak record with a fresh one at today’s cost is called rehashing, and it has a catch: making a record takes the password, and the server does not have it. The simplest moment to upgrade is therefore a correct login, when the user has just sent the password. Lesson 6 does that. A service that cannot wait for every user to log in has another way, which this course names and does not build: it can wrap each old hash in the stronger function at once, and replace the wrapped record with a clean one at the next login.

Your task

In passwords.py, write make_record, verify_password, and needs_rehash as described above.

Then write one attack test. The attack is the observation from Lesson 1: in the leaked table, equal digests gave away that two users had the same password. In tests/test_attacks.py, below the Chapter 3 comment, write test_attack_equal_passwords_get_different_records. Make two records for the same password with passwords.make_record(...) at n=16, r=8, p=1, and assert that they differ. The low cost keeps the test fast, which is what the cost are for.

Run the tests:

python -m pytest -q

The run ends with 1 failed, 5 passed. The one failure is still the Chapter 1 test. It turns green in Lesson 6.

Then press Check my work. A record now carries everything needed to check a password against it. The next lesson looks at what a login reveals when there is no record to check.

Task

In passwords.py, write three .

  • make_record(password, *, n, r, p) draws a with new_salt(), hashes with hash_password(...), and returns the text scrypt$n$r$p$salt$hash, where n, r, p are the numbers and the salt and the hash are base64.b64encode(...).decode("ascii").

  • verify_password(password, record) returns False when the record does not split into six parts on $ or its first part is not scrypt. Otherwise it hashes the password with the record’s salt and the record’s n, r, and p, and returns hmac.compare_digest(...) of the two byte .

  • needs_rehash(record, *, n) returns True when the record’s n, as a number, is lower than n.

Then, in tests/test_attacks.py, write test_attack_equal_passwords_get_different_records: make two records for the same password with passwords.make_record(...) at n=16, r=8, p=1, and assert that they differ.

Run python -m pytest -q in the : it must end with 1 failed, 5 passed, where the one failure is the test from Chapter 1.

Then press Check my work. It calls the three functions with passwords and costs you have not seen, while it records the calls of hashlib.scrypt, hmac.compare_digest, and secrets.token_bytes. A record must have the six parts, a salt from one call of secrets.token_bytes(16), and the hash scrypt gives for that salt and cost. verify_password() must make one scrypt call with the record’s own n, decide through hmac.compare_digest, and return False for four texts that are not records. needs_rehash() is checked for a record at n=4096 against a higher, an equal, and a lower cost. Last, it runs your test by name with passwords.make_record replaced by other versions: it must pass against a correct one and fail against one that uses the same salt every time and one that stores a plain digest. Nothing is timed, except that a test of yours that runs for more than 3 seconds is stopped, and nothing from your terminal is graded.