48. Cryptography done right: secrets, hmac, AEAD, password hashing
The Security guide Parts 3–5 explain the theory. This chapter is the Python checklist.
48.1 Randomness: secrets, never random
import secrets
token = secrets.token_urlsafe(32) # 256-bit random, URL-safe: session IDs, reset links, API keys
key = secrets.token_bytes(32) # raw key material
otp = f"{secrets.randbelow(10**6):06d}" # a 6-digit code
print(token, key.hex()[:16], otp)
random is a Mersenne Twister: fast, reproducible from its seed, and
predictable after observing 624 outputs. It is for simulations and ML
(where you want seeds, Ch 62), never for tokens or keys. secrets reads
the OS CSPRNG (getrandom(2)).
48.2 Hashing and HMAC
import hashlib
import hmac
print(hashlib.sha256(b"model-weights").hexdigest()) # integrity / content addressing
with open(__file__, "rb") as f:
print(hashlib.file_digest(f, "sha256").hexdigest()) # 3.11+: efficient file hashing
# Webhook verification (GitHub, Stripe, Slack all work like this):
secret = b"whsec_shared_secret"
body = b'{"event": "payment.succeeded", "amount": 1500}'
signature = hmac.new(secret, body, hashlib.sha256).hexdigest() # what the sender computes
def verify(body: bytes, received_sig: str) -> bool:
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_sig) # constant time: no timing leak
print(verify(body, signature), verify(body + b" ", signature)) # True False
Rules:
- A plain hash proves nothing about who sent the data: anyone can hash. Use HMAC with a shared secret to authenticate a message (Security Ch 11).
- Compare secrets and MACs with
hmac.compare_digest, never==.==stops at the first differing byte, and the timing difference can leak the expected value byte by byte. - Verify the signature over the raw request bytes, before parsing JSON. Re-serializing changes the bytes.
- MD5 and SHA-1 are broken for collision resistance. Use SHA-256 or
BLAKE2 (
hashlib.blake2b).
48.3 Encryption: AES-GCM with cryptography
The standard library has no encryption. Use cryptography (uv add cryptography), and its high-level, authenticated APIs:
# labs/seal.py: authenticated encryption with AES-256-GCM
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def seal(key: bytes, plaintext: bytes, aad: bytes = b"") -> bytes:
nonce = os.urandom(12) # 96-bit nonce, NEVER reused with the same key
return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
def open_(key: bytes, sealed: bytes, aad: bytes = b"") -> bytes:
nonce, ct = sealed[:12], sealed[12:]
return AESGCM(key).decrypt(nonce, ct, aad) # raises InvalidTag if anything was modified
key = AESGCM.generate_key(bit_length=256)
box = seal(key, b"card=4111111111111111", aad=b"user:42")
print(open_(key, box, aad=b"user:42"))
tampered = box[:-1] + bytes([box[-1] ^ 1])
try:
open_(key, tampered, aad=b"user:42")
except Exception as e:
print("tampering detected:", type(e).__name__) # InvalidTag
- AEAD (AES-GCM, ChaCha20-Poly1305) encrypts and authenticates. Never
use raw AES-CBC or, worse, ECB from
hazmat.primitives.ciphersunless you are implementing a protocol and know exactly why (Security Ch 14–15). - The AAD binds context (a user ID, a record ID) to the ciphertext, so a ciphertext copied to another user's record fails to decrypt.
- Nonce reuse with GCM is catastrophic (it leaks the authentication key). Random 96-bit nonces are fine for up to about 2³² messages per key; rotate keys before that.
- For "encrypt this blob with a password-free key and be done",
cryptography.fernet.Fernetis a simpler, misuse-resistant recipe. - In the cloud, use envelope encryption with a KMS (Security in Depth Ch 9): a KMS key encrypts a per-object data key, and the data key encrypts the data with AES-GCM as above.
48.4 Password storage: Argon2id
# uv add argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # Argon2id, with OWASP-recommended defaults
stored = ph.hash("correct horse battery staple")
print(stored[:60], "...") # $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
def login(stored: str, attempt: str) -> bool:
try:
ph.verify(stored, attempt)
except VerifyMismatchError:
return False
if ph.check_needs_rehash(stored): # parameters were raised since this hash was made
pass # re-hash with the new parameters and save
return True
print(login(stored, "correct horse battery staple"), login(stored, "hunter2"))
Never store passwords with SHA-256, salted or not: GPUs compute billions per
second. Use a slow, memory-hard password hash: Argon2id first, then scrypt
(hashlib.scrypt, in the standard library) or bcrypt. The
Security guide Ch 10
explains why the parameters matter.