Follow-ups to the certificate searcher: bounds-checked property reads, store handle leaks - #1091
Open
darkfronza wants to merge 6 commits into
Open
Conversation
cryptFindCertificateKeyContainerName followed the pwszContainerName pointer CryptoAPI wrote into the property buffer, walking UTF-16 until a NUL with no relation to the size the API reported, and pinned the buffer with runtime.KeepAlive. The KeepAlive was a no-op: Go's garbage collector retains an object when any pointer into it is live, and both info and info.pwszContainerName are interior pointers into buf. Noscan means the collector does not scan inside buf for outgoing pointers; it says nothing about incoming references. The comment justifying the KeepAlive stated the opposite, and would have been copied to somewhere it mattered. The hazard that was real is the unbounded read. Decode the string out of buf instead, so every read is bounds-checked against the reported size and a short or malformed property yields an error rather than reading past the buffer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SearchCertificates dropped a certificate whenever its CERT_KEY_PROV_INFO
could not be read. A certificate with no private-key association already
reads back as ("", nil) via CRYPT_E_NOT_FOUND, so the only certificates
reaching that path were ones whose association exists but is corrupt --
exactly the population this API was added to find. Silently omitting them
inverts the purpose of the search.
Return the certificate with the read failure recorded on the new
SearchCertificateResult.Err field, which also lets a caller tell "no key
association at all" apart from "association present but unreadable". A
certificate whose DER cannot be parsed is still skipped: there is no
certificate to report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing capi test stores with skip-find-certificate-key=true and asserts the container name comes back empty, so it passes just as happily against the reader this branch's parent replaced -- one that returned "" unconditionally. The only assertion of a real container name lives in a test needing both a TPM and Windows. Add a capi-level test that pins the name. StoreCertificate's "key" branch records the association without checking that the container exists, so the test needs no key, no TPM and no administrative rights, and it sets up precisely the state SearchCertificates exists to detect: a certificate naming a key container that is not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six functions opened a system certificate store with the same twenty lines of location switch, CertOpenStore and error wrap, and five of them never closed the handle. SearchCertificates, added on the parent branch, was the only one that did, on the grounds that a search invites frequent calls; but FindCertificatesByIssuer and CleanupCredentials are called just as often by the same long-running sweeps, and every LoadCertificate, CreateSigner and GetPublicKey leaks one through getCertContext. Extract openCertStore and close the handle at all six sites. Closing with no flags only drops a reference -- a certificate context obtained from a store holds its own -- so getCertContext can close with a defer while still handing a live context back to its caller. CERT_CLOSE_STORE_FORCE_FLAG would not be safe there and is deliberately not used. Consolidating also fixes a latent GC hazard the six copies shared: the store name was converted to uintptr inside the call, leaving no Go pointer to it, so it could be reclaimed while CertOpenStore ran. The compiler's special case for that pattern covers only conversions written inside a syscall.Syscall call, which this is not, so the string is now held in a variable and kept alive across the call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five places distinguished "not there" from "failed to look" with a bare err.(windows.Errno) type assertion, which silently stops working the day the error arrives wrapped. Replace them with an isNotFound helper built on errors.As. Also remove the certHandle == nil branch in SearchCertificates. x/sys sets err whenever CertEnumCertificatesInStore returns a nil context -- errnoErr maps a zero errno to EINVAL rather than nil -- so the branch was unreachable, and having it there implied the two conditions were independent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wrapper dereferenced req in clone(req) before any validation, so a nil request panicked where both backends that implement the interface return an error. Validate at the wrapper, which is the entry point most callers reach the KMS through; capi and tpmkms keep their own checks, since both are exported backends used directly as well. platform.CleanupCredentials has the same gap, left alone here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1089 — review that one first; this PR's diff is against it, not against master.
Six follow-ups from review of #1089. They either change behavior that PR deliberately scoped out, or touch pre-existing code, so they are kept separate.
The container name reader no longer follows a raw pointer
cryptFindCertificateKeyContainerNamedereferenced thepwszContainerNamepointer CryptoAPI wrote into the property buffer, walking UTF-16 until a NUL with no relation to the size the API reported, and pinned the buffer withruntime.KeepAlive.The
KeepAlivewas a no-op. Go's GC retains an object when any pointer into it is live, and bothinfoandinfo.pwszContainerNameare interior pointers intobuf. Noscan means the collector doesn't scan insidebuffor outgoing pointers; it says nothing about incoming references. The comment justifying theKeepAliveasserted the opposite, which is the kind of reasoning that gets copied to somewhere it matters.The hazard that was real is the unbounded read. The string is now decoded out of
buf, so every read is bounds-checked against the reported size and a short or malformed property returns an error instead of reading past the buffer. The newutf16StringInBufferhelper is reusable forpwszProvName.Certificates with unreadable key metadata are reported, not hidden
SearchCertificatesdropped a certificate whenever itsCERT_KEY_PROV_INFOcouldn't be read. A certificate with no private-key association already reads back as("", nil)viaCRYPT_E_NOT_FOUND, so the only ones reaching that path had an association that exists but is corrupt — exactly the population this API was added to find.apiv1.SearchCertificateResultgains anErrfield carrying the read failure. That also lets a caller tell "no key association at all" apart from "association present but unreadable". A certificate whose DER can't be parsed is still skipped; there's no certificate to report.A test that actually pins the fix
The capi test in #1089 stores with
skip-find-certificate-key=trueand asserts the container name comes back empty — it passes just as happily against the reader #1089 replaced, which returned""unconditionally. The only assertion of a real container name needs both a TPM and Windows.The new capi test pins the name on any Windows box.
StoreCertificate'skey=branch records the association viasetCertificateKeyProvInfowithout checking the container exists, so no key, no TPM and no administrative rights are needed — and it sets up precisely the state this feature exists to detect: a certificate naming a key container that isn't there.Certificate store handles are closed
Six functions opened a store with the same twenty lines of location switch,
CertOpenStoreand error wrap; five never closed the handle.SearchCertificateswas the exception, on the grounds that a search invites frequent calls — butFindCertificatesByIssuerandCleanupCredentialsare called just as often by the same sweeps, and everyLoadCertificate,CreateSignerandGetPublicKeyleaked one throughgetCertContext.Extracted
openCertStoreand closed the handle at all six. Closing with no flags only drops a reference — a certificate context holds its own — sogetCertContextcan close with adeferwhile still handing a live context back.CERT_CLOSE_STORE_FORCE_FLAGwould not be safe there and is deliberately not used.Consolidating also fixed a latent GC hazard all six copies shared: the store name was converted to
uintptrinside the call, leaving no Go pointer to it, so it could be reclaimed whileCertOpenStoreran. The compiler's special case for that pattern covers only conversions written inside asyscall.Syscallcall, which this isn't. It's now held in a variable and kept alive across the call — the genuine version of the pattern the first commit removes, and the two comments explain the difference.StoreCertificateopens its store before building the certificate context, preserving the early store-location validation so an unusable location still fails before key association, which can prompt for a smart card.Smaller fixes
err.(windows.Errno)assertions replaced with anisNotFoundhelper built onerrors.As, which keeps working when the error arrives wrapped.certHandle == nilbranch inSearchCertificates. x/sys setserrwheneverCertEnumCertificatesInStorereturns a nil context (errnoErrmaps a zero errno toEINVAL, not nil), so it was unreachable, and its presence implied the two conditions were independent.platform.KMS.SearchCertificatesrejects a nil request instead of panicking inclone(req).capiandtpmkmskeep their own checks — both are exported backends used directly too.platform.CleanupCredentialshas the same gap, left alone here.Verification
CI here is ubuntu-only, so none of the windows-tagged code runs there. Stating exactly what was run locally:
GOOS=windows GOARCH=amd64 go build ./kms/...— clean, and clean at each of the six commits individuallyGOOS=windows GOARCH=amd64 go vet ./kms/capi/... ./kms/platform/... ./kms/tpmkms/...— the single finding (ncrypt_windows.gopossible misuse of unsafe.PointerinfindCertificateInStore) is byte-identical to the base branch; only its line number moved. Nothing new around theuintptrarithmetic inutf16StringInBuffer.go test ./kms/...on the host; windows-tagged tests compile underGOOS=windowsutf16StringInBufferwas validated against a synthetic blob laid out the way CryptoAPI lays one out: correct container and provider names, out-of-buffer pointer rejected (an underflowing offset wraps to a hugeuintptr, so one check covers both directions), unterminated string rejectedgolangci-lintcould not be run locally: the shared smallstep config requires a newer version than 2.4.0, and once patched around that the linter panics type-checking a dependency needing go1.26 while built with go1.25. It fails identically on the base branch, so it's a local toolchain gap rather than something this branch introduces — CI covers it.Still outstanding, not addressed here: the
KeyContainerNamefield is a CAPI/CNG concept sitting in cross-platformapiv1, and unlikeSearchKeysit returns a raw container name rather than a KMS URI the platform wrapper can translate. Worth settling while the interface is still Experimental.🤖 Generated with Claude Code