tpmkms: only claim a Windows certificate's key when the key is ours - #1099
tpmkms: only claim a Windows certificate's key when the key is ours#1099darkfronza wants to merge 1 commit into
Conversation
storeCertificateChainToWindowsCertificateStore associated every certificate it stored with "app-<name>" under the Microsoft Platform Crypto Provider, unconditionally. A caller can name a key that is not a TPM key at all, and then that association names a container which does not exist. On Windows this is what a smallstep agent endpoint with no key protection looks like: its key is created through CAPI in the software KSP under the bare endpoint name, while its certificate is stored through this KMS. The stored certificate reports HasPrivateKey=True and appears in the browser's client certificate picker, but resolves no key, so it cannot complete a handshake -- and a later lookup that goes through the key finds nothing, so replace-on-store never replaces and certificates accumulate instead. Bind explicitly only for a key this TPM holds. The non-Windows branch of StoreCertificateChain already resolves the key before storing; this makes the Windows branch agree. Only a definitive ErrNotFound counts as "not ours": a lookup that fails because the TPM is busy or unreadable leaves the answer unknown and keeps the association, so a transient fault cannot silently drop the binding for a genuine TPM key and reintroduce the machine-scoped discovery failure the explicit association exists to avoid. When the key is not ours, discovery is re-enabled for that store. The platform wrapper injects skip-find-certificate-key=true into every Windows request, to avoid a smart-card prompt while hunting for a TPM key discovery cannot find anyway; neither half of that reasoning applies to a key another provider holds. Without this the certificate is stored with no association at all rather than a wrong one -- still unusable, just invisible instead of misleading. CAPI restricts the search to the keyset the store location implies, so this does not widen it. Verified on Windows against a software-KSP key: the stored certificate's recorded container goes from "app-<name>" (a container that does not exist) to "<name>" (the one holding the key). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Validated on the full robot matrixCut a dev release with this commit as the only change, so a failure anywhere would be attributable to it: All six release builds passed, and of the robot suites:
The two failures are pre-existing rather than caused by this change: the same Windows and Linux Browser suites fail on That is not a perfect control (dev6 sits on a different branch of agent work), but the Windows-side suites most likely to be sensitive to this change — Resilience, Key Scope, Key Scope Remediation — all pass. Also verified directlyStoring twice through the agent's own KMS wrapper for an endpoint with software key protection, on a real Windows host: Both certificates land on the software-KSP container that actually holds the key, where before this change they were stamped Note what this change does not fix, deliberately: replace-on-store still does not fire for these endpoints, because |
Draft — opened for review of the approach and for CI, while the downstream agent change that depends on it is validated.
The problem
storeCertificateChainToWindowsCertificateStoreassociates every certificate it stores withapp-<name>under the Microsoft Platform Crypto Provider, unconditionally:A caller can name a key that is not a TPM key. On Windows that is exactly what a smallstep agent endpoint with
KeyProtection_NONElooks like: its key is created through CAPI in the software KSP under the bare endpoint name, while its certificate is stored through this KMS. The association then names a container that does not exist.The resulting certificate:
HasPrivateKey=Trueand appears in the browser's client-certificate pickerHistory
Three stages, which is why this went unnoticed:
ead241c/3131fc6)skip-find-certificate-key=true→ noneHasPrivateKey=False, absent from the pickerd1a37b0)app-<name>+ PCP → wrongHasPrivateKey=True, in the picker, fails at handshaked1a37b0fixed machine-scoped TPM association and, as collateral, made an already-broken certificate look healthy. Shipped since v0.83.0.The change
Three-way instead of unconditional:
d1a37b0fixed stays fixed.skip-find-certificate-key=trueexists to avoid a smart-card prompt while hunting for a TPM key discovery cannot find anyway; neither half of that reasoning applies to a key another provider holds. Without re-enabling it the certificate gets no association at all — still unusable, just invisible rather than misleading. CAPI restricts the search to the keyset the store location implies, so this does not widen it.managesKeytreats only a definitiveErrNotFoundas "not ours". A lookup that fails because the TPM is busy or unreadable leaves the answer unknown and keeps the association, so a transient fault cannot silently drop the binding for a genuine TPM key.Verification
Simulator test covering both branches of
managesKey.go test ./...and thetpmsimulator-tagged suites pass.Verified on a real Windows host by reproducing the agent's path — software-KSP key created through CAPI, certificate stored through this KMS — and reading back the recorded container:
Review notes
The riskiest part is re-enabling discovery, since the skip was added deliberately. It applies only on the path where the named key is provably not a TPM key, which is a path that previously produced an unusable certificate either way.
🤖 Generated with Claude Code