Warm-up · Activity 1 of 7
Warm-up from the previous lesson: json can only build plain data. What does this print?
import json
print(json.loads('[1, 2]') == [1, 2], type(json.loads('true')).__name__)// A5.5 · ~45 min · Advanced
After this lesson you can make tokens with secrets, hash and sign data with hashlib and hmac, store passwords with a salted, slow hash, check a pyproject.toml before python3 -m build, and finish the module with a safe t-string report generator.
Lesson 5 of 5 in A5 Modern Python and safe code
You will be able to
Warm-up · Activity 1 of 7
import json
print(json.loads('[1, 2]') == [1, 2], type(json.loads('true')).__name__)Predict · Activity 2 of 7
import hashlib
short = hashlib.sha256(b"a").hexdigest()
long = hashlib.sha256(b"a" * 100_000).hexdigest()
print(len(short), len(long))Practice · Activity 3 of 7
import secrets
import string
token = secrets.____(16)
allowed = set(string.ascii_letters + string.digits + "-_")
print(len(token), set(token) <= allowed)Practice · Activity 4 of 7
import hmac
key = b"server-key"
tag = hmac.new(key, b"user=ada", "sha256").hexdigest()
again = hmac.new(key, b"user=ada", "sha256").hexdigest()
forged = hmac.new(key, b"user=admin", "sha256").hexdigest()
print(hmac.compare_digest(tag, again), hmac.compare_digest(tag, forged))Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
import hmac
for a, b in [("abc", "abc"), (b"abc", b"abc"), ("abc", b"abc")]:
try:
print(hmac.compare_digest(a, b), end=" ")
except TypeError:
print("TypeError", end=" ")Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
The program makes a reset token with secrets and prints only its length, since the value changes every run. It hashes a small CSV report with SHA-256, then signs it with hmac and checks two copies: one unchanged, and one with an extra row added. Only the unchanged copy matches the tag.
main.py
import hashlib
import hmac
import secrets
# 1. A token for a reset link: secrets, never random.
token = secrets.token_urlsafe(32)
print("token length:", len(token))
# 2. A checksum: the same bytes always give the same SHA-256 digest.
data = b"report,2026\nada,3\n"
print("sha256:", hashlib.sha256(data).hexdigest()[:16])
# 3. A signature: only someone with the key can make a matching tag.
key = b"keep-this-secret"
tag = hmac.new(key, data, "sha256").hexdigest()
received = hmac.new(key, data, "sha256").hexdigest()
tampered = hmac.new(key, data + b"bob,9\n", "sha256").hexdigest()
print("genuine:", hmac.compare_digest(tag, received))
print("tampered:", hmac.compare_digest(tag, tampered))
Run it with
python main.pyOutput
token length: 43
sha256: 24ba945e8f82a042
genuine: True
tampered: FalseTab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercise 1 of 4
Fix three functions. make_token uses random and only 8 bytes; return secrets.token_urlsafe(32). sign hashes key + text, which is not an HMAC; return the hex HMAC-SHA256 tag of text.encode() under key. verify compares with ==; use hmac.compare_digest.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
import secrets and hmac; random and hashlib are no longer needed.
hmac.new(key, text.encode(), "sha256").hexdigest() is the tag.
verify returns hmac.compare_digest(sign(key, text), tag).
One way to solve it. Yours can look different and still pass the checks.
import hmac
import secrets
def make_token() -> str:
"""Return a URL-safe token with 32 bytes of randomness."""
return secrets.token_urlsafe(32)
def sign(key: bytes, text: str) -> str:
"""Return the hex HMAC-SHA256 tag of text under key."""
return hmac.new(key, text.encode(), "sha256").hexdigest()
def verify(key: bytes, text: str, tag: str) -> bool:
"""Return True if tag is the right tag for text under key."""
return hmac.compare_digest(sign(key, text), tag)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import hashlib
import random
def make_token() -> str:
"""Return a URL-safe token with 32 bytes of randomness."""
return "%016x" % random.getrandbits(64)
def sign(key: bytes, text: str) -> str:
"""Return the hex HMAC-SHA256 tag of text under key."""
return hashlib.sha256(key + text.encode()).hexdigest()
def verify(key: bytes, text: str, tag: str) -> bool:
"""Return True if tag is the right tag for text under key."""
return sign(key, text) == tag
test_main.py
import string
from main import make_token, sign, verify
URLSAFE = set(string.ascii_letters + string.digits + "-_")
def test_token_shape():
"""A token has 43 URL-safe characters (32 random bytes)"""
token = make_token()
assert len(token) == 43 and set(token) <= URLSAFE, f"make_token gave {token!r}; use secrets.token_urlsafe(32)"
def test_tokens_differ():
"""Two tokens are different"""
assert make_token() != make_token(), "make_token returned the same token twice"
def test_sign_is_hmac():
"""sign returns the HMAC-SHA256 tag"""
got = sign(b"key", "msg")
want = "2d93cbc1be167bcb1637a4a23cbff01a7878f0c50ee833954ea5221bb1b8c628"
assert got == want, f"sign(b'key', 'msg') gave {got!r}; use hmac.new(key, text.encode(), 'sha256')"
def test_verify():
"""verify accepts the right tag and refuses changed text or another key"""
tag = sign(b"key", "user=ada")
assert verify(b"key", "user=ada", tag), "the correct tag was refused"
assert not verify(b"key", "user=admin", tag), "a tag for other text was accepted"
assert not verify(b"other", "user=ada", tag), "a tag made with another key was accepted"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 4
hash_password stores a bare SHA-256 of the password: no salt, and fast to guess. Store "salt$hash" instead: 16 random bytes from secrets as the salt, and hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS) as the hash, both in hex. check_password recomputes with the stored salt and compares with hmac.compare_digest. Run the tests on your own computer: the browser has no pbkdf2_hmac.
This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.
salt = secrets.token_bytes(16), then return salt.hex() + "$" + digest.hex().
In check_password, split on "$" and turn the salt back into bytes with bytes.fromhex.
Compare digest.hex() with the stored hex using hmac.compare_digest.
One way to solve it. Yours can look different and still pass the checks.
import hashlib
import hmac
import secrets
ITERATIONS = 100_000
def hash_password(password: str) -> str:
"""Return "salt$hash" in hex, with a new random salt each time."""
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS)
return salt.hex() + "$" + digest.hex()
def check_password(password: str, stored: str) -> bool:
"""Return True if password matches what hash_password stored."""
salt_hex, digest_hex = stored.split("$")
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), ITERATIONS)
return hmac.compare_digest(digest.hex(), digest_hex)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import hashlib
ITERATIONS = 100_000
def hash_password(password: str) -> str:
"""Return "salt$hash" in hex, with a new random salt each time."""
# sha256 of the bare password: no salt, and fast to brute-force.
return "$" + hashlib.sha256(password.encode()).hexdigest()
def check_password(password: str, stored: str) -> bool:
"""Return True if password matches what hash_password stored."""
return hash_password(password) == stored
test_main.py
import hashlib
from main import check_password, hash_password
def test_round_trip():
"""The right password matches, a wrong one does not"""
stored = hash_password("hunter2")
assert check_password("hunter2", stored), "the right password was refused"
assert not check_password("hunter3", stored), "a wrong password was accepted"
def test_salted():
"""The same password hashed twice gives different results"""
assert hash_password("hunter2") != hash_password("hunter2"), "no random salt: equal passwords give equal hashes"
def test_uses_pbkdf2():
"""The stored hash is PBKDF2-HMAC-SHA256 with a 16-byte salt"""
salt_hex, _, digest_hex = hash_password("pw").partition("$")
assert len(salt_hex) == 32, f"the salt part is {salt_hex!r}; use 16 random bytes, in hex"
want = hashlib.pbkdf2_hmac("sha256", b"pw", bytes.fromhex(salt_hex), 100_000).hex()
assert digest_hex == want, "the hash part is not pbkdf2_hmac('sha256', password, salt, ITERATIONS)"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 3 of 4
render(template) inserts values raw, and report builds its lines with f-strings, so a name such as "<b>bob</b>" becomes HTML. Make render apply each interpolation's conversion and format spec, then html.escape the result. Then build report's lines as t-strings and pass them through render.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
from string.templatelib import convert: value = convert(item.value, item.conversion).
Then escape(format(value, item.format_spec)), with escape from html.
In report, write render(t"<li>{name}: {score:>3}</li>"): only the values are escaped, not your own tags.
One way to solve it. Yours can look different and still pass the checks.
from html import escape
from string.templatelib import Interpolation, Template, convert
def render(template: Template) -> str:
"""Join the template, HTML-escaping every interpolated value."""
parts: list[str] = []
for item in template:
if isinstance(item, Interpolation):
value = convert(item.value, item.conversion)
parts.append(escape(format(value, item.format_spec)))
else:
parts.append(item)
return "".join(parts)
def report(title: str, rows: list[tuple[str, int]]) -> str:
"""Return an HTML report: a heading and one list item per row."""
lines = [render(t"<h1>{title}</h1>")]
for name, score in rows:
lines.append(render(t"<li>{name}: {score:>3}</li>"))
return "\n".join(lines)
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from string.templatelib import Interpolation, Template
def render(template: Template) -> str:
"""Join the template, HTML-escaping every interpolated value."""
return "".join(
str(item.value) if isinstance(item, Interpolation) else item for item in template
)
def report(title: str, rows: list[tuple[str, int]]) -> str:
"""Return an HTML report: a heading and one list item per row."""
lines = [f"<h1>{title}</h1>"]
for name, score in rows:
lines.append(f"<li>{name}: {score:>3}</li>")
return "\n".join(lines)
test_main.py
from main import render, report
def test_render_escapes():
"""Interpolated values are HTML-escaped"""
x = "<script>"
got = render(t"<p>{x}</p>")
assert got == "<p><script></p>", f"render gave {got!r}"
def test_render_format_spec():
"""The format spec is applied"""
n = 7
got = render(t"[{n:>4}]")
assert got == "[ 7]", f"render gave {got!r}; apply format(value, item.format_spec)"
def test_render_conversion():
"""!r is applied, then escaped"""
v = "a<b"
got = render(t"{v!r}")
assert got == "'a<b'", f"render gave {got!r}; apply convert(value, item.conversion)"
def test_report():
"""The report escapes the title and every name"""
got = report("Scores <2026>", [("ada", 3), ("<b>bob</b>", 12)])
want = "<h1>Scores <2026></h1>\n<li>ada: 3</li>\n<li><b>bob</b>: 12</li>"
assert got == want, f"report gave {got!r}"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 4 of 4
Before running python3 -m build, check the project file. package_info(text) should return "name version" and raise ValueError unless there is a [build-system] table with a requires list and a build-backend string, and a [project] table whose name and version are strings. The starter crashes with KeyError or TypeError instead.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Use data.get("project") and data.get("build-system"), and check each is a dict.
Check isinstance(name, str) and isinstance(version, str); version = 1 in TOML is an int.
Check isinstance(system.get("requires"), list) and isinstance(system.get("build-backend"), str).
One way to solve it. Yours can look different and still pass the checks.
import tomllib
def package_info(text: str) -> str:
"""Check a pyproject.toml before building; return "name version"."""
data = tomllib.loads(text)
project = data.get("project")
system = data.get("build-system")
if not isinstance(project, dict) or not isinstance(system, dict):
raise ValueError("need a [project] and a [build-system] table")
name = project.get("name")
version = project.get("version")
if not isinstance(name, str) or not isinstance(version, str):
raise ValueError("[project] needs name and version as strings")
if not isinstance(system.get("requires"), list) or not isinstance(system.get("build-backend"), str):
raise ValueError("[build-system] needs a requires list and a build-backend string")
return f"{name} {version}"
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import tomllib
def package_info(text: str) -> str:
"""Check a pyproject.toml before building; return "name version"."""
data = tomllib.loads(text)
return data["project"]["name"] + " " + data["project"]["version"]
test_main.py
from main import package_info
GOOD = """
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "example_package"
version = "0.0.1"
"""
NO_BUILD_SYSTEM = """
[project]
name = "example_package"
version = "0.0.1"
"""
NUMBER_VERSION = """
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "example_package"
version = 1
"""
NO_PROJECT = """
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
"""
def rejects(text):
try:
package_info(text)
except ValueError:
return True
return False
def test_good():
"""A complete pyproject.toml gives its name and version"""
got = package_info(GOOD)
assert got == "example_package 0.0.1", f"package_info gave {got!r}"
def test_needs_build_system():
"""A missing [build-system] table is refused"""
assert rejects(NO_BUILD_SYSTEM), "a file without [build-system] was accepted or raised the wrong error"
def test_version_is_string():
"""A version that is a number is refused"""
assert rejects(NUMBER_VERSION), "version = 1 was accepted or raised the wrong error; it must be a string"
def test_needs_project():
"""A missing [project] table is refused with ValueError"""
assert rejects(NO_PROJECT), "a file without [project] was accepted or raised the wrong error"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyimport hashlib
digest = hashlib.sha256("report,2026").hexdigest()
What Python prints
TypeError: Strings must be encoded before hashingWhy, and the fix
Hash functions work on bytes, and the same text can be encoded in different ways. Encode explicitly, usually "report,2026".encode() (UTF-8), so every program that checks the digest hashes the same bytes.
import hmac
tag = hmac.new(b"server-key", b"user=ada").hexdigest()
What Python prints
TypeError: Missing required argument 'digestmod'.Why, and the fix
digestmod is required: pass the algorithm name as the third argument, hmac.new(key, msg, "sha256"), or as digestmod=hashlib.sha256. There is no default, so the choice is always visible in the code.
import hashlib
import secrets
salt = secrets.token_bytes(16)
key = hashlib.pbkdf2_hmac("sha256", "hunter2", salt, 100_000)
What Python prints
TypeError: a bytes-like object is required, not 'str'Why, and the fix
pbkdf2_hmac reads the password and the salt as bytes. Encode the password first: "hunter2".encode(). The salt from secrets.token_bytes is already bytes.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.