Skip to content

feat(legacy): add the Windows certificate store to the CA bundle - #140

Closed
pjcdawkins wants to merge 2 commits into
spike/windows-cert-storefrom
feat/windows-merged-ca-bundle
Closed

feat(legacy): add the Windows certificate store to the CA bundle#140
pjcdawkins wants to merge 2 commits into
spike/windows-cert-storefrom
feat/windows-merged-ca-bundle

Conversation

@pjcdawkins

Copy link
Copy Markdown
Contributor

Fixes #110. Stacked on #139, so review that first — the base will change to main once it merges.

Draft while one decision is open: whether reading the certificate store needs caching. The CI job now measures it, so the numbers are in the run below rather than guessed at.

The problem

The embedded PHP has to be given a CA file. Its openssl extension cannot read the Windows certificate store at all, and the CLI uses openssl streams for some requests, so leaving the file unset is not an option. But setting one also stops curl reading the store, which #139 measured. The result is that the CLI trusted only the certificates shipped with it, and failed wherever an organization installs its own root certificate — cURL error 60, as reported.

The Go half of the CLI verifies through the Windows platform verifier, so it already trusted such a certificate. The two halves disagreed about the same connection.

The change

caBundle() reads the trusted roots from the Windows ROOT store and appends them to the shipped certificates. The wrapper writes that to cacert.pem in the cache directory and points openssl.cafile at it, as before.

Pointing the ini setting at the merged file is enough for the whole PHP layer, not just curl: Composer\CaBundle checks openssl.cafile when it resolves a bundle, so Guzzle's verify option and the stream context in Config::getStreamContextOptions() follow the same file.

This widens trust to what the machine already trusts, which is the point — and it is what every other program on it does, including Go. It is additive, so nothing previously trusted stops working.

Open question: caching

writeCAFile runs on every legacy command, inside the init lock. It reads the store, builds the bundle, and WriteIfNeeded compares it against the cached file, writing only when the store has changed. Caching the enumeration would save that work at the cost of not noticing a newly installed certificate until the cache expired.

BenchmarkWindowsSystemRootsPEM and BenchmarkWindowsCAFile measure both halves on the runner. See the "Measure building the CA bundle" step.

Notes

  • No new configuration. Anyone who already sets SSL_CERT_FILE keeps overriding everything, because Composer\CaBundle checks it before openssl.cafile.
  • Unix is unchanged: the wrapper sets no CA file there, and the certificate paths PHP and Go already look in work.
  • The certificate store test from fix(legacy): quote the CA bundle path, and cover Windows certificate trust #139 changes with the behavior, as its comment said it would: the wrapper's own settings now trust a certificate from the store, and a new case pins the shipped certificates alone to keep the old failure documented.
  • Still outstanding for anyone whose root certificate is in a file rather than the store: the Go layer ignores SSL_CERT_FILE on Windows and macOS, since Go only reads it on Unix. That is a separate change.

Written by Claude Code.

The embedded PHP has to be given a CA file, because its openssl extension
cannot read the Windows certificate store, and setting one stops curl
reading the store as well. So the CLI trusted only the certificates
shipped with it, and failed wherever an organization installs its own
root certificate, which is what issue #110 reported. The Go layer already
trusts the store, so the two halves of the CLI disagreed.

The bundle written to the cache directory now holds the shipped
certificates and the store's trusted roots.

Written by Claude Code.
A Windows runner has 382 certificates in its root store, so the whole
store plus the shipped certificates came to more than the 1 MiB curl will
read from a CA file (MAX_CAFILE_SIZE in its schannel_verify.c), and every
request failed with cURL error 77 instead. One certificate in the store
could not be parsed by Go either.

Windows keeps the certificates from Microsoft's root program in the
AuthRoot store as well as in the root store, so leaving those out leaves
the roots the machine was told to trust locally, which is what an
organization installs and what the shipped certificates cannot cover.
That cannot lose any trust the CLI had before, because before it trusted
only the certificates shipped with it.

Expired and unreadable certificates are skipped, and the shipped
certificates alone are used if the result would still be too large.

Written by Claude Code.
@pjcdawkins

Copy link
Copy Markdown
Contributor Author

Superseded by #142, which fixes this properly rather than working around it.

Merging the whole store under Schannel does not work: it refuses a CA file over 1 MiB, and the runner measured a 758 KB bundle from a 564-root store, so this approach lived just under a hard limit and fell back to the shipped certificates alone when it exceeded it — silently reintroducing the bug. The AuthRoot filter it used to stay under that limit was also based on a wrong assumption: AuthRoot held 14 certificates on the runner, not the hundreds it assumed.

upsun/cli-php-builds#1 builds curl against OpenSSL instead, which reads a CA file and the store together with no size limit. #142 is the result: no heuristic, no cap, no fallback.

Written by Claude Code.

@pjcdawkins pjcdawkins closed this Jul 30, 2026
@pjcdawkins
pjcdawkins deleted the feat/windows-merged-ca-bundle branch July 30, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant