Signed Tokens and Secret Keys · capstone
Capstone project: Project: Tokens the Server Can Trust
signing.py can issue a token, read one, and replace a key. reading_server.py uses none of it yet: the write routes still accept two sample_state(). This project connects the two and removes the strings. Here is what the finished server must do:
| Question | Answer |
|---|---|
| Who may ask for a token? | A logged-in POST /tokens with the |
| What does the token hold? | user, can_write, and exp |
| How long does it live? | TOKEN_LIFETIME, 900 seconds, counted from state["clock"]() |
| What does a write route check? | The tag under state["keys"], then exp against state["clock"]() |
| A token that does not verify, or has expired | 401 with WWW-Authenticate: Bearer |
| A token that verifies and may not write | 403, as in |
See where you stand:
python pl_sec.py forged-token
It logs in as ben, asks for a token, and then tries tokens that must not work. Today two lines tell the story:
ben asks for a token, with the cookie POST /tokens -> 404
a token that was once in the source code POST /books -> 201 accepted
Where a token comes from now
A reader logs in, which gives them Chapter 4’s session, and then sends POST /tokens with the session cookie. The session proves who they are. The token is what a program sends afterward, with no cookie and no password.
current_session(request, state) finds the session. A visitor session has no user, so it gets the same 401 as no session at all. That 401 carries the Basic challenge of /login, because logging in is how a client gets the session it lacks.
can_write used to be written next to each fixed token. Now it comes from state["writers"], a set of names that sample_state() already has: the token says user in state["writers"]. A token carries that answer as it was when the token was made. A user who is removed from the set keeps writing until the token expires.
Milestones
Write the attack.
test_attack_token_from_the_source_is_refusedbuilds its state withsteps.keyed_state(), which issample_state()with the test key instate["keys"]. A test never runsmain, so nothing else would put keys there. The precondition is a login that works; the attack is aPOST /bookswithBearer tok-ana-writes-7Qx2. Runpython -m pytest -q:1 failed, 14 passed, because the string still opens the route.Issue tokens. Write
token_response, add thetokensbranch tohandle, and addtokensto theno-storerule: a token is a credential, and no cache may keep one.Check tokens. Write
token_claims, and rewritewrite_errorso that it decides from the claims alone. Your test passes.Remove the strings. Delete the
"tokens"entry fromsample_state(). Nothing reads it any more.Two more attacks. Both start from
steps.bearer_for(state, user, password), which logs in, asksPOST /tokens, and returns the wholeAuthorization, Bearerincluded. They are described below.Watch it. Run
python pl_sec.py forged-tokenagain.Write it down. Fill and close F6 in
SECURITY.md, and add F14.
test_attack_changed_token_is_refused uses steps.keyed_state({"now": 1000000}), so the clock stands still at a moment when an exp of 2000000 is still ahead. Then only the tag can refuse the forged token. Get ben’s value with bearer_for and split it at the dots. Build the payload {"user": "ana", "can_write": True, "exp": 2000000}, turn it into text with signing.b64u_encode(json.dumps(payload).encode("utf-8")), and join the first part, that text, and the old third part with dots. First assert that ben’s honest token reads back as his: signing.read_token(state["keys"], bearer.split(" ")[1], now=1000000)["user"] is "ben". Then send POST /books with the forged value and assert 401.
test_attack_expired_token_is_refused keeps its clock: clock = {"now": 1000000}, then steps.keyed_state(clock). Get ana’s value, send POST /books, and assert 201. Add 16 * 60 to clock["now"], send the same request again, and assert 401.
After milestone 5, python -m pytest -q says 17 passed. The scenario of milestone 6 then shows what is inside ben’s token, and three refusals. Among its lines:
its payload, read without any key: {"user": "ben", "can_write": false, "exp": 1000900}
ben adds a book with his own token POST /books -> 403 {"error": "this token may read the list but not change it"}
the changed payload with the old tag POST /books -> 401 refused
a token that was once in the source code POST /books -> 401 refused
ben's own token, 16 minutes old POST /books -> 401 refused
3 of 3 bad tokens refused
One finding stays open
Closing F6 opens a smaller hole, and an honest
Exposed: whatever a token allows, for up to 15 minutes.
Attack: a token that leaks works until it expires. Logout does not end it, and the server keeps no list to cross it off.
Fix: a short life, which is done, and in a real service a stored list of withdrawn tokens, which is not built.
Test:
none yet. Status:open.
Ana gets a token, and five minutes later she logs out. The token is sent again. What happens?
A token's tag is right, and its exp passed a minute ago. Which answer?
Then press Check my work. It checks the routes with users, keys, and a clock of its own, calls main as Lesson 4’s check did, and runs your four tests of this chapter against servers of its own, where each test must fail on the mistake it is about.
The fixed strings are gone, and a token now proves who is asking. It does not yet say which books that person may touch: every writer’s token still opens every book. That is Chapter 6.
Task
Issue signed tokens to logged-in
Write
test_attack_token_from_the_source_is_refused(): withstate = steps.keyed_state(), assert thatsteps.login_cookie(state, "ana", "correct horse")is notNone, then sendPOST /bookswith a valid book and the headerAuthorization: Bearer tok-ana-writes-7Qx2, and assert status 401. It must fail first.Write
token_response(state, user):expiresisint(state["clock"]()) + TOKEN_LIFETIME; the claims are the user and whether the user is instate["writers"]; make the token withsigning.issue_token(state["keys"], claims, expires=expires); returnjson_response(200, {"token": token, "expires": expires}).Add the route:
POST /tokensanswersunauthorized(REALM)whencurrent_session(request, state)isNoneor has no user, andtoken_response(...)otherwise; anothergets not_allowed("POST"). Addtokensto theno-storerule.Write
token_claims(request, state): the bearer token read withsigning.read_token(state["keys"], token, now=state["clock"]()), orNonewhen there is no bearer token.Rewrite
write_errorto usetoken_claims: 401 withunauthorized("Bearer")when it returnsNone, 403 whencan_writeis false.Remove
"tokens"fromsample_state(), and take the two tokens out of its docstring.Write
test_attack_changed_token_is_refused()andtest_attack_expired_token_is_refused()as the lesson describes.In
SECURITY.md: fill and close F6, and add row F14 as open.
Run python -m pytest -q in the python pl_sec.py forged-token to watch three bad tokens be refused.
Then press Check my work. With users, writers, books, two keys, and a fixed clock of its own, it sends a fixed POST /tokens with no can_write, and a life of at most 900 seconds. It writes with a writer’s token, a reader’s token, a changed token, a token 901 seconds old, and a token from a key your server does not hold. It calls main as in Lesson 4, and passes one POST /tokens through your server’s handler class as bytes, without opening a SECURITY.md; the wording of your findings is not judged. Nothing from your terminal is graded.