Skip to content

feat(legacy): trust the Windows certificate store - #142

Open
pjcdawkins wants to merge 3 commits into
spike/windows-cert-storefrom
feat/windows-ca-store
Open

feat(legacy): trust the Windows certificate store#142
pjcdawkins wants to merge 3 commits into
spike/windows-cert-storefrom
feat/windows-ca-store

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. Needs cli-php-builds#1, which built the PHP this depends on.

The problem

The embedded PHP has to be given a CA file: its openssl extension cannot read the Windows certificate store, and the CLI uses openssl streams for some requests. curl built against Schannel then verifies against that file alone. So on Windows the CLI trusted only the certificates shipped with it, and a machine whose TLS traffic is inspected — where every other program trusts the organization's root certificate from the store — failed with cURL error 60.

The Go part of the CLI verifies through the Windows platform verifier, so it already trusted that certificate. The two parts of the CLI disagreed about the same connection.

The change

curl in the embedded PHP is now built against OpenSSL, which loads a CA file and the Windows stores together and does not limit the size of the file. caBundle() reads the trusted roots from the store and appends the ones the shipped bundle does not already have, skipping expired certificates and any the Go parser cannot read. The wrapper writes that to cacert.pem and points openssl.cafile at it, as before.

That setting covers 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() use 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, Go included. It is additive, so nothing previously trusted stops working.

Why not merge the whole store under Schannel

