Summary
When a capi key is referenced only by container name (key=<name>, no sha1=/key-id=/issuer=), LoadCertificate (via getCertContext) rebuilds an internal URI for GetPublicKey that carries only the container name and drops store-location. getKeyFlags then falls into its default case and never sets NCRYPT_MACHINE_KEY_FLAG, so nCryptOpenKey searches the user keyset instead of the machine keyset and fails with NTE_BAD_KEYSET.
Version
step-kms-plugin.exe version
step-kms-plugin/0.17.0 (windows/amd64)
Release Date: 2026-04-08T18:48:33Z
Current latest release.
Where
kms/capi/capi.go, getCertContext, the containerName != "" branch. It builds the lookup URI with only the container name argument, unlike CreateKey/CreateSigner, which correctly include the store location.
Repro
step kms create capi:store-location=machine;key=mykey
# sign a cert for that key - this step works, CreateSigner sets the machine flag correctly
step kms certificate --import cert.crt capi:store-location=machine;store=My;key=mykey
The write in the last step succeeds - the certificate is correctly imported into Cert:\LocalMachine\My and the private key is correctly bound (independently verified: HasPrivateKey is True on the resulting certificate). But the plugin's own read-back check right after the write, LoadCertificate, fails with:
unable to open key: NCryptOpenKey for container "mykey" returned NTE_BAD_KEYSET
...and the command exits non-zero even though the real work already succeeded. Any caller checking the exit code sees a false failure after a successful operation.
Expected
The URI rebuilt in getCertContext for the container-name-only branch should also carry the store location (and store name, if set), so getKeyFlags can set NCRYPT_MACHINE_KEY_FLAG correctly on that GetPublicKey call, the same way CreateKey/CreateSigner already do.
Workaround
Identify the key with sha1=<thumbprint> instead of key=<name> on the store step - that routes getCertContext through a different branch that doesn't hit this.
Summary
When a capi key is referenced only by container name (
key=<name>, nosha1=/key-id=/issuer=),LoadCertificate(viagetCertContext) rebuilds an internal URI forGetPublicKeythat carries only the container name and dropsstore-location.getKeyFlagsthen falls into its default case and never setsNCRYPT_MACHINE_KEY_FLAG, sonCryptOpenKeysearches the user keyset instead of the machine keyset and fails withNTE_BAD_KEYSET.Version
Current latest release.
Where
kms/capi/capi.go,getCertContext, thecontainerName != ""branch. It builds the lookup URI with only the container name argument, unlikeCreateKey/CreateSigner, which correctly include the store location.Repro
The write in the last step succeeds - the certificate is correctly imported into
Cert:\LocalMachine\Myand the private key is correctly bound (independently verified:HasPrivateKeyisTrueon the resulting certificate). But the plugin's own read-back check right after the write,LoadCertificate, fails with:...and the command exits non-zero even though the real work already succeeded. Any caller checking the exit code sees a false failure after a successful operation.
Expected
The URI rebuilt in
getCertContextfor the container-name-only branch should also carry the store location (and store name, if set), sogetKeyFlagscan setNCRYPT_MACHINE_KEY_FLAGcorrectly on thatGetPublicKeycall, the same wayCreateKey/CreateSigneralready do.Workaround
Identify the key with
sha1=<thumbprint>instead ofkey=<name>on the store step - that routesgetCertContextthrough a different branch that doesn't hit this.