0%

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 from sample_state(). This project connects the two and removes the strings. Here is what the finished server must do:

QuestionAnswer
Who may ask for a token?A logged-in : POST /tokens with the . Anyone else gets 401 with the login route’s Basic challenge
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 expired401 with WWW-Authenticate: Bearer
A token that verifies and may not write403, as in with Python

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

  1. Write the attack. test_attack_token_from_the_source_is_refused builds its state with steps.keyed_state(), which is sample_state() with the test key in state["keys"]. A test never runs main, so nothing else would put keys there. The precondition is a login that works; the attack is a POST /books with Bearer tok-ana-writes-7Qx2. Run python -m pytest -q: 1 failed, 14 passed, because the string still opens the route.

  2. Issue tokens. Write token_response, add the tokens branch to handle, and add tokens to the no-store rule: a token is a credential, and no cache may keep one.

  3. Check tokens. Write token_claims, and rewrite write_error so that it decides from the claims alone. Your test passes.

  4. Remove the strings. Delete the "tokens" entry from sample_state(). Nothing reads it any more.

  5. Two more attacks. Both start from steps.bearer_for(state, user, password), which logs in, asks POST /tokens, and returns the whole Authorization , Bearer included. They are described below.

  6. Watch it. Run python pl_sec.py forged-token again.

  7. 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 says so. Add row F14 below F7:

  • 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 , accept nothing else on the write routes, and record what stays open.

  1. Write test_attack_token_from_the_source_is_refused(): with state = steps.keyed_state(), assert that steps.login_cookie(state, "ana", "correct horse") is not None, then send POST /books with a valid book and the header Authorization: Bearer tok-ana-writes-7Qx2, and assert status 401. It must fail first.

  2. Write token_response(state, user): expires is int(state["clock"]()) + TOKEN_LIFETIME; the claims are the user and whether the user is in state["writers"]; make the token with signing.issue_token(state["keys"], claims, expires=expires); return json_response(200, {"token": token, "expires": expires}).

  3. Add the route: POST /tokens answers unauthorized(REALM) when current_session(request, state) is None or has no user, and token_response(...) otherwise; another gets not_allowed("POST"). Add tokens to the no-store rule.

  4. Write token_claims(request, state): the bearer token read with signing.read_token(state["keys"], token, now=state["clock"]()), or None when there is no bearer token.

  5. Rewrite write_error to use token_claims: 401 with unauthorized("Bearer") when it returns None, 403 when can_write is false.

  6. Remove "tokens" from sample_state(), and take the two tokens out of its docstring.

  7. Write test_attack_changed_token_is_refused() and test_attack_expired_token_is_refused() as the lesson describes.

  8. In SECURITY.md: fill and close F6, and add row F14 as open.

Run python -m pytest -q in the to run your tests, and 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 from the old source, asks POST /tokens with no , with a visitor session, and with logged-in sessions, and reads each token it gets with its own code: the user, 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 . It runs your four tests of this chapter against servers of its own: all must pass against a correct one, each must fail against the mistake it is about, and the three token tests must fail against a server that refuses everything. It reads rows F6, F7, and F14 of SECURITY.md; the wording of your findings is not judged. Nothing from your terminal is graded.