In the previous lesson we closed the course's debt: MediNube's records are now encrypted at rest with AES-256-GCM in the v1 format. But every example started with a trick we promised to resolve — key = os.urandom(32) —, a placeholder that dodges the question: in a real system, where does that 32-byte key come from? A clinic administrator doesn't memorize 32 random bytes; they type a password. And a password is not a key. This lesson covers the bridge between the two worlds: key derivation functions (KDF). You'll learn to turn passwords into keys with KDFs that are deliberately slow (PBKDF2, scrypt, Argon2), the role of the salt we promised back in 01-03, and how to derive multiple subkeys from a strong master key with HKDF. As the module's final lesson, we'll close out the whole module and open the next one.
Contents
- Why a password isn't a key
- What a KDF is and what properties it has
- Password KDFs: PBKDF2, scrypt, and Argon2
- The role of the salt
- HKDF: deriving subkeys from a strong master key
- Practical case: an exportable MediNube backup with scrypt
- Deriving keys to encrypt vs storing passwords to authenticate
Why a password isn't a key
An AES-256 key is 32 bytes of pure entropy (256 real bits), straight out of the CSPRNG (01-03). A human password is something very different:
| Cryptographic key | Human password | |
|---|---|---|
| Length | Fixed (32 bytes) | Variable (6, 12, 40 characters...) |
| Composition | Any bytes (0–255) | Printable characters, patterns, words |
| Entropy | Maximal: 256 real bits | Low: "Summer2026!" has maybe 30-40 bits |
| Origin | CSPRNG | A person's memory |
Two problems stop you from using a password directly as a key:
- Incompatible format. AES-256 requires exactly 32 bytes. An 11-character password is 11 bytes; a 40-character one, forty. It doesn't fit.
- Insufficient, structured entropy. Even if you trimmed or padded it to 32 bytes, it would still carry its original low entropy: an attacker wouldn't try 2^256 keys, they'd try a dictionary of common passwords — millions, not quattuorvigintillions. AES-256's theoretical strength collapses to that of the password.
We need a function that takes that weak, variable-length password and produces a 32-byte key with the right format, while also making a dictionary attack as expensive as possible. That's a KDF.
What a KDF is and what properties it has
A KDF (Key Derivation Function) transforms input material (a password, or a master key) into one or more cryptographic keys with the desired size and properties. Properties that define a good KDF:
- Fixed, uniform-length output. It produces exactly the bytes you ask for (32 for AES-256), and those bytes are indistinguishable from random even if the input has structure.
- Deterministic. The same input (same password + same salt + same parameters) always produces the same key. Essential: otherwise you couldn't decrypt tomorrow what you encrypted today.
- One-way. From the output key you can't get back to the password.
- Costly on demand (for password KDFs): tunable to deliberately take time, making brute force more expensive.
There's a fundamental split here that structures the rest of the lesson:
- Slow KDFs for low-entropy inputs (passwords): PBKDF2, scrypt, Argon2. Their whole point is being expensive — the more each attempt costs, the fewer attempts per second an attacker can make.
- Fast KDFs for already-strong inputs (a 256-bit master key): HKDF. Here there's no need to slow anything down, because the input already has full entropy; we just want to spread it across several subkeys.
Using the wrong tool is a classic mistake: HKDF over a password does not protect it (it's fast, brute force flies); a slow KDF to chop up a master key just wastes CPU.
Password KDFs: PBKDF2, scrypt, and Argon2
All three derive a key from a password, and all three are deliberately slow. They differ in how they impose the cost, which determines their resistance to different kinds of attackers (who today use GPUs and dedicated hardware, not CPUs).
- PBKDF2: the classic one (and the one FIPS requires). Its cost is the number of iterations: it repeats an HMAC tens or hundreds of thousands of times. Problem: it only costs computation, and GPUs do mass computation very cheaply. It's still acceptable with enough iterations, but it's the weakest of the three against modern hardware.
- scrypt: adds memory cost (memory-hard). It forces the use of a large amount of RAM in addition to CPU, and memory is expensive to parallelize on GPUs/ASICs. Much better than PBKDF2 against attackers with specialized hardware. It ships with
pyca/cryptography. - Argon2: the state of the art, winner of the Password Hashing Competition (2015). Memory-hard, with independent time, memory, and parallelism parameters. The recommended variant is Argon2id.
pyca/cryptographydoesn't include it; you use the separateargon2-cffipackage (pip install argon2-cffi).
| KDF | Cost it imposes | GPU/ASIC resistance | In pyca/cryptography | When to choose it |
|---|---|---|---|---|
| PBKDF2 | Iterations (CPU only) | Low-medium | Yes | If you need FIPS/compatibility |
| scrypt | CPU + memory | Good | Yes | Solid general choice with what the library ships |
| Argon2id | Adjustable CPU + memory + parallelism | The best | No (use argon2-cffi) |
Recommended for new projects |
Recommended, indicative parameters (2026; always double-check against current OWASP guidelines, since they rise with hardware):
- PBKDF2-HMAC-SHA256: ≥ 600,000 iterations.
- scrypt: n = 2^17 (131072), r = 8, p = 1 (≈128 MB of memory).
- Argon2id: memory ≥ 19 MiB, ≥ 2 iterations, parallelism 1 (OWASP minimums; raise the memory if the server allows it).
The rule of thumb for calibrating: tune the parameters so that deriving ONE key takes between 0.1 and 0.5 s on your hardware. It's a blink of an eye for your legitimate user (they derive once), but it multiplies by millions the cost for an attacker trying dictionaries. Example with scrypt, the one we'll use at MediNube:
import os
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
password = "P4ssw0rd-Admin-Sol!".encode("utf-8") # low entropy, variable length
salt = os.urandom(16) # 16 bytes from the CSPRNG, UNIQUE (see below)
def derive_key(password: bytes, salt: bytes) -> bytes:
kdf = Scrypt(
salt=salt,
length=32, # we want 32 bytes -> AES-256 key
n=2**17, # CPU/memory cost (power of 2)
r=8,
p=1,
)
return kdf.derive(password)
key = derive_key(password, salt)
print(len(key), key.hex()) # 32 random-looking bytes
# Verify a candidate password: it's RE-derived with the SAME salt and compared.
kdf_check = Scrypt(salt=salt, length=32, n=2**17, r=8, p=1)
kdf_check.verify(password, key) # doesn't raise -> correct; InvalidKey if it doesn't matchDetails:
Scrypt(...).derive(password)produces the 32 bytes. AScryptobject is single-use: to verify or re-derive, create a new one.- The 16-byte
saltmust be stored (we'll see this next): without it you can't re-derive the same key. kdf.verify(password, expected_key)re-derives and compares in constant time; it raisesInvalidKeyif it doesn't match.
The role of the salt
The salt is a random, unique value combined with the password before deriving. In 01-03 we promised to explain salts; here's their reason for existing. Without a salt, derive(password) would be a fixed function, and that opens two attacks:
- Precomputed tables (rainbow tables). An attacker precomputes the derivation of millions of common passwords once and then looks up matches instantly. The salt kills this: with a different salt per user, the table would have to be rebuilt for every salt — infeasible.
- Batch attacks. Without a salt, two administrators with the same password produce the same key, and breaking one breaks both. An attacker attacks everyone at once. With a unique salt, each derivation is an independent problem: they have to be attacked one at a time.
Properties of the salt that surprise beginners:
- It's unique per derivation (
os.urandom(16)every time). - It's random, from the CSPRNG.
- It's NOT secret. It's stored in the clear, alongside the result. Its job isn't to hide, but to be different in every case so as to individualize each derivation. It's exactly the same philosophy as the IV/nonce from 02-02: public but unrepeatable.
That's why, on disk, a password-protected record always stores the salt right next to it:
HKDF: deriving subkeys from a strong master key
Let's switch scenarios. Sometimes the input is already strong: a master key with 256 real bits of entropy (MediNube's case). We don't want to "protect" it with slowness — it's already unbreakable by brute force —; we want to derive several independent subkeys from it, one per purpose. Why not use the same master key for everything? For domain separation: if each use has its own subkey, compromising or reusing one doesn't affect the others, and you avoid nonce collisions between different subsystems.
The tool is HKDF (HMAC-based KDF), fast because the input already has entropy. It works in two conceptual phases: extract (condenses the input into a uniform internal key) and expand (stretches it into as many bytes as you ask for). Its star parameter is info: a context label that separates the derivations. Same master key + different info = cryptographically independent subkeys.
import os
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
master_key = os.urandom(32) # already-strong input (256 real bits)
def derive_subkey(master_key: bytes, context: bytes) -> bytes:
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=32, # 32 bytes -> AES-256 subkey
salt=None, # optional here; the input already has maximal entropy
info=context, # LABEL that separates this subkey from the others
)
return hkdf.derive(master_key)
k_medical_records = derive_subkey(master_key, b"medinube:historiales:v1")
k_backups = derive_subkey(master_key, b"medinube:backups:v1")
print(k_medical_records != k_backups) # True: two independent subkeys from ONE master keyWith this, MediNube's master key gives rise to k_medical_records (the one that feeds encrypt_medical_record_v1 from 02-03) and k_backups (for backups), with neither revealing anything about the other. Notice the difference from password KDFs: here info takes the starring role the salt had, there's no cost parameter (it's fast on purpose), and the input must be strong to begin with — HKDF does not rescue a weak password.
Practical case: an exportable MediNube backup with scrypt
Let's put the module's pieces together in a real case. MediNube lets a clinic administrator export an encrypted backup of their records, protected by a passphrase only they know (so it can be restored in a different environment without depending on the server's master key). The flow combines scrypt (password → key) with AES-256-GCM (02-03):
import os
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
SCRYPT_N, SCRYPT_R, SCRYPT_P = 2**17, 8, 1
def export_backup(passphrase: str, data: bytes) -> bytes:
"""salt(16) || nonce(12) || AES-256-GCM(ciphertext+tag). The passphrase is NEVER stored."""
salt = os.urandom(16)
key = Scrypt(salt=salt, length=32, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P).derive(
passphrase.encode("utf-8"))
nonce = os.urandom(12)
aad = b"medinube-backup;formato=v1"
ciphertext = AESGCM(key).encrypt(nonce, data, aad)
return salt + nonce + ciphertext
def restore_backup(passphrase: str, blob: bytes) -> bytes:
"""Re-derives the key with the stored salt and decrypts. Fails if the passphrase is wrong."""
salt, nonce, ciphertext = blob[:16], blob[16:28], blob[28:]
key = Scrypt(salt=salt, length=32, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P).derive(
passphrase.encode("utf-8"))
aad = b"medinube-backup;formato=v1"
return AESGCM(key).decrypt(nonce, ciphertext, aad) # InvalidTag if the passphrase is wrong
# Usage:
data = "Backup Clinica Sol: Ana Perez's medical record, ...".encode("utf-8")
blob = export_backup("Long-secret-admin-passphrase-2026", data)
print(restore_backup("Long-secret-admin-passphrase-2026", blob).decode("utf-8")) # OK
try:
restore_backup("wrong-passphrase", blob)
except Exception as e:
print("Rejected:", type(e).__name__) # InvalidTag: the derived key doesn't matchNotice how the whole module fits together:
- The salt (16 bytes, public) is stored at the start of the blob: without it, the key can't be re-derived. The nonce (12 bytes, public, unique) also travels included — just like in 02-03's
v1format. - The passphrase is never stored: it only ever exists in the administrator's head. If they get it wrong when restoring, the derived key is different and GCM's
decryptfails withInvalidTag— AEAD's integrity check detects the wrong passphrase "for free." - scrypt makes an attacker who steals the blob and wants to try passphrases from a dictionary pay scrypt's cost on every attempt.
Fictional data, as always; a real deployment of health-data backups requires security and compliance review (GDPR).
Deriving keys to encrypt vs storing passwords to authenticate
A crucial distinction that avoids one of the most common mix-ups. In this lesson we've used KDFs with passwords to derive a key and encrypt data (backups, medical records). There's a similar but distinct use of these same function families: storing user passwords to authenticate them when they log in (the classic "log ana.perez into the portal").
| Deriving a key to encrypt (this lesson) | Storing a password to authenticate (03-03) | |
|---|---|---|
| Goal | Get a key for AES | Verify that whoever's logging in knows the password |
| What's stored | The encrypted data (+ salt, nonce) | The hash of the password (+ salt, parameters) |
| The derived key... | is used to encrypt/decrypt | isn't used for anything; it's only compared |
| Functions | scrypt/Argon2/PBKDF2 (+ HKDF for subkeys) | scrypt/Argon2/PBKDF2, in "password hashing" mode |
They share the machinery (slow KDFs, salt, cost), but the purpose differs: here the derived key is a means to encrypt; in authentication, the output is the end goal — it's stored to be re-checked on every login, and nothing is ever encrypted with it. That second scenario, secure storage of user passwords, is developed in full in lesson 03-03. Here we're just flagging it so you don't confuse the two uses.
Common Mistakes and Tips
- Using a password directly as an AES key. The mistake that opened the lesson. Always pass it through a password KDF.
- Using a fast KDF (HKDF, or a plain hash) on a password. It doesn't protect it: brute force flies. For passwords, use a slow KDF (scrypt/Argon2/PBKDF2). HKDF is only for already-strong inputs.
- Reusing the salt, or believing it's secret. The salt is unique per derivation and public; it's stored in the clear alongside the result. Reusing it reopens rainbow tables and batch attacks.
- Copying cost parameters from an old tutorial. They rise with hardware: 1,000 PBKDF2 iterations was secure in 2010 and is a joke today. Calibrate to 0.1–0.5 s and check OWASP.
- Forgetting to store salt/nonce/parameters. Without them you can't re-derive or decrypt. Store them alongside the data (the backup blob does this).
- Confusing deriving-to-encrypt with storing-to-authenticate. Different purposes; password authentication is 03-03.
Exercises
-
Raise the cost. Derive a key with scrypt using
n=2**14and time it withtime.perf_counter(); repeat withn=2**17. By how much does the time multiply? Explain why that increased cost hurts the attacker far more than the legitimate user. -
The magic of
infoin HKDF. Derive two subkeys from the SAMEmaster_keywithinfo=b"historiales"andinfo=b"backups". Check that they're different. Then derive again withinfo=b"historiales"and check it matches the first one. What KDF property guarantees that reproducibility? -
Wrong passphrase detected for free. With
export_backup/restore_backup, export a backup and restore it with the wrong passphrase. Why does it fail withInvalidTagand not with an explicit "wrong key" error? Which two mechanisms from the module (scrypt + AES-GCM) collaborate to produce that failure?
Solutions
-
Going from
n=2**14ton=2**17multiplies the cost by 8, so the time multiplies by roughly 8 (e.g. from ~15 ms to ~120 ms). For the legitimate user it's a blink of an eye: they derive only once, when exporting or restoring. For an attacker trying a dictionary of, say, a billion passphrases, that factor of 8 multiplies the cost of each of those billion attempts by 8 — turning days into weeks. The asymmetry (you derive once, the attacker derives millions of times) is exactly what makes the slowness useful. -
The two subkeys with different
infocome out different; re-deriving withinfo=b"historiales"reproduces the first subkey exactly. This is guaranteed by the KDF's deterministic property: same input (master key + info + parameters) → same output. Without it we couldn't get the subkey back to decrypt tomorrow what we encrypted today. And with differentinfo, the subkeys stay cryptographically separated despite coming from the same master key. -
It fails with
InvalidTagbecause a wrong passphrase, passed through scrypt with the stored salt, produces a different derived key from the original one. When trying to decrypt with that wrong key, AES-GCM's tag (02-03) doesn't validate anddecryptraisesInvalidTag. Two mechanisms collaborate: scrypt turns "bad passphrase" into "bad key" deterministically, and AES-GCM turns "bad key" into "detected failure" thanks to its integrity check. We don't need to check the passphrase separately: the AEAD itself does it, and in constant time.
Conclusion
With key derivation we close out Module 2: Symmetric Cryptography, and with it, the arc that started in module 1. The journey has been complete: we met AES and ChaCha20 (02-01), learned that a block cipher needs a mode and why ECB leaks while CBC and CTR require an unrepeatable IV/nonce (02-02), made the leap to authenticated encryption, which unites confidentiality and integrity, and finally paid off the course's debt by encrypting MediNube's records with AES-256-GCM in the v1 format (02-03), and in this lesson we've resolved the last unknown — where the keys come from: turning passwords into keys with slow KDFs (PBKDF2, scrypt, Argon2) protected by a unique, public salt, and splitting a strong master key into independent subkeys with HKDF. MediNube's balance sheet has changed completely since the end of module 1: Ana Pérez's records are no longer unencrypted on disk, but protected with authenticated encryption; there's a centralized, crypto-agile medinube.crypto module; and we know how to derive both the master key from an administrator's passphrase and the subkeys per purpose. The symmetric foundation is built. What's still missing are the pieces that guarantee integrity and identity without encrypting anything: how do you check that a file hasn't changed without decrypting it? How do you authenticate a message between two parties who share a key? And the question we've brushed against twice — how are the user passwords that log into MediNube's portal stored securely, a different problem from deriving keys to encrypt? All of that is Module 3: Hashes, MACs, and Passwords, which begins in lesson 03-01 with cryptographic hash functions — the primitive on which HMAC (03-02) and secure password storage (03-03) are built. See you in module 3.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
