0%

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 of real names, every later attack gets cheaper, because no guess is wasted on an account that is not there.

This lesson checks whether your login gives that away. The answer has two halves.

The answers already match

This is the that decides a login in your server today:

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 with Python made, and it stays. The first half is fine.

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 whose "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:

  1. Make a users dict with one record, at a low cost.

  2. Then start the counter. Starting it after step 1 keeps the scrypt call of make_record out of the count.

  3. Call passwords.check_login(...) for a name that is not in the dict.

  4. Assert three things: the answer is False, counter["calls"] is 1, and counter["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

  1. In tests/test_attacks.py, write test_attack_unknown_user_costs_the_same_work and see it fail against the obvious check_login.

  2. In passwords.py, finish check_login(users, name, password) so that every call makes exactly one scrypt call.

  3. Run python -m pytest -q. It ends with 1 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, that counter["calls"] is 1, and that counter["n"] is [passwords.COST_N].

In passwords.py, write check_login(users, name, password):

  • it returns True only when name is in users and password verifies against that record;

  • for a name that is not in users, it calls verify_password(password, DUMMY_RECORD) and returns False;

  • every call of check_login makes 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 . Then it runs your test by name with 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.