Articles / Cryptography

  • cryptography
  • go
  • hkdf
  • key-derivation
  • security

Why You Need HKDF When a Hash of the Key Looks Fine

Hashing a shared secret with a label is not key derivation, however much it looks like it. I have seen the same shape of code in more than one review now. There's a shared secret, usually the output of an ECDH exchange or a long-lived master key, and the code needs two or three different keys out of it.

Typically that's one for encryption, one for a MAC, maybe one for a nonce salt. The fix looks like this:

encKey := sha256.Sum256(append(sharedSecret, []byte("enc")...))
macKey := sha256.Sum256(append(sharedSecret, []byte("mac")...))

It compiles, it produces fixed-length outputs that look like keys, and it even sort of achieves domain separation because the two labels differ. It is also not what a key derivation function does.

The gap between "produces 32 bytes that look random" and "is safe to use as a cryptographic key" is exactly where HKDF lives.

Three separate things wrong with hash-and-label

The ad-hoc concatenation-and-hash approach has three separate problems. It's worth pulling them apart, because they get fixed by different parts of a real KDF.

1. Hashes aren't built to clean up non-uniform input

A hash function is not obliged to behave well when its input isn't uniformly distributed. Shared secrets from Diffie-Hellman, in particular, are not uniformly random bit strings: the output is a point on a curve or an integer mod a prime, and it can carry structure that a raw hash doesn't fully erase.

A KDF's first job is to take input keying material of unknown or partial entropy and concentrate it into something close to uniform. Plain sha256.Sum256 was never designed or analysed for that job. It was designed to be a one-way, collision-resistant hash of a message, which is a different property.

2. The labels don't give you real domain separation

Concatenating a label string onto the secret and hashing it is fine until something goes wrong, for example:

  • someone picks two labels that happen to collide under length-extension quirks
  • someone reuses the same label in two different call sites for two different purposes without noticing

HKDF's "info" parameter is a formalised version of the same idea. But it is bound into an HMAC construction, not a bare hash, which closes off the length-extension and prefix-collision issues that plain SHA-2 concatenation is prone to.

3. Nothing tells you how much output is safe

This is the one people miss most often. The double-hash pattern has no accounting for how many independent keys you can safely pull out of one secret before you are just relying on hope.

HKDF has an explicit, provable bound on how much output you can expand from one extraction: up to 255 times the hash output length, per RFC 5869. The double-hash pattern has no such bound because nobody designed it to have one. It works until it doesn't, and you won't necessarily notice when it stops.

HKDF is two steps: extract, then expand

RFC 5869 splits HKDF into two HMAC-based steps that do genuinely different jobs.

Extract fixes the entropy problem

Extract takes your input keying material (IKM) and an optional salt, and produces a fixed-length pseudorandom key (PRK):

PRK = HMAC-Hash(salt, IKM)

This is the step that deals with non-uniform input. Hang on, why is the IKM the "message" and the salt the "key"? It's deliberate: HMAC is a good pseudorandom function even when its message input has some structure, which is exactly the situation you're in with a raw ECDH secret.

The salt doesn't need to be secret, but it should be random and, ideally, different per context. RFC 5869 explicitly allows salt to be public, and even an all-zero salt is defined, though a real random salt is stronger when you have one available.

Expand does the domain separation properly

Expand takes the PRK and an "info" string, and stretches it out into as much output keying material (OKM) as you need:

T(0) = empty string
T(i) = HMAC-Hash(PRK, T(i-1) | info | i)
OKM  = T(1) | T(2) | T(3) | ...

The info string is where domain separation happens properly. Feed it "tls13 c ap traffic" and you get TLS 1.3's client application traffic key.

Feed it "encryption key" instead of "mac key" against the same PRK and you get two outputs that are cryptographically independent, not just differently labelled. That independence is the actual guarantee the ad-hoc hash-and-label pattern was reaching for and not quite getting.

In Go it's in the standard library now

As of Go 1.24, HKDF is in the standard library as crypto/hkdf. There is no reason to reach for a hand-rolled construction, or even pull in golang.org/x/crypto/hkdf, any more.

The common case is the one-shot Key function, which does extract and expand together:

package main

import (
	"crypto/hkdf"
	"crypto/sha256"
	"fmt"
)

func deriveKeys(sharedSecret, salt []byte) (encKey, macKey []byte, err error) {
	encKey, err = hkdf.Key(sha256.New, sharedSecret, salt, "encryption key", 32)
	if err != nil {
		return nil, nil, fmt.Errorf("deriving encryption key: %w", err)
	}

	macKey, err = hkdf.Key(sha256.New, sharedSecret, salt, "mac key", 32)
	if err != nil {
		return nil, nil, fmt.Errorf("deriving mac key: %w", err)
	}

	return encKey, macKey, nil
}

Extracting once when you need several keys

Two calls to Key with the same secret and salt but different info strings each run their own extract-then-expand internally. That's slightly wasteful (the extract step is repeated) compared to extracting once and expanding twice.

If you're deriving several keys from the same secret and care about that, crypto/hkdf also exposes Extract and Expand separately, so you can extract the PRK once and reuse it:

prk, err := hkdf.Extract(sha256.New, sharedSecret, salt)
if err != nil {
	return nil, nil, fmt.Errorf("extracting: %w", err)
}

encKey, err := hkdf.Expand(sha256.New, prk, "encryption key", 32)
if err != nil {
	return nil, nil, err
}

macKey, err := hkdf.Expand(sha256.New, prk, "mac key", 32)
if err != nil {
	return nil, nil, err
}

Either form is a small, direct replacement for the double-hash pattern, and it costs nothing extra to call correctly. There isn't a good excuse left to hand-roll this in Go.

HKDF is not a password hash

It is worth being explicit about the boundary, because this is a common second mistake. HKDF is for stretching already-high-entropy secret material (a DH shared secret, a master key, a session secret) into multiple derived keys.

It is not a password hashing function. A password has low entropy and is guessable by brute force, and HKDF does nothing to slow an attacker down; there's no configurable work factor. That job belongs to something deliberately slow and memory-hard, which is a separate topic with its own parameter choices.

The distinction matters because both HKDF and a password hash produce "a fixed-length secret-looking byte string from some input". It is tempting to treat that surface similarity as interchangeability. They solve different threat models:

  • HKDF assumes the input already has enough entropy and needs concentrating and separating.
  • A password KDF assumes the input doesn't, and needs to be made expensive to attack instead.