Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Cryptography

Published:

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

HKDF Explained: Why Hashing Your Key Material Twice Isn't the Same as Deriving It Properly

I have seen the same shape of code in more than one review now. Somewhere there is a shared secret, usually the output of an ECDH exchange or a long-lived master key, and somewhere else the code needs two or three different keys out of it: 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, and the gap between "produces 32 bytes that look random" and "is safe to use as a cryptographic key" is exactly where HKDF lives.

What is actually wrong with hashing twice

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

First, 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.

Second, there is no real domain separation guarantee. Concatenating a label string onto the secret and hashing it is exactly the kind of construction that's fine until someone picks two labels that happen to collide under length-extension quirks, or until 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.

Third, and this is the one people miss most often, there is 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 (up to 255 times the hash output length, per RFC 5869) on how much output you can expand from one extraction. 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.

Extract, then expand

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

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. Using HMAC with the IKM as the "message" and the salt as the "key" is a deliberate choice: 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 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.

Using it in Go

As of Go 1.24, HKDF is in the standard library as crypto/hkdf, so 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
}

Two calls to Key with the same secret and salt but different info strings each run their own extract-then-expand internally, which is 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.

What HKDF is not for

It is worth being explicit about the boundary, because it 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", and 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.