Skip to content
Open
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,39 @@ jobs:

- name: Run integration tests
run: make integration-test

# The only Windows coverage: which certificates the embedded PHP trusts.
windows-cert-store:
runs-on: windows-latest

steps:
- name: Check out repository code
uses: actions/checkout@v7

- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: ./go.mod

- name: Download embedded PHP files
shell: bash
run: |
php_version=$(grep -m1 '^PHP_VERSION' Makefile | cut -d' ' -f3)
mkdir -p internal/legacy/archives
curl -fSL "https://github.com/upsun/cli-php-builds/releases/download/php-$php_version/php-$php_version-windows-amd64.exe" \
-o internal/legacy/archives/php_windows.exe
curl -fSL https://curl.se/ca/cacert.pem -o internal/legacy/archives/cacert.pem
# Only needed so that the package builds.
touch internal/legacy/archives/platform.phar

- name: Run Windows tests
shell: bash
env:
CLI_TEST_MODIFY_CERT_STORE: '1'
run: GOEXPERIMENT=jsonv2 go test -v -count=1 -timeout 3m -run TestWindows ./internal/legacy/

# Reading the certificate store happens before every legacy command.
- name: Measure building the CA bundle
if: always()
shell: bash
run: GOEXPERIMENT=jsonv2 go test -run '^$' -bench BenchmarkWindows -benchmem ./internal/legacy/
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
PHP_VERSION = 8.4.20
PHP_VERSION = 8.4.23

GOOS := $(shell uname -s | tr '[:upper:]' '[:lower:]')
GOARCH := $(shell uname -m)
Expand Down
120 changes: 120 additions & 0 deletions internal/legacy/ca_bundle_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package legacy

import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"time"
"unsafe"

"golang.org/x/sys/windows"
)

// caBundle returns the certificates the legacy CLI should trust: those shipped
// with it, plus the trusted roots from the Windows certificate store.
//
// If the store cannot be read it returns the shipped certificates and the
// reason: they are what the CLI trusted before, so they are still usable, and
// the caller decides what to do about the rest.
//
// An organization which inspects TLS traffic installs its own root certificate
// in that store, and every other program on the machine then trusts it. The
// embedded PHP has to be given a CA file, because its openssl extension cannot
// read the store, so the store's certificates are added to the file instead.
//
// This needs curl in the embedded PHP to be built against OpenSSL. Built
// against Schannel, as static-php-cli does by default, it refuses a CA file
// larger than 1 MiB, which this bundle can exceed.
func caBundle() ([]byte, error) {
roots, err := systemRootsPEM()
if err != nil {
return caCert, err
}

bundle := make([]byte, 0, len(caCert)+len(roots)+1)
bundle = append(bundle, bytes.TrimRight(caCert, "\n")...)
bundle = append(bundle, '\n')
return append(bundle, roots...), nil
}

// systemRootsPEM returns the trusted roots from the Windows certificate store
// which the shipped certificates do not already cover.
//
// The ROOT store merges the machine's roots with the current user's, which is
// what the operating system, and so every other program on it, trusts. That
// includes anything the user has added themselves.
func systemRootsPEM() ([]byte, error) {
shipped, err := certFingerprints(caCert)
if err != nil {
return nil, err
}

var out bytes.Buffer
err = eachStoreCert("ROOT", func(der []byte) error {
Comment thread
pjcdawkins marked this conversation as resolved.
if shipped[sha256.Sum256(der)] {
return nil
}
cert, err := x509.ParseCertificate(der)
if err != nil {
// A store can hold a certificate Go cannot read, and one which
// cannot be parsed would make the whole file unusable.
return nil
}
if now := time.Now(); now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return nil
}
return pem.Encode(&out, &pem.Block{Type: "CERTIFICATE", Bytes: der})
})
if err != nil {
return nil, err
}
return out.Bytes(), nil
}

// certFingerprints reads a PEM bundle and returns a fingerprint per certificate.
func certFingerprints(bundle []byte) (map[[32]byte]bool, error) {
fingerprints := make(map[[32]byte]bool)
for rest := bundle; len(rest) > 0; {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
fingerprints[sha256.Sum256(block.Bytes)] = true
}
}
if len(fingerprints) == 0 {
return nil, errors.New("no certificates found in the shipped CA bundle")
}
return fingerprints, nil
}

// eachStoreCert calls fn with the encoded bytes of every certificate in a
// Windows system store, as read by every program which verifies against it.
func eachStoreCert(name string, fn func(der []byte) error) error {
store, err := windows.CertOpenSystemStore(0, windows.StringToUTF16Ptr(name))
if err != nil {
return fmt.Errorf("could not open the %s certificate store: %w", name, err)
}
defer windows.CertCloseStore(store, 0) //nolint:errcheck

var certContext *windows.CertContext
for {
certContext, err = windows.CertEnumCertificatesInStore(store, certContext)
if certContext == nil {
if err != nil && !errors.Is(err, windows.Errno(windows.CRYPT_E_NOT_FOUND)) {
return fmt.Errorf("could not read the %s certificate store: %w", name, err)
}
return nil
}
// The context belongs to the store, so the bytes are only borrowed.
if err := fn(unsafe.Slice(certContext.EncodedCert, certContext.Length)); err != nil {
windows.CertFreeCertificateContext(certContext) //nolint:errcheck
return err
}
}
}
65 changes: 65 additions & 0 deletions internal/legacy/ca_bundle_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package legacy

import (
"crypto/x509"
"encoding/pem"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWindowsCABundle(t *testing.T) {
bundle, err := caBundle()
require.NoError(t, err)

shipped, err := certFingerprints(caCert)
require.NoError(t, err)
held, err := certFingerprints(bundle)
require.NoError(t, err)

// The size varies by machine, so it is reported rather than asserted. It
// only has to be a size curl will read, which OpenSSL does not limit.
t.Logf("bundle: %d certificates in %d KB (%d shipped, %d from the store)",
len(held), len(bundle)/1024, len(shipped), len(held)-len(shipped))

assert.Greater(t, len(held), len(shipped), "expected the store to add certificates")
for fingerprint := range shipped {
require.True(t, held[fingerprint], "expected every shipped certificate to still be trusted")
}
require.True(t, x509.NewCertPool().AppendCertsFromPEM(bundle),
"expected the bundle to be readable as a pool of trusted roots")
for rest := bundle; len(rest) > 0; {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
require.Empty(t, rest, "expected the whole bundle to be readable")
break
}
_, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err, "expected every certificate in the bundle to be readable")
}
}

// BenchmarkWindowsCABundle measures reading the store and building the bundle,
// which happens before every legacy command.
func BenchmarkWindowsCABundle(b *testing.B) {
for b.Loop() {
if _, err := caBundle(); err != nil {
b.Fatal(err)
}
}
}

// BenchmarkWindowsCAFile measures the whole of what a command pays: building
// the bundle, and finding the cached file already up to date.
func BenchmarkWindowsCAFile(b *testing.B) {
manager := &phpManagerPerOS{cacheDir: b.TempDir()}
require.NoError(b, manager.writeCAFile())

for b.Loop() {
if err := manager.writeCAFile(); err != nil {
b.Fatal(err)
}
}
}
Loading