Skip to content

tpmkms: only claim a Windows certificate's key when the key is ours - #1099

Open
darkfronza wants to merge 1 commit into
masterfrom
diego/tpmkms-non-tpm-certificate-binding
Open

tpmkms: only claim a Windows certificate's key when the key is ours#1099
darkfronza wants to merge 1 commit into
masterfrom
diego/tpmkms-non-tpm-certificate-binding

Conversation

@darkfronza

Copy link
Copy Markdown
Contributor

Draft — opened for review of the approach and for CI, while the downstream agent change that depends on it is validated.

The problem

storeCertificateChainToWindowsCertificateStore associates every certificate it stores with app-<name> under the Microsoft Platform Crypto Provider, unconditionally:

v.Set("key", tpm.ApplicationKeyName(o.name))
v.Set("provider", microsoftPCP)

A caller can name a key that is not a TPM key. On Windows that is exactly what a smallstep agent endpoint with KeyProtection_NONE 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 association then names a container that does not exist.

The resulting certificate:

  • reports HasPrivateKey=True and appears in the browser's client-certificate picker
  • resolves no key, so it cannot complete a handshake
  • is unreachable by any lookup that goes through the key — so replace-on-store never replaces, and certificates accumulate

History

Three stages, which is why this went unnoticed:

when association symptom
before 2026-02-25 discovery → correct worked
2026-02-25 (ead241c/3131fc6) platform injects skip-find-certificate-key=truenone HasPrivateKey=False, absent from the picker
2026-06-11 (d1a37b0) explicit app-<name> + PCP → wrong HasPrivateKey=True, in the picker, fails at handshake

d1a37b0 fixed 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:

  • key this TPM holds → explicit binding, unchanged. What d1a37b0 fixed stays fixed.
  • key it does not hold → drop the binding and re-enable discovery for that store. The platform wrapper's blanket skip-find-certificate-key=true exists 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.
  • no name → unchanged.

managesKey treats only a definitive ErrNotFound 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.

Verification

Simulator test covering both branches of managesKey. go test ./... and the tpmsimulator-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:

before:  recorded container = "app-bindtest"   (does not exist)
after:   recorded container = "bindtest"       (holds the key)

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

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>
@darkfronza
darkfronza marked this pull request as ready for review August 10, 2026 21:26
@darkfronza

Copy link
Copy Markdown
Contributor Author

Validated on the full robot matrix

Cut a dev release with this commit as the only change, so a failure anywhere would be attributable to it: v0.69.1-dev2 (smallstep/agent@e219596c, branch diego/verify-tpmkms-swkey-binding) → run 31428774985.

All six release builds passed, and of the robot suites:

suite result
Windows Resilience
Windows Key Scope
Windows Key Scope Remediation
MacOS
Jamf MacOS
Linux API Registered (22.04 + 24.04)
Linux Browser (24.04)
Windows
Linux Browser (22.04)

The two failures are pre-existing rather than caused by this change: the same Windows and Linux Browser suites fail on v0.70.0-dev6, which does not contain it — and that run additionally failed Windows Key Scope Remediation, which passes here. Confirmed independently by @darkfronza against the dev build.

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 directly

Storing twice through the agent's own KMS wrapper for an endpoint with software key protection, on a real Windows host:

first store    serial=1001 container="superstest"
second store   serial=1002 container="superstest"

Both certificates land on the software-KSP container that actually holds the key, where before this change they were stamped app-superstest under the Platform Crypto Provider — a container that does not exist, so CryptAcquireCertificatePrivateKey failed outright while CertFindChainInStore/BY_ISSUER still offered them as client-auth candidates.

Note what this change does not fix, deliberately: replace-on-store still does not fire for these endpoints, because LoadCertificateChain resolves through GetPublicKey on the TPM store and there is no TPM key of that name. That is #1036's territory, and the resulting accumulation is meanwhile bounded by the container-based stale sweep — measured 2 → 1 immediately after the store.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants