Deep dive
How it works
In: IKM (secret), optional salt, info label, length L · Out: L bytes of output keying material (OKM) · State: none (PRK may be cached) · Cost: 1 + ceil(L/32) HMAC-SHA-256 calls.
- Extract:
PRK = HMAC-Hash(salt, IKM); the salt is the HMAC key. No salt means HashLen zero bytes (RFC 5869 section 2.2). This concentrates the entropy of a non-uniform secret, such as an elliptic-curve point, into a uniform 32-byte key. - Choose
info: protocol, version, role or direction, purpose, algorithm, for handshakes the transcript hash. It must not depend on the IKM (section 3.2). - Expand:
N = ceil(L/HashLen); rejectL > 255*HashLen(the counter is one byte). T(0)is empty;T(i) = HMAC(PRK, T(i-1) || info || byte(i)).OKM= first L bytes ofT(1) || ... || T(N).- Slice OKM into keys, or expand again with a different
infoper key. Never reuse oneinfofor two purposes.
Skipping extract is allowed only when the IKM is already a uniform key of at least HashLen bytes (from a CSPRNG); a Diffie-Hellman value must go through extract (section 3.3). Krawczyk's CRYPTO 2010 paper gives the formal analysis. TLS 1.3 wraps expand in HKDF-Expand-Label: info is a struct of 2-byte length, "tls13 " + label and a context, usually the transcript hash (RFC 8446 section 7.1). Noise's HKDF() is HKDF with the chaining key as salt and empty info, giving two or three 32-byte outputs (Noise specification section 4.3).
Code
Rust, std only; SHA-256 constants derived from the primes. Asserts RFC 5869 Appendix A cases 1-3 (PRK and OKM); cases 4-7 use SHA-1.
use std::sync::OnceLock;
// SHA-256 (FIPS 180-4); constants derived from the first 64 primes
fn consts() -> &'static ([u32; 8], [u32; 64]) {
static C: OnceLock<([u32; 8], [u32; 64])> = OnceLock::new();
C.get_or_init(|| {
let f = |x: f64| ((x - x.floor()) * 4294967296.0) as u32;
let p: Vec<u64> = (2u64..).filter(|n| (2..*n).take_while(|d| d * d <= *n).all(|d| n % d != 0)).take(64).collect();
let (mut h, mut k) = ([0u32; 8], [0u32; 64]);
for i in 0..8 { h[i] = f((p[i] as f64).sqrt()); }
for i in 0..64 { k[i] = f((p[i] as f64).cbrt()); }
(h, k)
})
}
pub fn sha256(msg: &[u8]) -> [u8; 32] {
let (h0, k) = consts();
let mut h = *h0;
let mut m = msg.to_vec();
m.push(0x80);
while m.len() % 64 != 56 { m.push(0); }
m.extend((msg.len() as u64 * 8).to_be_bytes());
for c in m.chunks(64) {
let mut w = [0u32; 64];
for i in 0..16 { w[i] = u32::from_be_bytes(c[4 * i..4 * i + 4].try_into().unwrap()); }
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
}
let mut v = h;
for i in 0..64 {
let t1 = v[7].wrapping_add(v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25))
.wrapping_add((v[4] & v[5]) ^ (!v[4] & v[6])).wrapping_add(k[i]).wrapping_add(w[i]);
let t2 = (v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22))
.wrapping_add((v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]));
v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]];
}
for i in 0..8 { h[i] = h[i].wrapping_add(v[i]); }
}
let mut o = [0u8; 32];
for i in 0..8 { o[4 * i..4 * i + 4].copy_from_slice(&h[i].to_be_bytes()); }
o
}
pub fn hmac(key: &[u8], msg: &[u8]) -> [u8; 32] {
let mut k0 = [0u8; 64];
if key.len() > 64 { k0[..32].copy_from_slice(&sha256(key)); } else { k0[..key.len()].copy_from_slice(key); }
let mut inner: Vec<u8> = k0.iter().map(|b| b ^ 0x36).collect();
inner.extend(msg);
let mut outer: Vec<u8> = k0.iter().map(|b| b ^ 0x5c).collect();
outer.extend(sha256(&inner));
sha256(&outer)
}
// HKDF (RFC 5869) with HashLen = 32
pub fn hkdf_extract(salt: &[u8], ikm: &[u8]) -> [u8; 32] {
// an absent salt means HashLen zero bytes; HMAC zero-pads keys, so empty is equivalent
hmac(if salt.is_empty() { &[0u8; 32] } else { salt }, ikm)
}
pub fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Result<Vec<u8>, &'static str> {
if len > 255 * 32 { return Err("L > 255*HashLen"); }
let (mut okm, mut t) = (Vec::with_capacity(len + 32), Vec::new());
for i in 1..=len.div_ceil(32) {
let mut msg = t.clone(); // T(i-1) | info | i
msg.extend(info);
msg.push(i as u8);
t = hmac(prk, &msg).to_vec();
okm.extend(&t);
}
okm.truncate(len);
Ok(okm)
}
fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() }
fn main() {
let r = |a: u8, b: u8| (a..=b).collect::<Vec<u8>>();
// RFC 5869 Appendix A.1
let (ikm, salt, info) = (vec![0x0b; 22], r(0, 12), r(0xf0, 0xf9));
let prk = hkdf_extract(&salt, &ikm);
assert_eq!(hex(&prk), "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5");
assert_eq!(hex(&hkdf_expand(&prk, &info, 42).unwrap()), "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865");
// A.2: 80-byte inputs, 82-byte output (three blocks)
let prk = hkdf_extract(&r(0x60, 0xaf), &r(0, 0x4f));
assert_eq!(hex(&prk), "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244");
assert_eq!(hex(&hkdf_expand(&prk, &r(0xb0, 0xff), 82).unwrap()), "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87");
// A.3: empty salt and info
let prk = hkdf_extract(&[], &ikm);
assert_eq!(hex(&prk), "19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04");
assert_eq!(hex(&hkdf_expand(&prk, &[], 42).unwrap()), "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8");
// limits and separation
assert!(hkdf_expand(&prk, &[], 255 * 32).is_ok());
assert!(hkdf_expand(&prk, &[], 255 * 32 + 1).is_err());
let a = hkdf_expand(&prk, b"app v1 client->server key", 32).unwrap();
let b = hkdf_expand(&prk, b"app v1 server->client key", 32).unwrap();
assert_ne!(a, b);
println!("RFC 5869 A.1-A.3 ok");
}
Complexity and constants
Extract is one HMAC; expand is ceil(L/32) HMACs in strict sequence (T(i) needs T(i-1)). One HMAC-SHA-256 over a short message is 4 compression calls, or 2 with cached key-pad states. Measured on an Apple M4 Pro with my unoptimised std-only code (heap allocations, no cached pads, one release run of 200,000 iterations), order of: extract plus 32-byte expand 1.9 µs; one extra 32-byte expand 1.0 µs; 1,024-byte expand 31 µs (linear in blocks); SHA-256 of 64 B 0.39 µs.
Efficiency
- Cost: one HMAC-SHA-256 per 32 output bytes, 2 to 4 compressions; PRK is 32 B of state. Compute-bound, no memory traffic (measured above).
- Levers:
- Cache the PRK — skips the extract HMAC (measured 1.9 → 1.0 µs per 32-byte key) — one secret, many keys.
- Cache the pad states of the PRK — halves compressions per block (RFC 2104 structure) — bulk derivation.
- One long expand, then slice — same hashing, fewer calls and allocations — fixed key bundles.
- SHA-256 hardware instructions (SHA-NI, ARMv8 SHA2) — constant-factor gain, not measured here — busy servers.
- Naive → standard → state of the art: hand-rolled hash(secret, label, counter) · 1 hash, no extraction proof; HKDF-SHA-256 with cached PRK · 1 HMAC per 32 B; cached pad states plus hardware SHA · 2 compressions per 32 B.
Implementation notes
- Zeroize PRK and OKM after use; derive each key just before use.
- Length-prefix every variable-length field in
info:("ab","c")and("a","bc")must differ (TLS 1.3 does inHkdfLabel). Start with protocol name and version, then role, purpose; add L if different lengths of one key must be unrelated. - Salt: a random or negotiated public value (nonce, handshake hash) beats none (RFC 5869 section 3.1); never attacker-chosen.
- Rotation by epoch:
info = label || u64 epoch. Whoever holds the master recomputes every epoch, so this bounds per-key exposure, not past secrecy. For forward secrecy chain and delete: TLS 1.3 key update derives the next secret withHKDF-Expand-Label(secret_N, "traffic upd", "", Hash.length)and deletes the old one (RFC 8446 section 7.2). See key rotation. - NIST SP 800-56C Rev. 2 (key-derivation methods in key establishment) lists extraction-then-expansion; SP 800-108 Rev. 1 covers PRF-based derivation (HMAC, CMAC, KMAC). Check which your policy names.
Pitfalls
- HKDF on a password: fast, so guessable. Use Argon2 first.
- Skipping extract on a Diffie-Hellman result (not uniform).
- Same
infofor two keys: identical output. Sameinfo, different L: outputs are prefixes of each other. - L above 255*HashLen must error, not wrap the one-byte counter.
- Salt or
infodepending on the IKM. - Using PRK itself as output key when L is at most HashLen: not recommended (RFC 5869 section 3.3),
infois skipped. - Expecting forward secrecy from
infoalone, or authentication from HKDF.
Variants and alternatives
In production
- TLS 1.3: the key schedule is HKDF-Extract and HKDF-Expand-Label (RFC 8446 section 7.1).
- Noise:
HKDF()mixes each DH result into the chaining key. - WireGuard: handshake chains HMAC-based
Kdf1..Kdf3, built like HKDF. - Signal:
KDF_RKis recommended to be HKDF, root key as salt, DH output as IKM (Double Ratchet). - ECDH deployments: the raw shared point goes to a KDF with transcript or both public keys as
info. - Storage and device fleets: per-object keys from a master, often HSM-held.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| hkdf (RustCrypto) | Rust | RustCrypto/KDFs | cargo add hkdf | — | — | — | — | generic over any digest; Hkdf::new + expand |
ring hkdf | Rust | briansmith/ring | cargo add ring | — | — | — | — | ring::hkdf; Salt::new, extract, expand |
| libsodium | C | jedisct1/libsodium | brew install libsodium | — | — | — | — | crypto_kdf_hkdf_sha256_extract / _expand |
| OpenSSL | C | openssl/openssl | brew install openssl | — | — | — | — | EVP_KDF "HKDF" with extract-only, expand-only or both modes |
| x/crypto hkdf | Go | golang/crypto | go get golang.org/x/crypto/hkdf | — | — | — | — | hkdf.New, Extract, Expand |
| cryptography | Python | pyca/cryptography | pip install cryptography | — | — | — | — | HKDF, HKDFExpand classes |
Sources
- Krawczyk, Eronen, HMAC-based Extract-and-Expand Key Derivation Function (HKDF), RFC 5869, 2010. rfc
- Krawczyk, Cryptographic Extraction and Key Derivation: The HKDF Scheme, CRYPTO 2010, pp. 631-648. paper · eprint
- Rescorla, The Transport Layer Security (TLS) Protocol Version 1.3, RFC 8446, 2018, section 7.1. rfc
- Perrin, The Noise Protocol Framework, section 4.3. spec
- Perrin, Marlinspike, The Double Ratchet Algorithm. spec
- Donenfeld, WireGuard: Next Generation Kernel Network Tunnel. pdf
- NIST SP 800-56C Rev. 2, Recommendation for Key-Derivation Methods in Key-Establishment Schemes. nist
- NIST SP 800-108 Rev. 1, Recommendation for Key Derivation Using Pseudorandom Functions. nist