Store Passwords Safely · capstone
Capstone project: Project: No Readable Password Anywhere
The passwords.py are finished and tested. The server does not use them yet: sample_state() still holds two plain passwords, and login_user still compares them with !=. This project connects the two, and closes the first findings of the course.
The requirements:
sample_state()storesANA_RECORDandBEN_RECORDunder"users", and no password.login_userdecides throughpasswords.check_login.After a correct login, a record made at a lower cost than
passwords.COST_Nis replaced by a new one at today’s cost.A wrong password and an unknown user get the same 401 after the same number of scrypt calls, and at the same cost among records at today’s cost.
No password and no record appears in a response, in a
, or in printed output. One new attack test, and rows F1 and F2 of
SECURITY.mdclosed.
“No readable password anywhere” is about what the service stores and sends. The two sample passwords still appear in your test file, in pl_sec.py, and in the comment above ANA_RECORD in reading_server.py. They are test values, and a test has to know them. A real service has no such comment: nobody but the user knows the password behind a record.
Milestone 1: records in the state
Change one line of sample_state() in reading_server.py, so that "users" maps ana to ANA_RECORD and ben to BEN_RECORD. Both constants are near the top of the file. They are records for the two sample passwords, made with your record format.
Run python -m pytest -q. The Chapter 1 test still fails, and it fails differently: at its first assertion, with assert 401 == 200. The login still compares the password ana sends with what is stored, and what is stored is now a record. Nobody can log in.
Reading that failure is the reason the test asserts the status first. Without that assertion the test would pass now, on a server where the password is gone because the login is broken.
Milestone 2: the login
Rewrite login_user(state, credentials):
when
credentialsisNone, returnNone;take the name and the password out of
credentials;when
passwords.check_login(state["users"], name, password)isFalse, returnNone;otherwise return the name.
Run the tests. test_attack_backup_reveals_no_password passes, for the first time since Chapter 1. The whole file is green.
Milestone 3: upgrade ben
BEN_RECORD was made at n=4096, a quarter of today’s cost. It stands for a record from before the service raised its settings.
After a correct login, look at the stored record. When passwords.needs_rehash(record, n=passwords.COST_N) is True, replace it in state["users"] with passwords.make_record(password, n=passwords.COST_N, r=passwords.COST_R, p=passwords.COST_P).
Do this only after check_login returned True. At that moment the server holds the user’s real password. A record replaced before the check would let anyone overwrite ben’s password with a wrong guess.
Until ben logs in, a wrong password for ben costs a quarter of what the dummy check costs, so a clock could tell his name from an unknown one. That is a second reason to upgrade old records.
Where does the upgrade of a weak record belong?
Milestone 4: F2
F2 said that the login compares a secret with !=. Look at your new login_user: it compares nothing. The one comparison of a secret now happens inside verify_password, with hmac.compare_digest.
Write the test that says so, through the server. In tests/test_attacks.py, write test_attack_login_compares_with_compare_digest: build reading_server.sample_state(), start helpers.count_compare_digest(), send POST /login for ana with a wrong password through reading_server.handle(...), and assert that the status is 401 and that counter["calls"] is at least 1.
“At least 1” is deliberate. A login that compares with == makes no such call at all, so the test catches it. And a later chapter may add another careful comparison on this route without breaking your test.
Milestone 5: the thief again
Run the scenario from Chapter 1:
python pl_sec.py stolen-backup
The copy of the state now shows two records, and the thief’s next step fails. Each record is cut short here after the first characters of its hash:
users 'ana': 'scrypt$16384$8$1$Iiddrf4ZE7Bp5ctR/ENjYg==$SedCTnOCi...
users 'ben': 'scrypt$4096$8$1$QKGmY5EvluaM3abQzz8CUQ==$OhHmwNl+T...
log in as ana with the value stored for ana -> 401 refused
log in as ben with the value stored for ben -> 401 refused
The result line reads 0 of 2 stored values worked as a password. python pl_sec.py login-cost still shows one scrypt call at n=16384 on all three lines.
Now open SECURITY.md. Set the Status of F1 and F2 to closed in 3.6, and put test_attack_login_compares_with_compare_digest in F2’s Test cell. A row is closed only when its test passes, and both do.
A thief copies the state after this lesson. What can they do with ana's record?
What is still open
Be exact about what you closed. A thief with this copy can still guess, at one scrypt call per guess per user. That is why the cost matters, and why a real service uses a higher one than this course. A password like correct horse would still fall to a
The login also allows any number of attempts. Nothing limits guesses that arrive as requests until Chapter 6.
And the copy holds more than passwords: two tokens that never expire, and a live session id. The server has no log yet, so the requirement about printed output is all there is to say about logs for now. Chapter 6 returns to that as well.
Run python -m pytest -q one last time. It ends with 8 passed. Then press Check my work. Chapter 4 starts with the session that never ends.
Task
Put the password
In reading_server.py:
Make
sample_state()storeANA_RECORDandBEN_RECORDunder"users".Rewrite
login_user(state, credentials). ReturnNonewhencredentialsisNoneor whenpasswords.check_login(state["users"], name, password)isFalse. After a correct login, whenpasswords.needs_rehash(record, n=passwords.COST_N)isTruefor the stored record, replace it withpasswords.make_record(password, n=passwords.COST_N, r=passwords.COST_R, p=passwords.COST_P). Return the user name.
In tests/test_attacks.py, write test_attack_login_compares_with_compare_digest: build reading_server.sample_state(), start counter = helpers.count_compare_digest(), send POST /login for ana with a wrong password through reading_server.handle(...), and assert status 401 and that counter["calls"] is at least 1.
In SECURITY.md, set the Status of F1 and F2 to closed in 3.6 and name this test in F2’s Test cell.
Run python -m pytest -q (it must end with 8 passed) and python pl_sec.py stolen-backup in the
Then press Check my work. It calls handle() with states whose users and records you have not seen, one of them made at n=4096, and records every call of hashlib.scrypt and hmac.compare_digest. A right password must answer 200, start one n, r, and p. The decision must go through hmac.compare_digest. The record at n=4096 must be replaced after a right password and left alone after a wrong one, and a record already at COST_N must stay. Then it runs four of your tests by name (the one from Chapter 1, the two from Lessons 4 and 5, and the new one) against a correct service, where all must pass, and against six broken ones, where the matching test must fail. Last, it reads rows F1 and F2 of SECURITY.md. 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.