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
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 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
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:
Put the old key in
READING_KEY_PREVIOUSand a new one inREADING_KEY, and restart. New tokens name the new key. Old tokens name the old key, and both ids are inby_id.Wait longer than
TOKEN_LIFETIME. Every token the old key signed has now expired.Remove
READING_KEY_PREVIOUSand 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 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 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
Task
Give every token a key id, and let the server hold a previous key, in signing.py.
In
load_keys, after the current key: whenenviron.get("READING_KEY_PREVIOUS", "")is not empty, get that key withkey_fromand add it toby_idunder its ownkey_id.currentstill names the key fromREADING_KEY.Change
issue_tokenso that its firstis keys, theload_keysreturns. The key id iskeys["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.Change
read_tokenthe same way: three parts orNone; look the first part up inkeys["by_id"]and returnNonewhen it is not there; verify the tag over the first two parts joined by a dot; then decode the second part and checkexpas before.
Run the python -c line from the lesson in the 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 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.