Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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