That was the first attempt, and the runner rejected it: Schannel refuses a CA file over 1 MiB (MAX_CAFILE_SIZE in curl's schannel_verify.c), and the store held 564 roots, giving a 758 KB bundle even after filtering — 76% of a hard limit, with a silent fallback to shipped-only when exceeded. Switching the backend removed the limit, and with it the filtering heuristics and the fallback.

Cost

Reading the store happens in copy(), before every legacy command, inside the init lock. Measured on the runner:

BenchmarkWindowsCABundle-4   60   19787728 ns/op   5849820 B/op   34752 allocs/op
BenchmarkWindowsCAFile-4     58   18934134 ns/op   5883677 B/op   34760 allocs/op

About 19 ms, effectively all of it reading and parsing certificates — the file comparison and write are free. Not cached deliberately: a cache would mean the CLI keeps rejecting a certificate the machine already trusts until the cache expired, which is the bug being fixed, only with a delay. The allocation count says the number is the implementation's, not the store's, so making it leaner is the first move if it ever matters.

PHP 8.4.23

The second commit takes the build with OpenSSL-backed curl, which this depends on. It also brings security fixes since 8.4.20, two of which apply here: CVE-2026-12184, a segfault in file_get_contents with an https URL and a proxy set, a combination the CLI uses; and CVE-2026-14355 in the openssl extension.

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 curl built
against Schannel then verifies against that file alone. 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 part of the CLI already trusted the store, so the two
parts disagreed about the same connection.

curl in the embedded PHP is now built against OpenSSL, which reads a CA
file and the store together and does not limit the size of the file, so
the bundle written to the cache directory holds the shipped certificates
and the store's trusted roots. Pointing openssl.cafile at it covers the
whole PHP layer, not just curl: Composer\CaBundle checks that setting, so
Guzzle and the stream context follow the same file.

The certificate store test changes with the behavior, as its comment said
it would.

Written by Claude Code.
This build has curl built against OpenSSL, which the CA bundle now
depends on.

It also brings security fixes since 8.4.20, including CVE-2026-12184, a
segfault in file_get_contents with an https URL and a proxy set, which is
a combination the CLI uses, and CVE-2026-14355 in the openssl extension.

Written by Claude Code.
@upsun-dispatch

upsun-dispatch Bot commented Jul 30, 2026

Copy link
Copy Markdown

📋 PR Summary

This incremental change makes the Windows CA bundle logic degrade gracefully instead of aborting the CLI. caBundle() now returns the shipped caCert (plus the error) when the certificate store cannot be read, and writeCAFile() records the failure as a non-fatal warning while still writing the shipped bundle. The warnings are surfaced through a new warnings() method on the phpManager interface and logged at debug level after init completes.

Changes
Layer / File(s) Summary
graceful store-read fallback
internal/legacy/ca_bundle_windows.go On a store-read error, caBundle() now returns the embedded caCert alongside the error instead of a nil bundle, and documents the ROOT store merge and fallback behavior.
internal/legacy/php_manager_windows.go writeCAFile() no longer returns the caBundle() error; it appends the reason to copyWarnings and still writes the (shipped) bundle so init cannot fail on a store-read error.
warnings plumbing
internal/legacy/php_manager.go Adds a warnings() method to the phpManager interface and a copyWarnings field to phpManagerPerOS; switches to keyed struct construction.
internal/legacy/legacy.go Holds the phpManager so its warnings can be emitted via c.debug after g.Wait() succeeds.
tests
internal/legacy/ca_bundle_windows_test.go Updates the benchmark to keyed struct construction to match the new field.

Comment thread internal/legacy/php_manager_windows.go Outdated
func (m *phpManagerPerOS) writeCAFile() error {
bundle, err := caBundle()
if err != nil {
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning — A store-read failure now breaks every legacy command instead of degrading to shipped certs.

writeCAFile returns any caBundle() error, and copy() is run inside the init errgroup in legacy.go (g.Go(newPHPManager(cacheDir).copy)), so a failure makes g.Wait() fail and runInitOnce abort, which fails Exec for every legacy command. Before this change copy() wrote the embedded caCert unconditionally and always succeeded. Now if CertOpenSystemStore("ROOT") or CertEnumCertificatesInStore fails in a locked-down or unusual Windows environment, the CLI can no longer run any legacy command at all — a strictly worse outcome than the shipped-only bundle, which would have kept normal (non-inspected) TLS working. Falling back to the shipped bundle on a store-read error preserves the prior behavior.

}

var out bytes.Buffer
err = eachStoreCert("ROOT", func(der []byte) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Info — Widens PHP trust to user-installed roots and unconstrained certs in the ROOT store.

eachStoreCert("ROOT", ...) opens the logical ROOT store via CertOpenSystemStore(0, ...), which merges the local-machine and current-user root stores, and every certificate found is appended to cacert.pem with no CA:true/basic-constraints or key-usage filtering. A root a non-privileged user (or user-level malware) adds to their own HKCU ROOT store, or any end-entity certificate sitting in ROOT, therefore becomes a trusted TLS anchor for the embedded PHP. This mirrors what the Go layer and other programs do, so it is largely by design, but it does widen PHP's trust to user-controllable roots.

copy() runs in the errgroup which initializes the wrapper, so returning
an error from reading the certificate store would stop every legacy
command from running. That is worse than not seeing an organization's
certificates: the shipped certificates keep everything else working, and
are what the CLI trusted before it read the store.

The reason is reported through the wrapper's debug output instead.

Written by Claude Code.
@pjcdawkins

Copy link
Copy Markdown
Contributor Author

Both review findings addressed.

The store-read failure warning was right and is the more important of the two: copy() runs in the errgroup which initializes the wrapper, so a failure to read the store would have stopped every legacy command — strictly worse than not seeing an organization certificate. caBundle() now returns the shipped certificates along with the reason, writeCAFile writes them and records the reason, and the wrapper reports it through debug. So a locked-down environment loses the store, not the CLI.

On widening trust to user-installed roots: that is deliberate, and it is the point of the change. The ROOT store is what the operating system trusts, and it is what the Go part of this same CLI already trusted through the platform verifier — so before this change the two halves disagreed about the same connection. Filtering to CA:true would drop legitimate corporate roots, which often lack basic constraints, in exchange for a threat model where a user who can write to their own HKCU\ROOT has already compromised every browser and tool on the machine. I have added a comment saying the store merges the machine and user roots, so the next reader does not have to infer it.

Written by Claude Code.

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Upsun Dispatch Review: incremental · 5 files reviewed · no new issues · 1 still open

Outstanding from earlier reviews:

  • #3679219852 — internal/legacy/ca_bundle_windows.go:56: Widens PHP trust to user-installed roots and unconstrained certs in the ROOT store.

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