0%

Signed Tokens and Secret Keys · practice

Tokens That Expire

The token of Lesson 1 says who it is for and proves that the server wrote it. It also works forever, like the fixed it is meant to replace. An expiry time fixes that, on one : the holder must not be able to edit it.

In this lesson you write the two the rest of the chapter stands on, in signing.py: issue_token makes a token, and read_token decides whether to believe one.

Text that fits in a header

A token travels in an Authorization: Bearer header, and that may contain = only at its very end. pads its output with =, and in payload.tag the padding would land in the middle. So signing.py now has two small helpers, which you can read and never need to change:

def b64u_encode(data):
    """Return bytes as URL-safe base64 text without the = padding."""
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")


def b64u_decode(text):
    """Return the bytes that b64u_encode turned into text."""
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))

b64u_encode takes bytes and returns text with the padding removed. b64u_decode puts the padding back and returns the bytes. Use these two wherever a payload becomes text or text becomes a payload.

Put the expiry inside

The payload gets a third field:

payload = {"user": claims["user"], "can_write": claims["can_write"], "exp": expires}

exp is a time in seconds, the same kind of number that state["clock"]() returns. Build the payload field by field, as shown. claims is a that some caller filled in, and a payload that copied all of it would sign whatever that caller happened to put there.

What matters most is where exp sits: inside the payload, so inside the text that the tag covers. The picture from Lesson 1 still holds, with one more field in the at the top. The token is still two parts. issue_token turns the payload into text with b64u_encode(json.dumps(payload).encode("utf-8")), signs that text with Chapter 2’s sign, and returns the text, a dot, and the tag.

A token is payload.1731240900.tag, and the tag covers the payload only. What can its holder do?

Check the tag, then decode

read_token receives text from a stranger. Until the tag has been checked, that is all it is: a stranger’s bytes. Decoding and parsing them runs your code on input nobody vouched for, and using a field from them is worse.

So the order is fixed. Split the token at the dots. Check the tag over the first part with verify(key, ...), which is Chapter 2’s function and compares with hmac.compare_digest. Only when the tag is right, decode the first part with b64u_decode and parse it with json.loads. Then look at exp.

verify takes the message as bytes and the tag as text, so the first part goes in as parts[0].encode("utf-8"). can encode any text a header can deliver. A stricter encoding would raise an error for an odd character, and read_token must never raise for a token, whatever it holds.

read_token decodes the payload to read exp and checks the tag afterward. A token with a wrong tag arrives. What is wrong with that order?

Every wrong token is None

A token can be wrong in several ways: it does not have exactly two parts, its tag is wrong, its tag was made with another key, or its exp has passed. read_token gives one answer for all of them, None, as Chapter 3 gave one answer for every wrong login. The caller learns that the token is not good, and nothing about why. A token that passes every check gives its payload back as a dict, so the caller can read user and can_write from it.

A token expires when now has reached exp: at that second, and at every second after it. now is an of read_token, so nothing in signing.py reads a clock. The server will pass state["clock"](), and a test can pass any number it likes.

Fifteen minutes

Lesson 1 named the price of a token the server does not store: nobody can take it back. Expiry is the only way this token ends, so its life has to be short. In this chapter’s project the server sets exp at most 900 seconds ahead, which is 15 minutes. A token that leaks is then a problem for 15 minutes, not for as long as the server runs.

Your task

In signing.py, write issue_token(key, claims, *, expires) and read_token(key, token, *, now). The exercise the steps. Then try the pair by hand:

python -c "import signing; k = signing.new_key(); t = signing.issue_token(k, {'user': 'ana', 'can_write': True}, expires=2000); print(t); print(signing.read_token(k, t, now=1999)); print(signing.read_token(k, t, now=2001)); print(signing.read_token(signing.new_key(), t, now=1999))"

The first line is the token. Its payload text is eyJ1c2VyIjogImFuYSIsICJjYW5fd3JpdGUiOiB0cnVlLCAiZXhwIjogMjAwMH0, with no = at the end, and after the dot come 64 hex characters that differ on every run, because new_key() makes a new key each time. The other three lines are the same on every run:

{'user': 'ana', 'can_write': True, 'exp': 2000}
None
None

One second before exp the token gives its payload back. One second after, it is None. Read with another key, it is None as well.

Notice where the key came from: signing.new_key(), made on the spot and gone when the command ends. A server cannot work that way. Where its key lives is the subject of the next lesson.

Then press Check my work.

Task

Write the two token in signing.py.

  1. Write issue_token(key, claims, *, expires). Build the payload {"user": claims["user"], "can_write": claims["can_write"], "exp": expires}. Turn it into text with b64u_encode(json.dumps(payload).encode("utf-8")). Return that text, a dot, and sign(key, ...) of the text encoded as .

  2. Write read_token(key, token, *, now). Split the token at dots; anything but two parts is None. Check the tag with verify(key, ...) over the first part encoded as UTF-8; a wrong tag is None. Only then decode the first part with b64u_decode and parse it with json.loads. When now has reached exp, return None. Otherwise return the payload.

Run the python -c line from the lesson in the to make a token and read it back three ways.

Then press Check my work. It calls issue_token with a key, users, and expiry times you have not seen, and with claims that hold an extra field, which must not reach the payload; it decodes the payload itself and computes the tag itself. It calls read_token with a valid token one second before and one second after its exp, and with tokens that must give None and never an error: a changed payload with the old tag, a token from another key, a shortened tag, an empty tag, and texts that are no token at all. It also counts calls: reading a valid token must compare the tag through verify or hmac.compare_digest, and reading a token with a wrong tag must not call b64u_decode or json.loads. Nothing from your terminal is graded.