Articles / Security

  • hsm
  • pkcs11
  • key-wrapping
  • key-management
  • cryptography
  • go

Your HSM Key Is Non-Extractable Until Someone Wraps It

Marking a private key non-extractable in an HSM does not mean it can never leave. It means nobody can read it out as plaintext. If the same device will wrap that key under another key, and an attacker can learn the value of that other key, they have your plaintext key. The HSM did exactly what it was told.

This is an old class of problem with PKCS#11, written up in the academic literature on cryptographic API attacks years ago. It still turns up because each individual setting looks sensible on its own.

The attributes involved

PKCS#11 describes what a key may do through attributes on the key object. Four matter here:

  • CKA_EXTRACTABLE: whether the key may be wrapped out of the token at all.
  • CKA_SENSITIVE: whether the plaintext value may be read with C_GetAttributeValue.
  • CKA_WRAP: whether this key may wrap other keys.
  • CKA_UNWRAP: whether this key may unwrap them.

Your signing key sensibly has CKA_SENSITIVE=true and CKA_EXTRACTABLE=false. Then someone wants backups, or a second HSM, so CKA_EXTRACTABLE becomes true and a key-encryption key (KEK) is created to protect the wrapped copy. That is where it goes wrong.

The attack is a handful of calls

Assume the attacker has a logged-in session, for example through a compromised application server. They do not need the HSM's admin credentials. The sequence looks like this:

  1. Find a wrapping key with CKA_WRAP=true.
  2. If its CKA_SENSITIVE is false, read its value with C_GetAttributeValue.
  3. Call C_WrapKey to wrap your "protected" signing key under it.
  4. Decrypt the wrapped blob offline, using the KEK value from step 2.

Step 2 is the leak, but there is a way round it even when the KEK is locked down. If the session is allowed to create objects, the attacker imports an AES key of their own choosing with CKA_WRAP=true and wraps your key under that. No KEK of yours needs to be readable at all.

So the real condition is that any key that can wrap must be one the attacker cannot choose or read. Marking the target non-extractable only protects it from direct reads.

Quick detour: why the HSM allows this

Hang on, why does the device cooperate? Because from its point of view nothing is wrong. Wrapping is a legitimate operation on an extractable key, under a key flagged for wrapping, requested by an authenticated session. PKCS#11 attributes are permission bits, and the HSM enforces each in isolation.

The weakness lives in how the permissions combine. A key that can both wrap and decrypt is worse again: wrap the target under it, then ask the HSM to decrypt the wrapped blob and hand back the plaintext. Two harmless-looking flags, one total break.

What to set instead

Work through these in order of how much they buy you:

  • Make every wrapping key CKA_SENSITIVE=true and CKA_EXTRACTABLE=false, so its value can never be read or moved.
  • Never give one key both CKA_WRAP and CKA_DECRYPT, nor CKA_UNWRAP and CKA_ENCRYPT.
  • Set CKA_WRAP_WITH_TRUSTED=true on keys you must export, so they can only be wrapped by keys carrying CKA_TRUSTED=true. Only the security officer can set the latter, so an attacker's imported key does not qualify.
  • Use CKA_WRAP_TEMPLATE and CKA_UNWRAP_TEMPLATE where your HSM supports them, so unwrapped keys come back with the attributes you chose.
  • Deny object creation to application sessions, so the import trick above is not available.

Support for the trusted-key attributes varies by vendor, so check your device's documentation rather than assuming. Some HSMs also enforce stricter policy than the PKCS#11 baseline, which is worth knowing before you design around the standard alone.

Creating a KEK that cannot be read

Here is a sketch using github.com/miekg/pkcs11, a third-party binding over cgo. It is simplified: the session is assumed to be open and logged in, and the context handling is left out.

func newKEK(ctx *pkcs11.Ctx, sh pkcs11.SessionHandle) (pkcs11.ObjectHandle, error) {
	tmpl := []*pkcs11.Attribute{
		pkcs11.NewAttribute(pkcs11.CKA_LABEL, "backup-kek"),
		pkcs11.NewAttribute(pkcs11.CKA_TOKEN, true),
		pkcs11.NewAttribute(pkcs11.CKA_VALUE_LEN, 32),
		pkcs11.NewAttribute(pkcs11.CKA_WRAP, true),
		pkcs11.NewAttribute(pkcs11.CKA_UNWRAP, true),
		pkcs11.NewAttribute(pkcs11.CKA_ENCRYPT, false),
		pkcs11.NewAttribute(pkcs11.CKA_DECRYPT, false),
		pkcs11.NewAttribute(pkcs11.CKA_SENSITIVE, true),
		pkcs11.NewAttribute(pkcs11.CKA_EXTRACTABLE, false),
	}
	mech := []*pkcs11.Mechanism{pkcs11.NewMechanism(pkcs11.CKM_AES_KEY_GEN, nil)}
	h, err := ctx.GenerateKey(sh, mech, tmpl)
	if err != nil {
		return 0, fmt.Errorf("generate KEK: %w", err)
	}
	return h, nil
}

A non-extractable KEK is a trade-off: the wrapped backup can only be unwrapped by an HSM that holds the same KEK. Clone the KEK between devices using the vendor's own mechanism (often a multi-person ceremony), not by making it readable.

Check what you actually have

Do not trust your provisioning script's intent. Read the attributes back from the device and fail the deployment if they differ. With OpenSC's pkcs11-tool, listing objects shows the usage flags for each key:

pkcs11-tool --module /path/to/module.so --login --list-objects --type secrkey

Look for any secret key where wrap appears next to extractable, or never extractable is missing. Then repeat the check for every key that is wrap-capable, including ones created by old scripts and forgotten.

The useful question is not whether the key is non-extractable. It is: what is the full set of keys that could be used to carry it out, and who controls each of them?