Skip to content

feat(legacy): let Schannel verify certificates on Windows - #259

Merged
pjcdawkins merged 5 commits into
mainfrom
cli-222-windows-schannel
Oct 21, 2026
Merged

pjcdawkins merged 5 commits into
mainfrom
cli-222-windows-schannel

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

On Windows, the CLI gave the embedded PHP a CA bundle: the curl.se bundle plus the roots in the Windows certificate store. That copy misses trust decisions Windows makes outside the ROOT store. For example, Microsoft distrusted the compromised eMudhra intermediates only through its auto-updated disallowed certificate trust list, which is why #256 blocklists three roots.

With curl built with Schannel (upsun/cli-php-builds#4) and no CA file, Windows verifies certificates itself: its certificate store (including roots installed for TLS inspection, see #110), the disallowed list, and revocation.

  • The wrapper no longer writes the CA bundle or sets openssl.cafile: PHP passes it to curl, and Schannel would then use the file instead of the store. The bundle code, the embedded curl.se bundle and its updates in update-php.sh are removed.
  • The legacy CLI makes all HTTPS requests with curl: the credential helper download used PHP streams, which use OpenSSL and cannot read the store (the activity log stream moved to curl in refactor(activity): use curl for the activity log stream #258). Config::getGuzzleOptions() holds the TLS, proxy and user agent options, which also set the activity log stream's curl options.
  • With Schannel, Guzzle gets verify: true and CURLSSLOPT_REVOKE_BEST_EFFORT. Schannel's default strict revocation checks reject roots without revocation information, which is common for TLS inspection roots. SSL_CERT_FILE is no longer used on Windows.
  • A Connector subclass applies the curl options to OAuth 2.0 token requests, which the API client library makes with a separate HTTP client.

Tested on Windows 11 with a Schannel build from upsun/cli-php-builds#4: the eMudhra test sites are rejected (CRYPT_E_REVOKED) for API and token requests, and a root without revocation information installed in the machine store is trusted.

This pins PHP 8.5.12 build 2, the first Schannel build. An automatic update-php pull request for the same build is redundant with this one.

🤖 Generated with Claude Code

pjcdawkins and others added 3 commits October 21, 2026 15:50
The embedded PHP's curl is now built with Schannel (upsun/cli-php-builds),
which verifies certificates with Windows: the certificate store
(including roots installed for TLS inspection, and roots Windows
downloads on demand), the disallowed certificate trust list, and
revocation.

The wrapper no longer writes a CA bundle (the shipped curl bundle plus
the store's roots) or sets openssl.cafile: PHP passes that to curl, and
Schannel would then verify against the file instead of the store. The
bundle missed trust decisions Windows makes outside the ROOT store, such
as the eMudhra intermediates distrusted only through the disallowed CTL.

This removes the CA bundle code, the embedded curl.se bundle and its
updates in update-php.sh, and file.WriteIfChanged, which only the bundle
used. The Windows test now checks that curl is built with Schannel, that
an installed root is trusted with best-effort revocation checks, and
that a CA file would replace the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on Windows

PHP streams use OpenSSL, which cannot use the Windows certificate store,
so the two remaining HTTPS requests made with them now use Guzzle and
curl:

- The activity log stream, with a new HttpStream class, which reads a
  response as it arrives through Guzzle's CurlMultiHandler, waiting for a
  limited time, as the non-blocking stream did.
- The credential helper download.

Config::getGuzzleOptions() replaces getStreamContextOptions(), and holds
the TLS, proxy and user agent options shared by every HTTP client. With
curl built against Schannel, it sets no CA file, so that Windows verifies
certificates, and sets CURLSSLOPT_REVOKE_BEST_EFFORT: Schannel's default
strict revocation checks reject roots without revocation information,
such as many installed to inspect TLS traffic.

The API client library's Connector only passes "verify" to Guzzle, so a
subclass adds a "curl" option, applied to API requests with a middleware
and to OAuth 2.0 token requests with the provider's HTTP client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	internal/legacy/ca_bundle_windows.go
#	internal/legacy/ca_bundle_windows_test.go
#	legacy/src/Service/ActivityMonitor.php
#	legacy/src/Service/Api.php
Build 2 of PHP 8.5.12 builds curl with Schannel on Windows
(upsun/cli-php-builds#4). No source archives changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pjcdawkins
pjcdawkins marked this pull request as ready for review October 21, 2026 16:42

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

Changes suggested — 🟡 1 warning

🔍 Full review · 25 files reviewed

Verification
  • Platformsh\Cli\Connector pushes its curl middleware after the library's OAuth middleware, so OAuth retries and redirects still go through it, and per-request curl options take precedence because of +.
  • The base Connector::$config is protected, and the library's getOAuth2Provider() passes the same debug, verify and proxy keys that the override reuses.
  • No remaining code in the repo references caBundle, WriteIfChanged, settings(), warnings(), cacert.pem (outside box.json) or getStreamContextOptions.
  • The only Guzzle clients in legacy/src are the connector, getExternalHttpClient, the credential-helper download and the OAuth provider, and all of them now get the options from getGuzzleOptions(); no remote file_get_contents remains.
  • Api::getCurlOptions sets CAINFO/CAPATH only when verify is a string, so on Schannel curl falls back to the Windows store.

New PHPUnit tests (ConfigTest::testGetGuzzleOptions and ConnectorTest, using a built-in-server trait) run in the CI legacy-php job on Linux, where Schannel is not used. The Schannel behaviour is covered only by the reworked TestWindowsCertStoreTrust in the windows-cert-store job, which calls PHP curl directly rather than the legacy CLI's own code. Nothing tests the HTTPS-proxy path.

Review details
  • Commit: 66ff4b2
  • Model: claude-opus-5-5

Review 1 of 10 for this pull request · View the full run

Comment thread legacy/src/Service/Config.php Outdated
… too

curl reads the TLS options for an HTTPS proxy from CURLOPT_PROXY_SSL_OPTIONS,
so with Schannel a proxy whose certificate chains to a root without
revocation information (as many TLS inspection roots are) failed the
strict default check. It now gets CURLSSLOPT_REVOKE_BEST_EFFORT as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pjcdawkins
pjcdawkins merged commit 899775d into main Oct 21, 2026
7 checks passed
@pjcdawkins
pjcdawkins deleted the cli-222-windows-schannel branch October 21, 2026 17:20
pjcdawkins added a commit that referenced this pull request Oct 21, 2026
* fix(legacy): use SSL_CERT_FILE on Windows, as Go does

Since #259, the embedded PHP on Windows verifies certificates with
Schannel against the Windows certificate store, and ignored
SSL_CERT_FILE. Go (since 1.27) still uses it on Windows, so with it set,
Go and PHP commands would trust different certificates, as before #141.

The wrapper now passes SSL_CERT_FILE to PHP as openssl.cafile, which
PHP gives to every curl handle as CURLOPT_CAINFO. Schannel then verifies
against that file instead of the store, as v5.15.0 did (through
Composer\CaBundle) and as Go does. Without SSL_CERT_FILE, nothing is
passed and the store is used. An unusable file makes requests fail, as
in Go.

SSL_CERT_DIR is still not supported on Windows, as Schannel cannot use a
directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(legacy): let PHP read SSL_CERT_FILE from the environment

The wrapper now sets openssl.cafile to "${SSL_CERT_FILE}", which PHP
expands itself, instead of quoting and escaping the path. A path with a
literal "${...}" was otherwise expanded by PHP's ini parser. The ini
escaping helper is removed, and the test checks paths with "~",
backslashes, "$" and quotes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(legacy): verify an HTTPS proxy with SSL_CERT_FILE on Windows too

PHP passes openssl.cafile to curl as CURLOPT_CAINFO, which only covers
servers: curl verifies an HTTPS proxy with CURLOPT_PROXY_CAINFO, without
falling back to CAINFO. So with SSL_CERT_FILE set, Schannel still
verified a proxy against the Windows store, while Go uses the file for
both.

With Schannel, getGuzzleOptions() now also sets CURLOPT_PROXY_CAINFO
from openssl.cafile when it is set. The Windows test checks both cases
through an HTTPS proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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