Applied Cryptography for Developers Module 6 – Key derivation and password storage
Deriving keys with HKDF
HKDF extract and expand, the jobs of salt and info, RFC 5869 vectors in Python and Web Crypto, labels that keep keys apart, and why passwords need more.
What you will learn
- Explain the extract and expand steps of HKDF and the roles of its salt and info inputs
- Derive several independent keys from one shared secret with distinct, framed labels
- Implement HKDF from HMAC and check it against the test vectors of RFC 5869
- Use the HKDF of Web Crypto within the import and parameter rules it enforces
- Distinguish a key-derivation function from a password-hashing function by what each assumes
Before you start
What you’ll learn · Before you start
What you will learn
- Explain the extract and expand steps of HKDF and the roles of its salt and info inputs
- Derive several independent keys from one shared secret with distinct, framed labels
- Implement HKDF from HMAC and check it against the test vectors of RFC 5869
- Use the HKDF of Web Crypto within the import and parameter rules it enforces
- Distinguish a key-derivation function from a password-hashing function by what each assumes
Before you start
On this page
A key agreement leaves two devices with the same 32 secret bytes, and the protocol needs four keys from them: one to encrypt and one to authenticate in each direction. Using the secret itself for all four would give them one value, so a weakness in any use would reach every other. HKDF, the HMAC-based key derivation function of RFC 5869, turns one secret into as many independent keys as a protocol needs, each bound to a label.
This lesson takes HKDF apart into its extract and expand steps, checks both against RFC 5869’s test vectors in Python and Web Crypto, derives separate keys with labels and shows where a label must also carry the key’s length, and draws the line between deriving keys and hashing passwords. A two-step code lab builds HKDF from HMAC and a key schedule for two devices.
One secret, many keys: derive them instead of reusing the secret
The secret that X25519 or a similar key agreement produces is unpredictable but not uniformly random, and it is one value where a protocol needs several. Reusing one key for two jobs, say AES encryption and an HMAC tag, ties their security together: whatever leaks or weakens through one use touches the other. The fix is key separation: Using a different key for each algorithm, purpose and direction, so that a weakness or a leak in one use cannot reach another. HKDF gives every purpose its own key from one secret., one key for each algorithm, purpose and direction, and a key-derivation function (KDF): A function that turns secret input into one or more keys: HKDF and the KDFs of NIST SP 800-108 for inputs that are already hard to guess, and slow password hashes such as Argon2id for passwords. produces those keys from the one secret. HKDF: The HMAC-based key derivation function of RFC 5869. HKDF-Extract turns input keying material and a salt into a pseudorandom key; HKDF-Expand turns that key and an info label into as many bytes as asked for, up to 255 hash blocks. does it in two steps. Extract turns the secret and a salt into a uniform pseudorandom key (PRK): The uniform key of HashLen bytes that HKDF-Extract makes from the input keying material, 32 bytes for HKDF-SHA-256. It feeds HKDF-Expand and is never used directly as a key.; Expand turns that key and a label into as many bytes as asked for, so each label yields its own key. Step through the derivation of four session keys:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The shared secret. A key agreement such as X25519 leaves both devices with the same 32 bytes. They are secret, but not uniformly random, and one value cannot safely serve as four different keys.
- Extract, with a salt. HKDF-Extract computes HMAC with the salt as the key and the secret as the message. The salt is public and may be reused; with no salt, HKDF uses 32 zero bytes.
- One pseudorandom key. The result is a pseudorandom key of 32 bytes, as good as uniformly random for everything that follows. It is never used directly as a key for encryption or tagging.
- Expand, once per label. HKDF-Expand takes the pseudorandom key and an info label naming the purpose, the direction and the version. Each label gives its own output.
- Keys for client to server. The labels enc c2s v1 and mac c2s v1 give an encryption key and a MAC key for traffic from the client to the server, unrelated to each other.
- Keys for server to client. Two more labels give the keys for the other direction. Four labels, four independent keys, and a leak of one of them reveals nothing about the other three.
The pseudorandom key is never used directly, and every key the protocol uses comes out of Expand with a label of its own. Because each output is unrelated to the others, an attacker who learns the client’s encryption key learns nothing about the server’s MAC key. TLS 1.3 builds its whole key schedule this way, from HKDF-Extract and labelled HKDF-Expand calls, which a later module computes from the RFC’s own traces.
Checkpoint
Check: Deriving keys instead of reusing one secret
Extract concentrates the secret, expand stretches it
RFC 5869 defines the two steps with HMAC and a hash whose output is HashLen bytes, 32 for SHA-256:
In words: the input keying material (IKM): The secret that goes into a key derivation, such as a Diffie-Hellman output, a random root key or another key. It must already be hard to guess, since HKDF adds no entropy. is hashed with the salt as the HMAC key, giving the pseudorandom key; then each block is the HMAC of the previous block, the info label and a one-byte counter, starting from an empty T(0), and the output is the first L bytes of T(1) ∥ T(2) ∥ …. For L = 42 with SHA-256 that is two blocks, all 32 bytes of T(1) and 10 of T(2). Step through it on RFC 5869’s test case 1:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The inputs. Extract has already turned the 22-byte input and the 13-byte salt into the pseudorandom key 07770936…. The info label is the 10 bytes f0 to f9, and the caller asks for 42 bytes.
- The first block. T(1) is the HMAC of the info label followed by the counter byte 01, keyed with the pseudorandom key. It gives 32 bytes, starting 3cb25f25.
- The next block. 42 bytes need two blocks, since one gives only 32. T(2) is the HMAC of T(1), the info label and the counter 02, and starts 34007208.
- Cut to length. The output is T(1) followed by the first 10 bytes of T(2), ending 87185865, exactly the OKM of the RFC. The one-byte counter is why HKDF-SHA-256 stops at 255 blocks, 8,160 bytes.
Python’s standard library has no HKDF, so the program writes both steps with hmac.digest and checks them against the
three SHA-256 test cases of the RFC:
"""HKDF-SHA-256 written with hmac.digest, as RFC 5869 defines it, checked against the RFC's test cases 1 to 3.
Python's standard library has no HKDF of its own, so this is the whole function."""
import hmac
HASH_LEN = 32 # SHA-256
def hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
if not salt:
salt = bytes(HASH_LEN) # no salt means HashLen zero bytes
return hmac.digest(salt, ikm, "sha256")
def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
if length > 255 * HASH_LEN:
raise ValueError(f"HKDF-SHA-256 gives at most {255 * HASH_LEN} bytes, not {length}")
okm, block = b"", b""
for counter in range(1, -(-length // HASH_LEN) + 1): # T(1), T(2), … until there are enough bytes
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
okm += block
return okm[:length]
CASES = [ # RFC 5869 appendix A: IKM, salt, info, L, PRK, OKM
("1", bytes([0x0B] * 22), bytes(range(0x0D)), bytes(range(0xF0, 0xFA)), 42,
"077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5",
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"),
("2", bytes(range(0x00, 0x50)), bytes(range(0x60, 0xB0)), bytes(range(0xB0, 0x100)), 82,
"06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244",
"b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09"
"da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87"),
("3", bytes([0x0B] * 22), b"", b"", 42,
"19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04",
"8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8"),
]
for name, ikm, salt, info, length, prk_hex, okm_hex in CASES:
prk = hkdf_extract(salt, ikm)
okm = hkdf_expand(prk, info, length)
print(f"test case {name}: {len(ikm)}-byte IKM, {len(salt)}-byte salt, {len(info)}-byte info, L = {length}")
print(f" PRK as RFC 5869: {prk.hex() == prk_hex} OKM {okm.hex()[:16]}… as RFC 5869: {okm.hex() == okm_hex}")
ikm = bytes([0x0B] * 22)
same = hmac.digest(b"", ikm, "sha256") == hmac.digest(bytes(32), ikm, "sha256")
print(f"\nas an HMAC key, an empty salt and 32 zero bytes give the same PRK: {same}")
print(f"blocks for L = 42: {-(-42 // HASH_LEN)}, the most bytes HKDF-SHA-256 gives: {255 * HASH_LEN}")
try:
hkdf_expand(bytes(32), b"", 255 * HASH_LEN + 1)
except ValueError as e:
print(f"asking for one byte more: ValueError: {e}") Output
test case 1: 22-byte IKM, 13-byte salt, 10-byte info, L = 42 PRK as RFC 5869: True OKM 3cb25f25faacd57a… as RFC 5869: True test case 2: 80-byte IKM, 80-byte salt, 80-byte info, L = 82 PRK as RFC 5869: True OKM b11e398dc80327a1… as RFC 5869: True test case 3: 22-byte IKM, 0-byte salt, 0-byte info, L = 42 PRK as RFC 5869: True OKM 8da4e775a563c18f… as RFC 5869: True as an HMAC key, an empty salt and 32 zero bytes give the same PRK: True blocks for L = 42: 2, the most bytes HKDF-SHA-256 gives: 8160 asking for one byte more: ValueError: HKDF-SHA-256 gives at most 8160 bytes, not 8161
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hkdf.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
All three cases match, the one with an empty salt and an empty info label included. The roles of the inputs follow from the RFC’s own notes:
- Salt: optional and not secret, and it may be reused; it adds strength, ideally a random value of HashLen bytes. Without one, HKDF uses HashLen zero bytes, which as an HMAC key is the same as an empty salt, as the program shows.
- Info: optional context, such as a protocol name, a purpose and a direction. It binds each output to one use, and it must not depend on the secret itself.
- Length: at most 255 × HashLen bytes, 8,160 for SHA-256, because the counter is a single byte; the program refuses one byte more.
Extract may be skipped when the input already is a uniformly random key, but RFC 5869 says it should not be skipped for Diffie-Hellman outputs, which are not.
Checkpoint
Check: The extract and expand steps of HKDF
Web Crypto’s HKDF, and the rules it enforces
Web Crypto has HKDF built in, behind two rules that catch people out. The secret is imported as an HKDF key, and that
key must be non-extractable. And the parameters must name a salt and an info label even when both are empty. The
program runs test cases 1 and 3, repeats test case 1 with hkdfSync from node:crypto, shows what each API refuses,
and derives two keys from one secret with the labels enc v1 and mac v1:
// HKDF in Node.js 24: Web Crypto's deriveBits on RFC 5869 test cases 1 and 3, the two rules Web Crypto enforces,
// node:crypto's hkdfSync, and two keys derived from one shared secret with different info labels.
import { hkdfSync } from 'node:crypto';
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const enc = new TextEncoder();
const range = (from, to) => Uint8Array.from({ length: to - from }, (_, i) => from + i);
async function hkdf(ikm, salt, info, length) {
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']); // HKDF keys are never extractable
return new Uint8Array(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8));
}
const tc1 = await hkdf(new Uint8Array(22).fill(0x0b), range(0x00, 0x0d), range(0xf0, 0xfa), 42);
const tc3 = await hkdf(new Uint8Array(22).fill(0x0b), new Uint8Array(0), new Uint8Array(0), 42);
console.log(`test case 1, Web Crypto: ${hex(tc1).slice(0, 16)}… as RFC 5869: ${hex(tc1) === '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865'}`);
console.log(`test case 3, Web Crypto: ${hex(tc3).slice(0, 16)}… as RFC 5869: ${hex(tc3) === '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8'}`);
const sync = hkdfSync('sha256', Buffer.alloc(22, 0x0b), Buffer.from(range(0x00, 0x0d)), Buffer.from(range(0xf0, 0xfa)), 42);
console.log(`test case 1, hkdfSync: same bytes as Web Crypto: ${hex(sync) === hex(tc1)}`);
console.log('\nwhat Web Crypto refuses:');
try {
await crypto.subtle.importKey('raw', new Uint8Array(32), 'HKDF', true, ['deriveBits']);
} catch (e) {
console.log(` an extractable HKDF key: ${e.name}: ${e.message}`);
}
const key = await crypto.subtle.importKey('raw', new Uint8Array(32), 'HKDF', false, ['deriveBits']);
try {
await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', info: new Uint8Array(0) }, key, 256);
} catch (e) {
console.log(` parameters without a salt: ${e.name}`);
}
try {
hkdfSync('sha256', Buffer.alloc(32), Buffer.alloc(0), Buffer.alloc(1025), 32);
} catch (e) {
console.log(` hkdfSync with 1,025 bytes of info: ${e.code}`);
}
// One shared secret (a lab value), a public salt, and a label per key.
const secret = new Uint8Array(32).fill(0x5a);
const salt = enc.encode('session 81 salt');
const encKey = await hkdf(secret, salt, enc.encode('enc v1'), 32);
const macKey = await hkdf(secret, salt, enc.encode('mac v1'), 32);
console.log(`\n'enc v1': ${hex(encKey).slice(0, 16)}… 'mac v1': ${hex(macKey).slice(0, 16)}… different: ${hex(encKey) !== hex(macKey)}`); Output
test case 1, Web Crypto: 3cb25f25faacd57a… as RFC 5869: true test case 3, Web Crypto: 8da4e775a563c18f… as RFC 5869: true test case 1, hkdfSync: same bytes as Web Crypto: true what Web Crypto refuses: an extractable HKDF key: SyntaxError: HKDF keys are not extractable parameters without a salt: TypeError hkdfSync with 1,025 bytes of info: ERR_OUT_OF_RANGE 'enc v1': c5f547de10d78f52… 'mac v1': 1534234ccb16787a… different: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hkdf.mjs
Both test cases match, and hkdfSync gives the same bytes. An extractable HKDF key throws a SyntaxError, parameters
without a salt throw a TypeError, so pass new Uint8Array(0) when there is none, and deriveBits takes its
length in bits, 336 for 42 bytes. hkdfSync adds a limit of its own that RFC 5869 does not have: the info label may
be at most 1,024 bytes. The two labelled keys share nothing, although both came from the same secret and salt.
deriveKey hands the key straight to its algorithm
When the output is an AES-GCM or HMAC key, crypto.subtle.deriveKey derives it and imports it in one step, as a
non-extractable key whose bytes your code never holds. Use deriveBits only when you need the raw bytes.
Checkpoint
Check: HKDF in Web Crypto and Node.js
Labels keep derived keys apart, and so must lengths
Every key needs its own info label, and the label has two jobs that are easy to miss. It must name everything that distinguishes the key, and it must read back only one way. The program derives keys for two directions and two purposes, then tries two pitfalls:
"""What the info label of HKDF separates, and what it does not: two purposes, two directions, two key lengths and two
ways of joining the parts of a label. The secret and the salt are lab values."""
import hmac
SECRET = bytes([0x5A] * 32)
SALT = b"session 81 salt"
def hkdf(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
prk = hmac.digest(salt, ikm, "sha256")
okm, block, counter = b"", b"", 1
while len(okm) < length:
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
okm, counter = okm + block, counter + 1
return okm[:length]
def label(*parts: str, length: int) -> bytes:
"""Each part framed by its 2-byte length, then the output length: a label that reads back one way."""
return b"".join(len(p.encode()).to_bytes(2, "big") + p.encode() for p in parts) + length.to_bytes(2, "big")
enc_c2s = hkdf(SECRET, SALT, b"enc c2s v1", 32)
enc_s2c = hkdf(SECRET, SALT, b"enc s2c v1", 32)
mac_c2s = hkdf(SECRET, SALT, b"mac c2s v1", 32)
print(f"enc c2s {enc_c2s.hex()[:16]}… enc s2c {enc_s2c.hex()[:16]}… mac c2s {mac_c2s.hex()[:16]}…")
print(f"three labels, three different keys: {len({enc_c2s, enc_s2c, mac_c2s}) == 3}")
short = hkdf(SECRET, SALT, b"enc c2s v1", 16) # an AES-128 key with the same label as the 32-byte key above
print(f"\nsame label, 16 bytes: {short.hex()}")
print(f"it is the first half of the 32-byte key: {enc_c2s.startswith(short)}")
framed16 = hkdf(SECRET, SALT, label("msc-demo/v1", "enc", "c2s", length=16), 16)
framed32 = hkdf(SECRET, SALT, label("msc-demo/v1", "enc", "c2s", length=32), 32)
print(f"with the length in the label, still the first half: {framed32.startswith(framed16)}")
joined_a = hkdf(SECRET, SALT, ("msc" + "1enc").encode(), 32) # parts joined with nothing between them
joined_b = hkdf(SECRET, SALT, ("msc1" + "enc").encode(), 32)
print(f"\n'msc' + '1enc' and 'msc1' + 'enc' joined: same key: {joined_a == joined_b}")
framed_a = hkdf(SECRET, SALT, label("msc", "1enc", length=32), 32)
framed_b = hkdf(SECRET, SALT, label("msc1", "enc", length=32), 32)
print(f"the same parts framed by their lengths: same key: {framed_a == framed_b}") Output
enc c2s 3040c03f5845d0be… enc s2c 48b33ba6bbc670a3… mac c2s 61c5029f7548e739… three labels, three different keys: True same label, 16 bytes: 3040c03f5845d0be36c9a9ecc89feeb2 it is the first half of the 32-byte key: True with the length in the label, still the first half: False 'msc' + '1enc' and 'msc1' + 'enc' joined: same key: True the same parts framed by their lengths: same key: False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 labels.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The three labelled keys differ, as they should. The first pitfall is length. Expand’s output for 16 bytes is exactly
the first 16 bytes of its output for 32 with the same label, because both start with T(1), so an AES-128 key and an
AES-256 key derived under one label are related. Putting the length into the label, as the second derivation does,
makes the two unrelated. The second pitfall is joining: msc with 1enc and msc1 with enc make the same text and
so the same key, while framing each part by its length keeps them apart, the same rule the message module applied to
tagged fields.
Key idea
Give every key its own info label that names its protocol, version, purpose, direction and length, with each part framed, so that no two keys can ever share a label or be pieces of one another.
Checkpoint
Check: Labels that keep derived keys apart
A key-derivation function is not a password hash
HKDF assumes its input is already hard to guess: a Diffie-Hellman output, a random root key, a key from a hardware module. RFC 5869 puts it plainly: extraction can concentrate the entropy an input has but cannot add any, and slowing an attacker down is not part of HKDF. A password has little entropy, so a key derived from one with HKDF is only as hard to find as the password, and HKDF lets an attacker try guesses very fast. The program measures it against scrypt at the first setting OWASP lists for password storage:
"""What one guess at a password costs when a key is derived from it with HKDF, and when it is derived with scrypt at
the first setting OWASP lists (N = 2^17, r = 8, p = 1). The times depend on the computer and change from run to run."""
import hashlib
import hmac
import time
def hkdf(ikm: bytes, salt: bytes, info: bytes) -> bytes:
prk = hmac.digest(salt, ikm, "sha256")
return hmac.digest(prk, info + b"\x01", "sha256") # 32 bytes: one block of HKDF-Expand
def per_second(work, calls: int) -> float:
start = time.perf_counter()
for _ in range(calls):
work()
return calls / (time.perf_counter() - start)
password, salt = b"monsoon-rain-42", b"per-file salt" # a made-up password
fast = per_second(lambda: hkdf(password, salt, b"file key v1"), 20_000)
slow = per_second(lambda: hashlib.scrypt(password, salt=salt, n=2**17, r=8, p=1, maxmem=2**28, dklen=32), 3)
words = 10_000_000 # a list of likely passwords
print(f"HKDF-SHA-256: {fast:12,.0f} guesses a second")
print(f"scrypt N=2^17, r=8, p=1: {slow:12,.1f} guesses a second, using 128 MiB each")
print(f"10,000,000 guesses take {words / fast:,.0f} s with HKDF and {words / slow / 86400:,.0f} days with scrypt, on one core") Output
HKDF-SHA-256: 697,988 guesses a second scrypt N=2^17, r=8, p=1: 5.1 guesses a second, using 128 MiB each 10,000,000 guesses take 14 s with HKDF and 23 days with scrypt, on one core
This output changes from run to run: the times depend on the computer and change from run to run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 kdf_speed.py
HKDF allowed hundreds of thousands of guesses a second on one core, so a list of ten million likely passwords is done in under a minute, while scrypt allowed a few a second, each needing 128 MiB, which stretches the same list to weeks. The choice depends on what goes in:
| The input | Use | Why |
|---|---|---|
| A Diffie-Hellman or X25519 shared secret | HKDF, Extract with a salt, then Expand | unpredictable but not uniform; Extract fixes that |
| A random root key of 32 bytes | HKDF-Expand, or a KDF in counter mode as in SP 800-108 | already uniform, so Extract may be skipped |
| A password or a passphrase | Argon2id, scrypt or PBKDF2, slow and salted | few bits of entropy; only cost per guess protects it |
| A key from a password hash that must become several keys | the password hash once, then HKDF-Expand with labels | the slow step guards the password, the labels separate the keys |
NIST describes the first two cases in SP 800-56C Rev. 2, for keys from key establishment, and SP 800-108 Rev. 1, for keys from keys. ASVS 5.0 requirement 11.4.4 asks for key-stretching functions whenever keys come from passwords. The next lesson covers those functions.
Checkpoint
Check: A key derivation or a password hash
Practise: HKDF for any hash, and a key schedule for two devices
The lab has two Python steps with sample tests you can run here. Step one writes HKDF for any hash HMAC accepts, held to RFC 5869’s SHA-256 and SHA-1 cases and to the length limits of both. Step two is the key schedule of the first section: four keys for two devices, each with a framed label that carries its purpose, its direction and its length:
Code lab · 2 steps
Build HKDF and a key schedule
Write HKDF from HMAC for any hash, then derive four session keys whose labels keep each one apart from the others.
- HKDF for any hash, from HMAC, with its limits To do
- A key schedule with labels that keep every key apart To do
Exercise · Medium · Python
HKDF for any hash, from HMAC, with its limits
Write hkdf(ikm, salt, info, length, hash_name="sha256") in hkdf.py: HKDF as RFC 5869 defines it, Extract then Expand, for any hash that hmac.digest accepts by name, using only hmac and hashlib:
- Extract: the pseudorandom key is the HMAC of
ikmwithsaltas the key; an empty salt means HashLen zero bytes. - Expand:
T(1) = HMAC(PRK, info ∥ 0x01), thenT(n) = HMAC(PRK, T(n-1) ∥ info ∥ n), with the counter as one byte, until there arelengthbytes; return the firstlengthof them. - HashLen is the hash's digest size: 32 for SHA-256, 20 for SHA-1, 64 for SHA-512. Raise
ValueErrorwhenlengthis not between 1 and 255 × HashLen.
The sample tests use RFC 5869's test cases for SHA-256 and SHA-1, including the one without a salt, and the length limits of both hashes. The starter code makes one block only, so it is right for outputs no longer than one hash.
Starter code · hkdf.py
import hmac
def hkdf(ikm: bytes, salt: bytes, info: bytes, length: int, hash_name: str = "sha256") -> bytes:
"""HKDF of RFC 5869 with the hash hash_name: Extract, then Expand to length bytes."""
prk = hmac.digest(salt, ikm, hash_name)
return hmac.digest(prk, info + b"\x01", hash_name)[:length] # one block only: too short above one hash length The sample tests · test_hkdf.py
from hkdf import hkdf
IKM_22 = bytes([0x0B] * 22)
SALT_13 = bytes(range(0x00, 0x0D))
INFO_10 = bytes(range(0xF0, 0xFA))
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_rfc_5869_case_1_sha256():
"""test case 1: 42 bytes, so two blocks of SHA-256"""
assert hkdf(IKM_22, SALT_13, INFO_10, 42).hex() == (
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
)
def test_rfc_5869_case_3_empty_salt_and_info():
"""test case 3: no salt and no info"""
assert hkdf(IKM_22, b"", b"", 42).hex() == (
"8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8"
)
def test_rfc_5869_case_4_sha1():
"""test case 4: SHA-1, whose 20-byte blocks need three for 42 bytes"""
assert hkdf(bytes([0x0B] * 11), SALT_13, INFO_10, 42, "sha1").hex() == (
"085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896"
)
def test_rfc_5869_case_7_sha1_no_salt():
"""test case 7: SHA-1 with the salt not provided"""
assert hkdf(bytes([0x0C] * 22), b"", b"", 42, "sha1").hex() == (
"2c91117204d745f3500d636a62f64f0ab3bae548aa53d423b0d1f27ebba6f5e5673a081d70cce7acfc48"
)
def test_length_limits():
"""1 to 255 x HashLen bytes: 8,160 for SHA-256 and 5,100 for SHA-1; 0 and one byte more raise ValueError"""
assert len(hkdf(IKM_22, SALT_13, INFO_10, 8160)) == 8160
assert len(hkdf(IKM_22, SALT_13, INFO_10, 5100, "sha1")) == 5100
assert raises_value_error(lambda: hkdf(IKM_22, SALT_13, INFO_10, 8161))
assert raises_value_error(lambda: hkdf(IKM_22, SALT_13, INFO_10, 5101, "sha1"))
assert raises_value_error(lambda: hkdf(IKM_22, SALT_13, INFO_10, 0))
def test_prefix_of_a_longer_output():
"""the first 32 bytes do not depend on how many bytes were asked for"""
assert hkdf(IKM_22, SALT_13, INFO_10, 100)[:32] == hkdf(IKM_22, SALT_13, INFO_10, 32) A hint
Take hash_len = hashlib.new(hash_name).digest_size first and check length against it. Keep the previous block in a variable that starts as b"", and loop while the output is shorter than length, adding 1 to the counter each time.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
A key schedule with labels that keep every key apart
Write session_label(purpose, direction, length) and session_keys(secret, salt) in schedule.py. Two devices that share secret derive the same four keys from it with HKDF-SHA-256, one per purpose and direction:
session_labelreturns the info bytes: the partsmsc-demo/v1,purposeanddirection, each written as its length in 2 bytes, big-endian, followed by its UTF-8 bytes, and then the output length in 2 bytes, big-endian.session_keysreturns a dict with the keys"enc c2s","mac c2s","enc s2c"and"mac s2c". The twoenckeys are 16 bytes, for AES-128; the twomackeys are 32 bytes, for HMAC-SHA-256. Each comes fromhkdf_sha256(secret, salt, session_label(purpose, direction, length), length), which the starter code provides.
The sample tests check the exact label bytes, the four keys for the lesson's lab secret, and that no key is a piece of another. The starter code joins the label's parts with nothing between them and makes every key 32 bytes long.
Starter code · schedule.py
import hmac
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
"""HKDF-SHA-256 of RFC 5869 (given)."""
prk = hmac.digest(salt, ikm, "sha256")
okm, block, counter = b"", b"", 1
while len(okm) < length:
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
okm, counter = okm + block, counter + 1
return okm[:length]
def session_label(purpose: str, direction: str, length: int) -> bytes:
"""The info label of one key: three framed parts, then the length."""
return ("msc-demo/v1" + purpose + direction).encode() # parts run together, and no length
def session_keys(secret: bytes, salt: bytes) -> dict:
"""The four session keys, by "purpose direction"."""
keys = {}
for purpose in ["enc", "mac"]:
for direction in ["c2s", "s2c"]:
keys[f"{purpose} {direction}"] = hkdf_sha256(secret, salt, session_label(purpose, direction, 32), 32)
return keys The sample tests · test_schedule.py
from schedule import session_keys, session_label
SECRET = bytes([0x5A] * 32) # the lesson's lab secret
SALT = b"session 81 salt"
def test_label_bytes():
"""three parts, each after its 2-byte length, then the output length in 2 bytes"""
assert session_label("enc", "c2s", 16) == b"\x00\x0bmsc-demo/v1\x00\x03enc\x00\x03c2s\x00\x10"
assert session_label("mac", "s2c", 32) == b"\x00\x0bmsc-demo/v1\x00\x03mac\x00\x03s2c\x00\x20"
def test_four_keys_of_the_right_sizes():
"""two 16-byte encryption keys and two 32-byte MAC keys"""
keys = session_keys(SECRET, SALT)
assert sorted(keys) == ["enc c2s", "enc s2c", "mac c2s", "mac s2c"]
assert [len(keys[k]) for k in ["enc c2s", "enc s2c", "mac c2s", "mac s2c"]] == [16, 16, 32, 32]
def test_known_keys():
"""the keys both devices derive from the lab secret"""
keys = session_keys(SECRET, SALT)
assert keys["enc c2s"].hex() == "2ccaf331571a6dee11b49dd036ff8a70"
assert keys["enc s2c"].hex() == "f9b4520231258bf89bcf31ee5c304219"
assert keys["mac c2s"].hex() == "8dfc7935ca35f5ab2547f761eaa3c24f834c8d821f2f88f003e63c39c0a90ba9"
assert keys["mac s2c"].hex() == "78fb8e849966e0cd9fc0c1b5b3adb454ad31ff1d0daf9d5869aea3cbf5d5f55c"
def test_no_key_is_a_piece_of_another():
"""no key starts another, and another salt gives four other keys"""
keys = list(session_keys(SECRET, SALT).values())
for a in keys:
for b in keys:
assert a is b or not b.startswith(a), (a.hex(), b.hex())
other = session_keys(SECRET, b"session 82 salt")
assert not set(other.values()) & set(keys)
def test_labels_read_one_way():
"""purposes and directions that run together without framing still give different labels"""
assert session_label("enc", "c2s", 16) != session_label("encc", "2s", 16)
assert session_label("mac", "c2s", 16) != session_label("mac", "c2s", 32) A hint
Write a helper that frames one part, len(part.encode()).to_bytes(2, "big") + part.encode(), join the three framed parts, and add length.to_bytes(2, "big"). Then loop over the four combinations of purpose and direction.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
A short adaptive session closes the lesson: one question at a time across the five ideas, the shakiest first, with every answer explained:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid level): what do the salt and the info inputs of HKDF do, and does either of them have to be secret?
Neither has to be secret. The salt goes into HKDF-Extract as the HMAC key: it makes the extraction stronger and keeps uses of the same secret apart, ideally as a random value as long as the hash output, and it may be reused; without one, HKDF uses zero bytes. The info label goes into every block of HKDF-Expand: it binds each output to one context, such as a protocol, a version, a purpose and a direction, so that the same secret gives independent keys for different uses. Only the input keying material must be secret, and it must already be hard to guess: HKDF concentrates entropy but cannot add any, so a password needs a slow password hash instead.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A service encrypts records with AES-256-GCM and also tags them with HMAC-SHA-256, using the same 32-byte key for both. Explain the risk and fix it without re-encrypting every record at once.
-
Fresher to mid
A desktop app encrypts each file with a key from HKDF-SHA-256 over the user's password and a random salt. Review the design and propose a better one, with numbers for an attacker who copies a file.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why derive keys from a shared secret instead of using the secret itself?
The secret from a key agreement is unpredictable but not uniformly random, and it is one value where a protocol needs several. Using one key for both AES and an HMAC tag ties their security together. Key separation gives one key per algorithm, purpose and direction, and a key-derivation function such as HKDF produces them from the one secret.
-
What do HKDF's Extract and Expand steps do?
Extract turns the input keying material and a salt into a uniform pseudorandom key: PRK = HMAC(salt, IKM). Expand turns the PRK and an info label into as many bytes as asked: T(n) = HMAC(PRK, T(n−1) ∥ info ∥ n), from an empty T(0). The PRK is never used directly. Extract may be skipped for an already uniform key, but not for Diffie-Hellman outputs.
-
How long can an HKDF output be, and why?
At most 255 × HashLen bytes, 8,160 for SHA-256, because the block counter is a single byte. For 42 bytes Expand makes two blocks: all 32 bytes of T(1) and 10 of T(2).
-
What are the roles of HKDF's salt and info inputs, and must either be secret?
Neither is secret. The salt is the HMAC key of Extract: optional, reusable, ideally random and HashLen bytes long; without one HKDF uses HashLen zero bytes. The info label binds each output to one use, such as a protocol, purpose and direction, and must not depend on the secret itself. Only the input keying material must be secret, and it must already be hard to guess.
-
Which rules does Web Crypto's HKDF enforce?
The secret must be imported as a non-extractable HKDF key (an extractable one throws SyntaxError), and the parameters must name a salt and an info label even when both are empty, as new Uint8Array(0), or a TypeError follows. deriveBits takes its length in bits, 336 for 42 bytes. deriveKey derives an AES-GCM or HMAC key and imports it in one step, so your code never holds its bytes.
-
Why must an HKDF info label name the key's length and frame its parts?
Expand's output for 16 bytes is the first 16 bytes of its output for 32 under the same label, since both start with T(1), so an AES-128 and an AES-256 key derived under one label are related. Joined parts are ambiguous too: msc with 1enc and msc1 with enc make the same text and the same key. Name the protocol, version, purpose, direction and length, each part framed.
-
Why is HKDF not a password hash?
HKDF assumes input that is already hard to guess: extraction concentrates entropy but cannot add any, and slowing attackers down is not its job. In the lesson's run HKDF allowed hundreds of thousands of password guesses a second on one core, while scrypt allowed a few, each needing 128 MiB. Use Argon2id, scrypt or PBKDF2 first; HKDF-Expand with labels can then split the result into keys.
-
Which key-derivation approach fits which input?
A Diffie-Hellman or X25519 secret: HKDF, Extract with a salt, then Expand. A random 32-byte root key: HKDF-Expand, or a counter-mode KDF as in SP 800-108. A password or passphrase: Argon2id, scrypt or PBKDF2. A key from a password hash that must become several keys: the password hash once, then HKDF-Expand with labels. ASVS 5.0 requirement 11.4.4 asks for key stretching for keys from passwords.
Key takeaways
- One shared secret should never serve as several keys: HKDF extracts a uniform pseudorandom key from it and expands that into one key per label, so a leak of one key reveals nothing about the others.
- Extract is an HMAC with the salt as key; Expand chains HMAC blocks with the info label and a one-byte counter, up
to 255 blocks, 8,160 bytes for SHA-256, and code built from
hmac.digestmatched RFC 5869’s test cases. - Web Crypto’s HKDF needs a non-extractable key and a salt and info in its parameters even when empty, and Node’s
hkdfSynccaps the info label at 1,024 bytes. - A 16-byte output is the first half of a 32-byte one under the same label, so labels name the length as well as the protocol, purpose and direction, with every part framed.
- HKDF assumes a secret that is already hard to guess; with a password it allowed hundreds of thousands of guesses a second, so passwords need a slow, salted password hash first.
Terms in this lesson
- HKDF
- The HMAC-based key derivation function of RFC 5869. HKDF-Extract turns input keying material and a salt into a pseudorandom key; HKDF-Expand turns that key and an info label into as many bytes as asked for, up to 255 hash blocks.
- input keying material (IKM)
- The secret that goes into a key derivation, such as a Diffie-Hellman output, a random root key or another key. It must already be hard to guess, since HKDF adds no entropy.
- key separation
- Using a different key for each algorithm, purpose and direction, so that a weakness or a leak in one use cannot reach another. HKDF gives every purpose its own key from one secret.
- key-derivation function (KDF)
- A function that turns secret input into one or more keys: HKDF and the KDFs of NIST SP 800-108 for inputs that are already hard to guess, and slow password hashes such as Argon2id for passwords.
- pseudorandom key (PRK)
- The uniform key of HashLen bytes that HKDF-Extract makes from the input keying material, 32 bytes for HKDF-SHA-256. It feeds HKDF-Expand and is never used directly as a key.
References
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) (Internet Engineering Task Force (IETF))
- RFC 9846: The Transport Layer Security (TLS) Protocol Version 1.3 (the key schedule) (Internet Engineering Task Force (IETF))
- SP 800-56C Rev. 2: Recommendation for Key-Derivation Methods in Key-Establishment Schemes (National Institute of Standards and Technology (NIST))
- SP 800-108 Rev. 1: Recommendation for Key Derivation Using Pseudorandom Functions (National Institute of Standards and Technology (NIST))
- Node.js 24 documentation: Web Crypto API (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Crypto (crypto.hkdf and crypto.hkdfSync) (OpenJS Foundation (Node.js))
- MDN Web Docs: HkdfParams (Mozilla)
- Python 3.14 documentation: hmac, keyed-hashing for message authentication (Python Software Foundation)
- OWASP Password Storage Cheat Sheet (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirement 11.4.4) (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress