skip to content

Your hkdf.Key wrapper let an empty info string through and two features now derive the same key — how do you catch this?

level: seniorimportance: should knowfreq 30%

answer

  1. the call returns no error at all
  2. only the key length is validated
  3. empty label, identical bytes
  4. gather the literal at every call site

basics

~20 s

crypto/hkdf validates only the requested key length, so an empty info string derives silently and every purpose that omits it shares one key. Audit the info string at every call site, and make the wrapper reject empty and unregistered labels.

solid answer

~50 s

`hkdf.Key` returns an error only when `keyLength` exceeds the hash's `Size()` times 255. A `nil` salt, an empty `info` string and a two-byte secret are all accepted, so a wrapper that takes `purpose string` and passes it straight through will happily return identical bytes for two callers that both passed `""` — or for a caller who copy-pasted another feature's label. The symptom is not a crash: it is one feature successfully reading data that belongs to another, which usually surfaces as a puzzling cross-feature or cross-environment leak rather than as an error. I find it with a repo-wide sweep of the literal passed at every call site, sorted and diffed for blanks and repeats. Then I close it in the API — no free-form string parameter, only named accessors or a checked registry — so it cannot come back.

code

go · 7 lines
go
func (d *Deriver) Key(purpose string, n int) ([]byte, error) {
	return hkdf.Key(sha256.New, d.master, d.salt, purpose, n)
}

// Elsewhere, in two unrelated packages:
cookieKey, err := d.Key("", 32) // config default was never set
urlKey, err := d.Key("", 32)    // same 32 bytes; err is nil both times

go deeper

for a junior

Know that hkdf.Key accepts an empty info string without complaint, and that two calls with identical arguments always return identical bytes. That combination is the whole defect.

for a middle

Explain precisely which arguments crypto/hkdf validates and which it does not, and why identical output for identical inputs is a determinism property of HKDF rather than a bug in the package.

for a senior

Walk the diagnosis from symptom to call site: no error, no crash, just one feature reading another's artefacts. Then show the audit of info literals and the guard you add so it cannot recur.

for a principal

Weigh what fixing it costs once keys are protecting production data — relabelling means re-deriving and re-encrypting — and conclude that the guard has to exist before the library's first release.

## What the package does and does not check The first thing to internalise is how narrow `crypto/hkdf`'s validation is. Reading the package, the only condition either `Key` or `Expand` reports outside FIPS 140-only mode is: ``` keyLength > h().Size() * 255 -> "hkdf: requested key length too large" ``` That is it. Everything else is accepted: - a `nil` salt (a zero-filled salt of hash length is substituted), - an empty `info` string (mixed in as zero bytes of context), - a very short or low-entropy `secret`, - an `info` string identical to one already used elsewhere in the same binary. So a wrapper like this compiles, passes review, runs, and is wrong: ```go func (d *Deriver) Key(purpose string, n int) ([]byte, error) { return hkdf.Key(sha256.New, d.master, d.salt, purpose, n) } ``` Two callers that pass `""` — a struct field that was never set, a config value that defaulted to empty, a constant someone forgot to fill in — get byte-identical keys, and both calls return a nil error. ## Why it is hard to see The defect has no signature that ordinary tooling recognises. - There is **no race**, so `-race` is silent. This is deterministic single-goroutine behaviour. - There is **no vet check**. `go vet` has no analyser for the semantics of a key-derivation label; an empty string is a perfectly ordinary string. - **Per-feature unit tests pass.** The cookie feature derives a key, uses it, and round-trips fine. The URL-signing feature does the same. Neither test ever compares its key to the other's, which is where the defect lives. - **Nothing fails in production either**, at first. The two features work. What eventually happens is that one of them accepts an artefact produced by the other — a signed URL that verifies against the cookie key, a record readable across a boundary that should have been cryptographically sealed. That gets reported as a strange authorisation bug or a cross-environment data leak, and the KDF is not the first place anyone looks. ## The diagnostic that actually works Because the input is a string literal at a call site, the cheapest reliable check is an audit of those literals across the repository. Collect every value passed as the `info` argument — including the ones that come from constants and from configuration defaults — sort them, and look for two things: blanks, and duplicates. In a repository with a handful of services and one shared keys library that is a short list, and the diff is unambiguous. Doing that once catches today's collisions. Doing it in CI keeps it caught, but it is fragile: it only sees literals, not a value computed at runtime. Which points at the real fix. ## Closing it in the API The durable fix is to remove the free-form parameter. Two shapes work: **A registry.** The deriver holds the set of allocated purposes and rejects anything not in it: ```go func (d *Deriver) Key(purpose string, n int) ([]byte, error) { if purpose == "" { return nil, errors.New("kdf: empty purpose") } if !d.registered[purpose] { return nil, fmt.Errorf("kdf: unregistered purpose %q", purpose) } return hkdf.Key(sha256.New, d.master, d.salt, purpose, n) } ``` **Named accessors.** No string parameter at all — one exported method per purpose, so adding a purpose is a change to the library that a reviewer sees. This is stronger, because a caller cannot express the mistake. Either way, add the test that the audit was standing in for: derive every registered purpose and assert that no two outputs are equal. That test is trivial, runs in microseconds, and is the one check that fails loudly when a label is duplicated. ## Two environments deriving the same key A related report is worth separating. If staging and production derive the same key even though their info strings genuinely differ, the info string is not the culprit — the master secret or the salt is shared. HKDF is deterministic in all four inputs, so identical output means identical input. Either the same secret was provisioned to both environments, or both passed a `nil` salt against a copied secret. Including the environment name in the info string makes the two unrelated by construction, which is good for blast radius and bad for anything that has to move data between environments. It is a decision to make once, library-wide, not per call site. ## The expensive part is the fix, not the detection Once keys derived under the wrong label are protecting real artefacts, correcting the label is not a code change — it is a data migration. The new label yields a different key, so everything sealed under the old one has to be re-derived and rewritten: add the correct purposes, derive both old and new keys, accept either on read, write only the new, and retire the old label when nothing reads it any more. That asymmetry — cheap to prevent, expensive to correct — is the argument for putting the guard in before the first release.

  • Would the race detector, `go vet` or the existing unit tests have caught this?
    None of them. There is no data race, `go vet` has no analyser for the meaning of a string argument, and each feature's own test passes because the key it derives works fine in isolation. What catches it is a test that derives every registered purpose and asserts all outputs are distinct, plus the wrapper rejecting empty and unregistered labels.
  • Staging and production derive the same key even though their info strings differ. What else is shared?
    The master secret, the salt, or both. HKDF is deterministic in all of its inputs, so identical output means identical input. If the labels genuinely differ, the two environments were provisioned with the same secret — possibly both passing a nil salt. Putting the environment name in the info string makes them unrelated by construction, but that is a library-wide decision, not a per-call one.
  • How do you fix it once keys derived from the empty label already protect real data?
    As a migration, not a code fix. Changing the label changes the key, so you add the correct purposes, derive both old and new keys, accept either on read, write only under the new one, and retire the old label when nothing reads it. That is why the guard belongs in the API before the first release rather than after.

saying these in an interview costs you the question

  • Assumes hkdf.Key errors on an empty info string
  • Thinks a per-feature unit test would have caught it
  • Renames the labels without re-deriving keys already in use
  • Blames the salt when the info strings were what collided
  • Logs a warning on an empty purpose instead of rejecting it