Store Passwords Safely · practice
The Same Answer for Every Wrong Login
A login form that says “no such user” for one name and “wrong password” for another has told an attacker something before they guess a single password: which names are real accounts. Finding out which user names exist is called user enumeration. With a
This lesson checks whether your login gives that away. The answer has two halves.
The answers already match
This is the
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"]
A name that is not in state["users"] and a wrong password for a real name both end in return None. handle() answers both with the same response: status 401, the same headers, and the same body, {"error": "credentials are missing or wrong"}. Nothing in the reply tells the two cases apart.
That was a choice
The work does not
The second half appears as soon as passwords are stored as records. Write the obvious check_login(users, name, password) in passwords.py first:
look the record up with
users.get(name);if there is none, return
False;otherwise return
verify_password(password, record).
Then run the course helper’s scenario for this lesson:
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
A wrong password for ana costs one scrypt call. A login for zoe, who does not exist, costs none, because there is no record to hash against. Both answers are False, and one of them took a scrypt call longer to produce.
This site cannot time that difference, so pl_sec.py counts the calls and prints the n of each. A real attacker has a clock. A scrypt call is slow on purpose, which makes it one of the easier differences to measure: answers that come back fast belong to names that do not exist.
With the obvious check_login, what is different between a login for a name that exists (wrong password) and one for a name that does not?
The attack test, red
Write the test before the fix. As in Chapter 2, the attack itself needs a clock, so the test checks what stops it. helpers.count_scrypt() works like its twin for compare_digest: it returns a "calls" entry counts calls of hashlib.scrypt from then on. It also has an "n" entry: a list with the cost n of each call, in order.
In tests/test_attacks.py, write test_attack_unknown_user_costs_the_same_work:
Make a users dict with one record, at a low cost.
Then start the counter. Starting it after step 1 keeps the scrypt call of
make_recordout of the count.Call
passwords.check_login(...)for a name that is not in the dict.Assert three things: the answer is
False,counter["calls"]is 1, andcounter["n"]is[passwords.COST_N].
The third assertion matters as much as the second. One scrypt call at n=2 is one call and almost no work. The property you want is equal work, so the test states the cost too.
Run python -m pytest -q. Your new test fails with assert 0 == 1: zero calls.
A dummy record
The fix is to give a name that does not exist something to hash against. passwords.py has a constant for that, DUMMY_RECORD. It is a real record, made at COST_N, COST_R, and COST_P, for a password that nobody knows.
For a name that is not in users, call verify_password(password, DUMMY_RECORD), the same function a real check goes through, and then return False whatever it answered. The call is there for its cost. Its answer is thrown away.
For a name that is not in users, verify_password(password, DUMMY_RECORD) returns some answer. What does check_login return?
Run the tests and the scenario again. The test passes, and all three lines of login-cost now end hashlib.scrypt calls: 1 (n=16384), with the result a wrong password and an unknown user cost the same: 1 scrypt call at n=16384 each.
What this does and does not equalize
Two limits, stated plainly.
The cost is equal among records made at today’s cost. A record made at an older, lower cost is cheaper to check than the dummy, so a clock could still tell that user’s name from an unknown one. Ben’s record is such a record, and Lesson 6 upgrades it.
A request with no credentials at all is answered without any scrypt call. That reveals nothing, because it names no user.
Your task
In
tests/test_attacks.py, writetest_attack_unknown_user_costs_the_same_workand see it fail against the obviouscheck_login.In
passwords.py, finishcheck_login(users, name, password)so that every call makes exactly one scrypt call.Run
python -m pytest -q. It ends with1 failed, 6 passed: only the Chapter 1 test is still red.
Then press Check my work. Every piece of the password check now exists and is tested on its own. The project in the next lesson puts them behind the login route.
Task
Make a login for an unknown name cost the same work as any other, and prove it with a test.
In tests/test_attacks.py, write test_attack_unknown_user_costs_the_same_work:
make
users = {"ana": passwords.make_record("correct horse", n=16, r=8, p=1)};then start
counter = helpers.count_scrypt();call
passwords.check_login(users, "zoe", "any guess");assert that it returned
False, thatcounter["calls"]is 1, and thatcounter["n"]is[passwords.COST_N].
In passwords.py, write check_login(users, name, password):
it returns
Trueonly whennameis inusersandpasswordverifies against that record;for a name that is not in
users, it callsverify_password(password, DUMMY_RECORD)and returnsFalse;every call of
check_loginmakes exactly one scrypt call.
Run python pl_sec.py login-cost and python -m pytest -q in the
Then press Check my work. It calls check_login() eight times with users and passwords you have not seen, once with no users at all, with its own record in place of DUMMY_RECORD, and records every hashlib.scrypt call: each login must return exactly True or False and make exactly one call, and for a name that is not in users that call must use the dummy record’s own n, r, p, and passwords.check_login replaced by other versions: it must pass against a correct one, fail against one that answers an unknown name without a scrypt call, and fail against one whose call for an unknown name runs at n=2. 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.