Repository navigation
fix(legacy): leave the eMudhra roots out of the Windows CA bundle - #256
Conversation
On Windows, the CA file given to PHP adds the trusted roots from the Windows certificate store to the shipped curl bundle. Windows distrusts the compromised eMudhra intermediates (EM DV/OV/EV TLS CA - G3B/G3C) through its disallowed certificate list, but keeps trusting their roots, and installs them on demand. PHP's OpenSSL cannot read that list, so it still trusted the intermediates through those roots. Leave out AAA Certificate Services, emSign Root TLS CA - G3 and emSign ECC Root CA - G3 (which cross-signs emSign Root TLS CA - G3), by SHA-256 fingerprint. The shipped curl bundle does not include them either. See: https://emudhra.we-have-a-problem.com/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 2 minor points
🔍 Full review · 2 files reviewed
🔵 Minor points
internal/legacy/ca_bundle_windows.go:74— The distrust check runs only on certificates from the Windows ROOT store, aftershipped[fingerprint]has already returned.caBundlecopiescaCertinto the bundle without filtering it. Theupdate-phpworkflow updates the shipped curl bundle automatically (CACERT_DATE/CACERT_SHA256). If a later curl bundle carries one of these roots, it goes into the bundle unnoticed, even thoughdistrustedRootssays it is never trusted. Its comment says "The shipped bundle no longer has these roots either", but nothing checks that.internal/legacy/ca_bundle_windows_test.go:81— On any machine whose ROOT store doesn't yet hold these roots,TestWindowsCABundleDistrustedRootspasses without testing anything. Windows installs them on demand, so a fresh CI runner usually won't have them. The test only logsinStoreand doesn't skip, so removing thedistrustedRootscheck would still pass there. Nothing in the test exercises the filter itself, for example by calling a function that takes the store certificates as input.
Verification
- The new condition skips a store certificate when its SHA-256 matches one of the three hex fingerprints.
hex.EncodeToStringgives lowercase output, and the map keys are lowercase, so the lookup matches. - The test's fingerprints are the same three values as in
distrustedRoots, and it decodes them into [32]byte the same waycertFingerprintskeys certificates. - The AAA Certificate Services and emSign ECC Root CA - G3 fingerprints match the published SHA-256 values for those roots.
The PR adds TestWindowsCABundleDistrustedRoots. It only runs on Windows, and it only catches a regression when the host's ROOT store already holds one of these roots. No test covers the filter with injected certificates.
Review details
- Commit: 2537942
- Model: claude-opus-5-5
Review 1 of 10 for this pull request · View the full run
Move the distrust check into usableTLSRoot, and test it with a generated certificate, as the Windows store may not hold the real roots yet (Windows installs them on demand). Also check that the shipped curl bundle has none of the distrusted roots, as it is added to the bundle unfiltered and is updated automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · ⚪ 1 nitpick
🔁 Incremental · 2 files reviewed
⚪ Nitpick
internal/legacy/ca_bundle_windows.go:97— The rewrittenusableTLSRootdoc comment wasn't re-wrapped. Line 97 runs to about 119 columns, while the lines around it wrap near 80.
Verification
usableTLSRootnow rejects a fingerprint indistrustedRootsbefore any other check, andsystemRootsPEMsends every store certificate it doesn't skip as shipped through that function.systemRootsPEMstill skips shipped certificates before the distrust check, soTestWindowsCABundleDistrustedRootsnow also asserts that none of the three roots is incaCert.TestWindowsUsableTLSRootadds a temporary entry to the package-leveldistrustedRootsmap and removes it int.Cleanup; no test ininternal/legacycallst.Parallel, so nothing races on the map.
TestWindowsUsableTLSRoot now tests the distrust filter directly, using a generated certificate. TestWindowsCABundleDistrustedRoots checks the shipped bundle and the built bundle. Both run in the windows-cert-store job in ci.yml (go test -run TestWindows ./internal/legacy/ on windows-latest).
Review 2 of 10 for this pull request · View the full run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows, the CA file given to the embedded PHP is the shipped curl bundle plus the trusted roots from the Windows
ROOTstore. That let PHP trust the compromised eMudhra intermediates (EM DV/OV/EV TLS CA - G3B/G3C, and their Sectigo cross-signs).Windows distrusts those intermediates through its auto-updated disallowed certificate list (
disallowedcertstl.cab). It doesn't distrust their roots, and it installs them on demand. TheDisallowedstore contains no certificates for them, so filtering on it would not help. PHP's OpenSSL can't read that list, so it still trusted the intermediates through the roots.This leaves out three roots by SHA-256 fingerprint:
The shipped curl bundle (2026-10-20) does not include them either. As a result, PHP on Windows no longer trusts other chains under these roots that Windows still accepts, such as legacy Sectigo cross-signs via AAA.
Checked on Windows 11:
TestWindowsCABundleDistrustedRootsfails without the change (all three roots in the store end up in the bundle) and passes with it.*.we-have-a-problem.com. It succeeds when the three roots are added back.🤖 Generated with Claude Code