Repository navigation
capi: machine-keyset lookups and ImportKey - #1145
darkfronza wants to merge 8 commits into
Conversation
A key created in the machine keyset can only be opened with NCRYPT_MACHINE_KEY_FLAG, but two paths dropped the keyset: - CreateKey returned a URI naming the provider and container only, so CreateSigner on that URI opened the user keyset and failed with NTE_BAD_KEYSET. - LoadCertificate, LoadCertificateChain and DeleteCertificate, given a "key" container, opened it to derive the subject key identifier with the container alone, so a machine-store lookup by key failed the same way. CreateKey now carries store-location in the returned URI, and the container lookup forwards the machine keyset (key-scope, defaulting to store-location). "user" is not forwarded because providers whose keys are machine-wide refuse it, so user-scope lookups behave as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ImportKey persists an existing ECDSA or RSA private key in a named container of the Microsoft Software Key Storage Provider, in the user or machine keyset the URI selects, where CreateKey would generate a new one. It lets the provider hold a key that also lives somewhere else, such as a key file. The key goes through NCryptImportKey with the container name in the parameter list, and is finalized only after its export policy is cleared, so the provider holds it non-exportable. ECDSA keys are imported as a BCRYPT_ECCPRIVATE_BLOB with the ECDSA private magic: as PKCS#8 the KSP files them under the ECDH algorithm group, where they cannot sign. RSA keys are imported as PKCS#8. An existing container is refused before the import, since the KSP otherwise replaces the key in it, and a key that fails after being persisted is deleted. The response has the same shape as CreateKey's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ImportKey is now exercised with P-521, RSA 3072 and RSA 4096 keys as well. The export policy is a DWORD, so the test decodes it from exactly four bytes: nCryptGetPropertyInt reads a whole int, whose upper half is whatever follows the property in memory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
No blocking issues. Notes:
containerKeyIDfollowsisMachineKeyset(), so it respectskey-scopeas well asstore-location. A certificate in the machine store whose key is explicitlykey-scope=userstill opens in the user keyset.- All new tests are in
capi_windows_test.go, and CI runs tests only on Linux. ImportKey's behavior (ECC private blob with the key name, export policy 0,NTE_EXISTSrefusal) is covered only by manual VM runs. That's reasonable for an EXPERIMENTAL method, but the PR description should say so. - Not from this PR:
CAPIKMS.DeleteKeycallsnCryptDeleteKey(kh)and also defersnCryptFreeObject(kh). The comment this PR adds inImportKeysaysnCryptDeleteKeyfrees the handle, so that's a double free. A caller that replaces an imported key goes throughDeleteKey, so it may be worth fixing here or in a follow-up. - The govulncheck failure is the
golang.org/x/net@v0.59.0advisories, unrelated to this change.
| // container that already exists is not replaced; the import fails with | ||
| // NTE_EXISTS. | ||
| // | ||
| // Only the Microsoft Software Key Storage Provider is supported. |
There was a problem hiding this comment.
Is this (just) a limitation of the current state of code? It is possible to import a key to a TPM, so that may be possible on Windows too. I haven't looked into this yet, though.
There was a problem hiding this comment.
It's a scope limit, not something Windows rules out. The agent only needs the software KSP, and I haven't tried importing into the Platform Crypto Provider. The PCP may take some imports, but its blob formats and its non-exportable handling differ, so it would need its own code and tests. The doc comment now says other providers "are not covered yet" instead of implying they can't be (90d55b0). Happy to open a follow-up issue for PCP import.
| ecs2Magic = 0x32534345 // "ECS2" BCRYPT_ECDSA_PRIVATE_P256_MAGIC | ||
| ecs4Magic = 0x34534345 // "ECS4" BCRYPT_ECDSA_PRIVATE_P384_MAGIC | ||
| ecs6Magic = 0x36534345 // "ECS6" BCRYPT_ECDSA_PRIVATE_P521_MAGIC |
There was a problem hiding this comment.
These can be ordered numerically with the three above
There was a problem hiding this comment.
Done in aa8ae99: RSA1, then ECS1 through ECS6.
| if u.isMachineKeyset() { | ||
| keyName.Set(StoreLocationArg, MachineStoreLocation) |
There was a problem hiding this comment.
This looks like a small behavior change that could break certain keys, depending on their configuration. Is this an intended change for this PR?
There was a problem hiding this comment.
Good catch, it was a behavior change. A certificate in a machine store can be bound to a key in the user's keyset, and with no key-scope the lookup now opened only the machine keyset, so that key wasn't found. In 69d24bb, when key-scope is unset and the machine keyset has no such key (NTE_BAD_KEYSET), the user's keyset is searched as before. An explicit key-scope is still followed alone. TestCAPIKMS_containerKeyset covers a machine-store certificate with a user-keyset key: no key-scope and key-scope=user find it, key-scope=machine doesn't.
| return nil, fmt.Errorf("%v not specified", ContainerNameArg) | ||
| } | ||
|
|
||
| blobType, blob, err := privateKeyBlob(req.PrivateKey) |
There was a problem hiding this comment.
The blob contains the key material. Clear it as soon as it's no longer required.
There was a problem hiding this comment.
Done in 90d55b0: defer clear(blob) right after building it, for both the ECC blob and the RSA PKCS#8 bytes. The caller's own crypto.PrivateKey still holds the key; that's outside what ImportKey can clear.
| switch k := key.(type) { | ||
| case *ecdsa.PrivateKey: |
There was a problem hiding this comment.
A nil key can be passed, and can panic in here
There was a problem hiding this comment.
Fixed in af8c09c. A typed nil, or a key missing its components, now returns "incomplete ECDSA/RSA private key" instead of panicking. The refusal test covers (*ecdsa.PrivateKey)(nil), &ecdsa.PrivateKey{} and (*rsa.PrivateKey)(nil).
| runtime.KeepAlive(name) | ||
| runtime.KeepAlive(&buffer) | ||
| runtime.KeepAlive(blob) | ||
| if r != 0 { |
There was a problem hiding this comment.
Should param be added to these?
There was a problem hiding this comment.
Added runtime.KeepAlive(¶ms) in aa8ae99. params is passed directly as a call argument, so it was already kept alive for the call, but now it matches the other three.
|
|
||
| // Checked first because the import does not: given a container that | ||
| // already exists, the software KSP replaces the key in it. | ||
| if existing, err := nCryptOpenKey(k.providerHandle, u.keyContainerName, 0, keyFlags); err == nil { |
There was a problem hiding this comment.
Besides err == nil, should this check for other types of errors while trying to open the key container, instead of continuing to import?
There was a problem hiding this comment.
Yes, fixed in 90d55b0. Only NTE_BAD_KEYSET, meaning the container isn't there, goes on to import. Any other failure to open it, such as access denied, is returned.
| // already exists, the software KSP replaces the key in it. | ||
| if existing, err := nCryptOpenKey(k.providerHandle, u.keyContainerName, 0, keyFlags); err == nil { | ||
| nCryptFreeObject(existing) | ||
| return nil, fmt.Errorf("unable to import key: container %q returned %v", u.keyContainerName, errNoToStr(NTE_EXISTS)) |
There was a problem hiding this comment.
There's an apiv1.AlreadyExistsError that can be returned from this.
There was a problem hiding this comment.
Done in 90d55b0: an existing container now returns apiv1.AlreadyExistsError, still with NTE_EXISTS in the message, and the test asserts ErrorIs(AlreadyExistsError{}). I also added capi to the list in its doc comment.
| if u.storeLocation != "" { | ||
| importedKeyURI += fmt.Sprintf(";%s=%s", StoreLocationArg, u.storeLocation) |
There was a problem hiding this comment.
parseURI always sets storeLocation, so the if can be removed
There was a problem hiding this comment.
Right, it defaults to user. Removed in 90d55b0, so the URI always carries store-location. The output is unchanged.
| if u.storeLocation != "" { | ||
| createdKeyURI += fmt.Sprintf(";%s=%s", StoreLocationArg, u.storeLocation) |
There was a problem hiding this comment.
parseURI always sets storeLocation, so the if can be removed
… alive Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A typed nil *ecdsa.PrivateKey or *rsa.PrivateKey, or one missing its components, passed the nil check on the request and panicked while building the import blob. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NCryptDeleteKey frees the handle it deletes, and DeleteKey also freed it afterwards. nCryptDeleteKey now consumes the handle whatever the outcome, freeing it itself when the deletion fails, so DeleteKey, the cleanup paths of ImportKey and the certificate cleanup neither free it twice nor leak it. It also decides the outcome by the status NCryptDeleteKey returns rather than the thread's last error, which is not part of its contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Only a container that does not exist is imported. A failure to open it other than NTE_BAD_KEYSET, such as access denied, is returned rather than taken for a missing container. - An existing container is refused with apiv1.AlreadyExistsError. - A key-spec on the URI is refused: an imported key has no legacy key spec, and ignoring it would hand back a key that is not what was asked. - The import blob, which holds the private key, is cleared once the provider has its copy. - CreateKey and ImportKey always add the store-location to the URI they return; parseURI defaults it to user, so it is never empty. The doc comment says other providers are not supported yet, rather than that they cannot be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A certificate in a machine store can be bound to a key in the user's keyset. Looking it up by key container opened the machine keyset whenever the store was the machine's, so such a key was not found. With no key-scope, a key missing from the machine keyset is now looked up in the user's keyset as well; an explicit key-scope is followed alone. The tests cover the lookup with and without key-scope, ImportKey's refusals of an incomplete key and a key-spec, and AlreadyExistsError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90e56ee to
69d24bb
Compare
|
Thanks @dopey, addressed:
Tested on a Windows VM: |
Two changes to
kms/capi: keys in the machine keyset stay addressable through the URIs and lookups that name them, and an existing private key, such as one kept in a key file, can be imported into the software key storage provider.Machine-keyset keys stay addressable
A key in the machine keyset can only be opened with
NCRYPT_MACHINE_KEY_FLAG, but two paths dropped the keyset and failed withNTE_BAD_KEYSET:CreateKeyreturned a URI naming only the provider and container, soCreateSigneron that URI opened the user keyset.LoadCertificate,LoadCertificateChainandDeleteCertificate, given akey=container, opened it without the keyset to derive the subject key identifier.CreateKeynow carriesstore-locationin the URI it returns. A newcontainerKeyIDhelper forwards the machine keyset to the container lookup.useris not forwarded, because providers whose keys are machine-wide refuse it, so user-scope lookups behave as before. A certificate in a machine store can be bound to a user-keyset key, so whenkey-scopeis unset and the machine keyset has no such key, the user's keyset is searched too. An explicitkey-scopeis followed alone.ImportKeyCAPIKMS.ImportKey(*ImportKeyRequest) (*apiv1.CreateKeyResponse, error)stores an existing ECDSA or RSA private key in a named container of the Microsoft Software Key Storage Provider, in the user or machine keyset the URI selects. It lets the provider hold a key that also lives elsewhere, such as a key file.NCryptImportKeywith the container name as a parameter, unfinalized. Its export policy is set to 0 before finalizing, so the provider holds it non-exportable.BCRYPT_ECCPRIVATE_BLOBwith the ECDSA private magic, because as PKCS#8 the KSP files them under the ECDH algorithm group, where they cannot sign. RSA keys are imported as PKCS#8.apiv1.AlreadyExistsError, because the KSP would otherwise silently replace its key. Only a container that doesn't exist (NTE_BAD_KEYSET) is imported; any other failure to open it is returned. A key that fails after being persisted is deleted.key-specon the URI is refused, an incomplete key is refused, and the import blob is cleared once the provider has its copy.DeleteKeyhandle fixnCryptDeleteKeynow consumes the key handle whatever the outcome:NCryptDeleteKeyfrees it on success, and it is freed here on failure.DeleteKeyfreed it a second time, and the cleanup paths leaked it when the deletion failed.Testing
The CNG tests are in
capi_windows_test.goand only run on Windows, while CI runs tests on Linux. They were run on a Windows VM, as administrator for the machine-keyset cases. This is whyImportKeyis marked EXPERIMENTAL.TestCAPIKMS_machineKeysetTestCAPIKMS_containerKeyset: a machine-store certificate bound to a user-keyset key is found with nokey-scopeand withkey-scope=user, but not withkey-scope=machine.TestCAPIKMS_ImportKey: P-256, P-384 and P-521, and RSA 2048, 3072 and 4096, in the user keyset; ECDSA in the machine keyset. Each key must sign, verify against its public key, refuse export, and refuse a second import withAlreadyExistsError.TestCAPIKMS_ImportKey_refusals: no key, no container, Ed25519, a typed-nil or empty ECDSA key, a typed-nil RSA key, akey-spec, and a non-software provider.go test ./kms/capi/ ./kms/tpmkms/passes on the VM.TestKMS_CleanupCredentials_capiinkms/platformfails ("CNG private key should be removed") on this branch, on its base, and on master alike, so it isn't caused by this change.The govulncheck failure is the
golang.org/x/netv0.59.0 advisories, unrelated to this change.🤖 Generated with Claude Code