0%

Signed Tokens and Secret Keys · practice

Load the Key or Refuse to Start

Start your server the way an would, for one connection and with one second of patience:

python reading_server.py --max-connections 1 --idle-timeout 1

It prints that it is listening on , on a the system chose, waits a second for a client that never comes, and prints served its connections and stopped. Nobody asked it for a key. A server that will sign tokens must not start like that.

A key as text

An environment holds text, and a key is 32 random bytes. So the key travels as , the standard kind with its padding. Open README.md: it shows the test key for this course and how to start the server with it. That key is readable on purpose. It decodes to the words test-only key, never use it: 001, so nobody can mistake it for a real one.

To turn the text back into bytes, use base64.b64decode(text, validate=True), as basic_credentials does for a login. The second matters more here than it did there. Without it, b64decode drops every character it does not know and decodes what is left:

import base64

text = "change this text to the real signing key before the server goes live"
print(len(base64.b64decode(text)))

That prints 42. A note that somebody left in a settings file has become 42 bytes of key, and everyone who has seen the file knows them. With validate=True the same call raises binascii.Error, which is a kind of , with the message Only base64 data is allowed.

A key must also be long enough. One that is shorter than the 32 bytes of a digest is the weak part of the tag, which is why Chapter 2 made keys 32 bytes long. signing.py has that number as KEY_BYTES.

Fail closed

When a security requirement is not met, refuse. That rule is called fail closed, and Lesson 3’s last question was about it. Every other choice keeps the service looking healthy while it is not: a built-in key that everyone knows, a random key that ends every token at each restart, or a server that is up and refuses everybody.

A developer writes environ.get("READING_KEY", "dev-key"). What does that default cost?

Write the attack first

The attack is a server that comes up with no key in its environment. As a test: signing.load_keys({}) must raise ValueError.

A loader that raised ValueError for everything would pass that test and be useless, so test_attack_missing_key_is_refused starts with a precondition: the test key loads. steps.TEST_KEY holds the same text as README.md, and signing.load_keys({"READING_KEY": steps.TEST_KEY})["current"] must be "29c565a5". Then comes the refusal. This course does not use pytest.raises: set a flag to False, call the inside try, set the flag to True in except ValueError, and assert the flag.

Run python -m pytest -q. The new test fails on its first line, because load_keys still raises NotImplementedError:

1 failed, 13 passed

The mapping is an argument

load_keys(environ) reads only the it is given. It does not import os, and it never looks at the real environment. main passes os.environ, a test passes {}, and both run the same code.

handle() never looks at the environment at all. main stores what load_keys returned in state["keys"], and from Lesson 6 on the request code finds its keys there.

Say why, on the right stream, without the secret

When load_keys raises ValueError, main catches it, prints one line to standard error, and returns 1. Catch it as except ValueError as error, and print with file=sys.stderr. main already has an environ for this; when it is None, use os.environ.

Standard error and a nonzero exit code are how whatever started the server learns that it did not start. The line names the and never repeats its . An error message is a log line somewhere, and Lesson 3 listed a log among the places a key must not be. That holds for a wrong value as well: a key with one character missing is still most of a key.

Why does main print the refusal to standard error and return 1, not print to standard output and return 0?

What load_keys returns

The work is split over two functions. key_from(environ, name) reads one variable and returns the key bytes, or raises ValueError. load_keys(environ) calls it for READING_KEY and returns a dict:

{"current": "29c565a5", "by_id": {"29c565a5": b"test-only key, never use it: 001"}}

With one key, that is more shape than you need. The next lesson adds a second key, and nothing else has to change. The short text is a key id, from the supplied key_id(key): the first 8 hex digits of the key’s SHA-256 digest. It is a public name for a secret thing. A digest cannot be turned back into a key of 32 random bytes.

Your task

Write the test and watch it fail. Then write key_from and load_keys in signing.py, and load the keys in main in reading_server.py, at the comment that marks the place. The exercise the steps. Then run both commands:

python reading_server.py; echo "exit code: $?"
READING_KEY=dGVzdC1vbmx5IGtleSwgbmV2ZXIgdXNlIGl0OiAwMDE= python reading_server.py --max-connections 1 --idle-timeout 1; echo "exit code: $?"

The first prints one line and the exit code. If your key_from says READING_KEY is not set, it reads:

reading_server: not starting: READING_KEY is not set
exit code: 1

The second listens, waits one second for a client that never comes, stops, and prints exit code: 0. Run python -m pytest -q again: 14 passed.

Last, fill row F7 of SECURITY.md. What is exposed is the power to sign a token for any user. Name your test in the Test cell and set the status to closed in 5.4.

Then press Check my work. Your server now has a key with a home. It has exactly one, though, and no way to replace it without ending every token at once. That is the next lesson.

Task

Make the server load its key from the environment, and refuse to start without one.

  1. In tests/test_attacks.py, write test_attack_missing_key_is_refused(): first assert that signing.load_keys({"READING_KEY": steps.TEST_KEY})["current"] is "29c565a5", so the test is about a loader that accepts a good key; then set refused = False, call signing.load_keys({}) inside try, set refused = True in except ValueError, and assert refused. It must fail first.

  2. In signing.py, write key_from(environ, name). Read environ.get(name, ""). Empty text raises with a message that names the . Decode with base64.b64decode(text, validate=True); text that is not , or a key shorter than KEY_BYTES, raises ValueError too. Never put the variable’s in a message. Return the key bytes.

  3. Write load_keys(environ): get the key with key_from(environ, "READING_KEY") and return {"current": key_id(key), "by_id": {key_id(key): key}}.

  4. In main in reading_server.py: when environ is None, use os.environ. Call signing.load_keys(environ). On ValueError (except ValueError as error), print one line that starts with reading_server: not starting: to standard error (print(..., file=sys.stderr)) and return 1. Otherwise store the result in state["keys"] before the server is made.

  5. Fill row F7 of SECURITY.md, name your test, and set the status to closed in 5.4.

Run python -m pytest -q in the , and the two commands from the lesson to see the server refuse and then start.

Then press Check my work. It calls load_keys with of its own: no key, an empty one, keys that are too short, text that is not base64, and good keys you have not seen. A refusal must be a ValueError whose message does not repeat the value. It calls key_from with another variable name, and main through its environ , with nothing listening: without a key main must build no server, return a nonzero code, and write exactly one line to standard error. It runs your test three times, each time with other key in place of the ones in signing.py: a correct loader, where your test must pass; a loader with a built-in key, where it must fail; and a loader that refuses every key, where it must fail too. It also reads row F7 of SECURITY.md; the wording of your finding is not judged. Your real environment is never read. Nothing from your terminal is graded.