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

  1. Why a password isn't a key
  2. What a KDF is and what properties it has
  3. Password KDFs: PBKDF2, scrypt, and Argon2
  4. The role of the salt
  5. HKDF: deriving subkeys from a strong master key
  6. Practical case: an exportable MediNube backup with scrypt
  7. 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:

  1. Incompatible format. AES-256 requires exactly 32 bytes. An 11-character password is 11 bytes; a 40-character one, forty. It doesn't fit.
  2. 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/cryptography doesn't include it; you use the separate argon2-cffi package (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 match

Details:

  • Scrypt(...).derive(password) produces the 32 bytes. A Scrypt object is single-use: to verify or re-derive, create a new one.
  • The 16-byte salt must 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 raises InvalidKey if 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:

  1. 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.
  2. 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:

stored = salt (16 bytes, in the clear)  ||  derived/encrypted data

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 key

With 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 match

Notice 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 v1 format.
  • 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 decrypt fails with InvalidTag — 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

  1. Raise the cost. Derive a key with scrypt using n=2**14 and time it with time.perf_counter(); repeat with n=2**17. By how much does the time multiply? Explain why that increased cost hurts the attacker far more than the legitimate user.

  2. The magic of info in HKDF. Derive two subkeys from the SAME master_key with info=b"historiales" and info=b"backups". Check that they're different. Then derive again with info=b"historiales" and check it matches the first one. What KDF property guarantees that reproducibility?

  3. Wrong passphrase detected for free. With export_backup/restore_backup, export a backup and restore it with the wrong passphrase. Why does it fail with InvalidTag and not with an explicit "wrong key" error? Which two mechanisms from the module (scrypt + AES-GCM) collaborate to produce that failure?

Solutions

  1. Going from n=2**14 to n=2**17 multiplies 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.

  2. The two subkeys with different info come out different; re-deriving with info=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 different info, the subkeys stay cryptographically separated despite coming from the same master key.

  3. It fails with InvalidTag because 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 and decrypt raises InvalidTag. 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.

© Copyright 2026. All rights reserved