crowdsecurity/crowdsec · critical

failed to derive key: %w

Error message

failed to derive key: %w

What it means

deriveKey expands the master secret into a 32-byte AES-256 key with HKDF-SHA256. This error means hkdf.Read failed while extracting the key — practically only when the input secret is empty/nil or the hash stream errors, which should never occur with a validated secret. Both sealCookieV0 and openCookieV0Bytes depend on it.

Source

Thrown at pkg/appsec/challenge/crypto.go:93

// cookiePlaintextFixedHeaderLen is the size of the fixed-layout portion of
// the plaintext header that precedes the protobuf envelope:
//
//	not_after_be8 (8) + flags_byte (1) + reason_len_be (2) = 11
//
// followed by reason_bytes (variable, 0..MaxAllowlistReasonLen).
const cookiePlaintextFixedHeaderLen = 8 + 1 + 2

// cookieFlagAllowlisted marks a cookie minted by GrantChallengeCookie
// (operator allowlist bypass) rather than by a real challenge submission.
const cookieFlagAllowlisted byte = 0x01

func deriveKey(secret []byte) ([]byte, error) {
	h := hkdf.New(sha256.New, secret, nil, []byte(hkdfInfo))
	key := make([]byte, 32) // AES-256

	if _, err := h.Read(key); err != nil {
		return nil, fmt.Errorf("failed to derive key: %w", err)
	}

	return key, nil
}

// sealCookieV0 produces a v0 cookie sealed under the long-lived master
// cookie key. notAfter is the unix-seconds expiration; flags carries the
// allowlist bit set by GrantChallengeCookie (0 for normal cookies);
// reason is the operator-supplied allowlist reason (empty for normal
// cookies). All three are prepended to the marshaled proto BEFORE
// encryption so they are both confidential (not observable from the wire)
// and authenticated (any tamper attempt invalidates the GCM tag).
//
// Returns ErrAllowlistReasonSize if reason exceeds MaxAllowlistReasonLen.
func sealCookieV0(envelope *pb.ChallengeCookie, masterCookieKey []byte, notAfter int64, flags byte, reason string, aad []byte, maxCookieLen int) (string, error) {
	if maxCookieLen <= 0 {
		maxCookieLen = MaxCookieLen
	}

View on GitHub (pinned to 909b515798)

Solutions

  1. Ensure the runtime is built via NewChallengeRuntime with a valid master secret (>= 32 bytes) or its random fallback.
  2. Check that BuildOptions was called and its error handled before constructing the runtime.
  3. Log the wrapped hkdf error; an empty HKDF secret input is the usual root cause.

Example fix

// before
opts, _ := challenge.BuildOptions(cfg, logger)
rt, err := challenge.NewChallengeRuntime(opts...)
// after
opts, err := challenge.BuildOptions(cfg, logger)
if err != nil {
    return fmt.Errorf("challenge config: %w", err)
}
rt, err := challenge.NewChallengeRuntime(opts...)
Defensive patterns

Strategy: validation

Validate before calling

if len(masterSecret) == 0 {
    return errors.New("master secret is empty; configure master_secret or use NewChallengeRuntime")
}

Try / catch

if err := errors.Is(err, hkdfErrFamily); err != nil {
    // construct runtime via the standard constructor instead of manual keys
}

Prevention

When it happens

Trigger: Calling sealCookieV0 or openCookieV0Bytes (via openCookie/ValidCookie) with an empty or nil master cookie key, e.g. a ChallengeRuntime whose keyring was never initialized with a valid master secret.

Common situations: A mis-built runtime where WithMasterSecret was never applied; a keyring initialized from an unvalidated empty secret; programmatic use of the challenge package without going through BuildOptions.

Related errors


AI-assisted analysis of crowdsecurity/crowdsec@909b515798 (2026-09-06). Data as JSON: /api/errors/dfb58194750eee3a. Report an issue: GitHub.