0%

Signed Tokens and Secret Keys · practice

Replace a Key Without Logging Everyone Out

Your server has one key, and it comes from READING_KEY. Now imagine changing it: set a new and restart. Every token that was issued before the restart carries a tag from the old key, so every one of them is refused. With tokens that live 15 minutes that is a small outage. With any signed value that lives longer, it is everyone at once.

A key that cannot be replaced calmly is a key that gets kept too long. This lesson makes the replacement an ordinary step, called key rotation: for a while the server knows two keys, and each token says which one signed it.

Name the key

A token names its key with key_id(key), the short id you met in Lesson 4. The id is public. It is 8 hex digits of a digest, and it gives nothing away about a key that is 32 random bytes. The two test keys in README.md have the ids 29c565a5 and a49130cb.

kid.payload.tag

The token gets a third part, in front. People call it the kid, for key id:

29c565a5 . eyJ1c2VyIjogImFuYSIsIC...MH0 . de374237...2eab4cf5
[ kid  ]   [---- the payload text ---]   [----- the tag -----]
[----- the tag covers all of this ----]

That is the start and the end of a real token, signed with the first test key, with the spaces added for reading.

The id is a part of its own so that the server can read it without decoding anything. The tag is computed over the kid, the dot, and the payload text together. Everything the server reads in a token is then either covered by the tag or is the tag.

Why is the key id a part of its own, in front, and not a field inside the payload?

Look up, then verify

read_token now starts with a lookup: keys["by_id"].get(kid). When there is no such id, it returns None at once, before any comparison. It does not fall back to the current key, and it does not try every key in turn.

The reasons are plain ones. The id names exactly one key, so a token that names an id this server does not hold was not issued by it, and it is refused before any work is spent on it. Trying every key would make the id pointless, cost one comparison per loaded key for every bad token, and hide which key a token really used. You need to know that on the day you remove a key.

Be clear about what this rule is not. Neither mistake would let anyone forge a token here: the tag must still be right under a key the server holds. Some real systems try a short of older keys in turn, on purpose. The danger lies in systems where the id decides how a key is fetched or which algorithm checks the tag; there, trusting the id has led to real forgeries. Here it is a lookup in a , and the rule keeps it that way.

Two variables, three steps

READING_KEY is the key that signs. READING_KEY_PREVIOUS, when it is set, is a key that still verifies. Replacing a key takes three steps:

  1. Put the old key in READING_KEY_PREVIOUS and a new one in READING_KEY, and restart. New tokens name the new key. Old tokens name the old key, and both ids are in by_id.

  2. Wait longer than TOKEN_LIFETIME. Every token the old key signed has now expired.

  3. Remove READING_KEY_PREVIOUS and restart. The old key is gone, and nobody noticed.

Here is the same thing as a table, for a token that was made before step 1:

               READING_KEY   keys that
               signs with    verify        the old token
before         key 001       001           verifies
after step 1   key 002       002 and 001   verifies: it names 001
after step 2   key 002       002 and 001   has expired
after step 3   key 002       002           names a key that is gone

load_keys reads the second through key_from, so a previous key that is set and broken is a refusal to start, like a broken current one. An empty READING_KEY_PREVIOUS means there is no previous key.

Step 1 is done: the new key signs, and the old key is in READING_KEY_PREVIOUS. Which tokens verify?

A leaked key is a different case

The overlap is for a planned change. A key that leaked gets no overlap: while it is loaded, in either variable, whoever holds it can sign tokens that the server accepts. Remove it at once, and let the tokens it signed end with it. Their holders have to ask for new tokens, and that is the right price.

The old key leaked this morning. May it stay in READING_KEY_PREVIOUS for the usual 15 minutes?

Your task

In signing.py, extend load_keys, and change issue_token and read_token so that their first is the keys dict that load_keys returns. The exercise lists the steps. Then replace a key by hand, with the two test keys from README.md:

python -c "import signing; old = signing.load_keys({'READING_KEY': 'dGVzdC1vbmx5IGtleSwgbmV2ZXIgdXNlIGl0OiAwMDE='}); t = signing.issue_token(old, {'user': 'ana', 'can_write': True}, expires=2000); both = signing.load_keys({'READING_KEY': 'dGVzdC1vbmx5IGtleSwgbmV2ZXIgdXNlIGl0OiAwMDI=', 'READING_KEY_PREVIOUS': 'dGVzdC1vbmx5IGtleSwgbmV2ZXIgdXNlIGl0OiAwMDE='}); new = signing.load_keys({'READING_KEY': 'dGVzdC1vbmx5IGtleSwgbmV2ZXIgdXNlIGl0OiAwMDI='}); print(t.split('.')[0]); print(signing.read_token(both, t, now=1999)); print(signing.read_token(new, t, now=1999))"

It makes a token with the first key, then reads it with both keys loaded and with the second key alone:

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

The token names key 29c565a5. While that key is still loaded as the previous one, the token verifies. Once it is removed, the token is None.

Run python -m pytest -q as well. It still says 14 passed, because none of your tests calls issue_token or read_token yet. Lesson 2’s check judged the two-part token, so it no longer passes on this file; each check judges the step its own lesson asks for.

Then press Check my work. The token and key are finished. No route in the server issues such a token yet, and the write routes still accept the two fixed . The project puts that right.

Task

Give every token a key id, and let the server hold a previous key, in signing.py.

  1. In load_keys, after the current key: when environ.get("READING_KEY_PREVIOUS", "") is not empty, get that key with key_from and add it to by_id under its own key_id. current still names the key from READING_KEY.

  2. Change issue_token so that its first is keys, the load_keys returns. The key id is keys["current"]. The signed text is the key id, a dot, and the payload text; the token is that, a dot, and the tag of that text under the current key.

  3. Change read_token the same way: three parts or None; look the first part up in keys["by_id"] and return None when it is not there; verify the tag over the first two parts joined by a dot; then decode the second part and check exp as before.

Run the python -c line from the lesson in the to replace a key by hand, and python -m pytest -q to see your tests still pass.

Then press Check my work. It calls load_keys with dicts of its own: a current key alone, a current and a previous key, an empty previous , and a previous key that is too short, which must raise . It calls issue_token and read_token with keys you have not seen: a new token must have three parts, name the current key, and carry a tag over the first two parts and the dot between them; a token of either loaded key must be accepted; and a token of a removed key, an expired token, a token that names an unknown key id, a token whose key id was swapped, and texts that are no token must give None and never an error. It also counts calls: a token with an unknown key id must be refused before any comparison, and a token with a wrong tag before b64u_decode or json.loads is called. Nothing from your terminal is graded.