Frontend Labs
⋯

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.ciphers unless 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.Fernet is 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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36