From 84edc691cc394ec12471592ac2434b07654672c0 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 08:53:47 -0800 Subject: [PATCH 1/3] CI: run the Windows runner trust probe under Windows PowerShell 5.1 pwsh is not installed on every Windows runner label, while Windows PowerShell (powershell) is. Switch the probe step to `shell: powershell` and make the probe script and the shared eMudhra helper compatible with both Windows PowerShell 5.1 and PowerShell 7+: - Build certificates from the PEM body instead of CreateFromPem (.NET 5+). - Compute SHA-256 from RawData instead of GetCertHashString('SHA256'). - Set ChainPolicy.DisableCertificateDownloads only when it exists. - Drop Invoke-WebRequest -SkipHttpErrorCheck and treat any HTTP response as a successful connection. - Append the step summary as BOM-less UTF-8. --- .../emudhra_intermediates.ps1 | 24 ++++++++++++++++--- .github/workflows/runner-trust-probe.yml | 2 +- ci/tools/runner_trust_probe.ps1 | 21 +++++++++++----- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 b/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 index 0324c5df448..a505ed8af63 100644 --- a/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 +++ b/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 @@ -14,15 +14,30 @@ $EmudhraIntermediatesPath = Join-Path $PSScriptRoot 'impacted-intermediates.json' $EmudhraIntermediatesCount = 16 +# Uppercase hex SHA-256 of the certificate's DER encoding. Computed directly +# because X509Certificate.GetCertHashString(HashAlgorithmName) does not exist +# in Windows PowerShell 5.1 (.NET Framework). +function Get-CertificateSha256([System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert) { + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return [System.BitConverter]::ToString($sha256.ComputeHash($Cert.RawData)).Replace('-', '') + } finally { + $sha256.Dispose() + } +} + # Returns the certificates as X509Certificate2 objects; callers dispose them. +# Works on both Windows PowerShell 5.1 and PowerShell 7+. function Get-EmudhraIntermediates { $entries = @(Get-Content -LiteralPath $EmudhraIntermediatesPath -Raw | ConvertFrom-Json) if ($entries.Count -ne $EmudhraIntermediatesCount) { throw "Expected $EmudhraIntermediatesCount certificates in $EmudhraIntermediatesPath, found $($entries.Count)." } foreach ($entry in $entries) { - $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($entry.pem) - if ($cert.GetCertHashString('SHA256') -ne $entry.sha256.ToUpperInvariant()) { + # X509Certificate2.CreateFromPem is .NET 5+ only; decode the PEM body manually. + $der = [System.Convert]::FromBase64String(($entry.pem -replace '-----[A-Z ]+-----', '' -replace '\s', '')) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($der) + if ((Get-CertificateSha256 $cert) -ne $entry.sha256.ToUpperInvariant()) { $cert.Dispose() throw "SHA-256 mismatch for $($entry.name) (issued by $($entry.issued_by))." } @@ -40,7 +55,10 @@ function Test-ExplicitlyDistrusted([System.Security.Cryptography.X509Certificate $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() try { $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck - $chain.ChainPolicy.DisableCertificateDownloads = $true + # DisableCertificateDownloads does not exist on .NET Framework (Windows PowerShell 5.1). + if ($chain.ChainPolicy | Get-Member -Name DisableCertificateDownloads) { + $chain.ChainPolicy.DisableCertificateDownloads = $true + } [void]$chain.Build($Cert) $statuses = @($chain.ChainStatus) + @($chain.ChainElements | ForEach-Object { $_.ChainElementStatus }) return [bool]($statuses | Where-Object { $_.Status.HasFlag($explicitDistrust) }) diff --git a/.github/workflows/runner-trust-probe.yml b/.github/workflows/runner-trust-probe.yml index 8b46e6e0f67..085633bf381 100644 --- a/.github/workflows/runner-trust-probe.yml +++ b/.github/workflows/runner-trust-probe.yml @@ -134,7 +134,7 @@ jobs: - name: Probe Windows OS trust store if: ${{ runner.os == 'Windows' }} - shell: pwsh + shell: powershell run: ./ci/tools/runner_trust_probe.ps1 - name: Probe Node 20 action runtime (informational) diff --git a/ci/tools/runner_trust_probe.ps1 b/ci/tools/runner_trust_probe.ps1 index 9b389226ca5..aaa3ad355f4 100644 --- a/ci/tools/runner_trust_probe.ps1 +++ b/ci/tools/runner_trust_probe.ps1 @@ -4,6 +4,7 @@ # # runner_trust_probe.ps1 -- probe the Windows certificate store on a CI runner # for the compromised eMudhra intermediates and their Sectigo cross-signs. +# Compatible with Windows PowerShell 5.1 (`shell: powershell`) and PowerShell 7+. $ErrorActionPreference = 'Stop' @@ -21,7 +22,8 @@ $SummaryFile = $env:GITHUB_STEP_SUMMARY function Append-Summary([string]$Line) { if ($SummaryFile) { - Add-Content -Path $SummaryFile -Value $Line -Encoding utf8 + # Windows PowerShell 5.1's `-Encoding utf8` emits a BOM; write BOM-less UTF-8 explicitly. + [System.IO.File]::AppendAllText($SummaryFile, "$Line`n", [System.Text.UTF8Encoding]::new($false)) } } @@ -40,9 +42,9 @@ Append-Summary "| --- | --- | --- |" # 1. Root anchor state (informational). Microsoft's fix blocks the # intermediates and leaves the root in place, so this is not a pass criterion. $rootCerts = @(Get-ChildItem -Path 'Cert:\LocalMachine\Root' -ErrorAction Stop) -$inRoot = [bool]($rootCerts | Where-Object { $_.GetCertHashString('SHA256') -eq $EmSignG3Hex }) +$inRoot = [bool]($rootCerts | Where-Object { (Get-CertificateSha256 $_) -eq $EmSignG3Hex }) $inDisallowed = [bool](Get-ChildItem -Path 'Cert:\LocalMachine\Disallowed' -ErrorAction SilentlyContinue | - Where-Object { $_.GetCertHashString('SHA256') -eq $EmSignG3Hex }) + Where-Object { (Get-CertificateSha256 $_) -eq $EmSignG3Hex }) Record-Check 'emSign ECC Root CA - G3 store state' 'INFO' "LocalMachine\Root=$inRoot ($($rootCerts.Count) certs), LocalMachine\Disallowed=$inDisallowed" # 2. All compromised intermediates and cross-signs explicitly distrusted (gating) @@ -59,23 +61,30 @@ if ($notDistrusted.Count -eq 0) { $GatingFailures++ } -# 3. OS-store client (Invoke-WebRequest) rejects each test endpoint (only a successful connection fails the job) +# 3. OS-store client (Invoke-WebRequest) rejects each test endpoint (only a successful connection fails the job). +# -SkipHttpErrorCheck is PowerShell 7+ only, so HTTP error statuses are caught below instead: +# any response at all means the TLS handshake (and thus the certificate chain) succeeded. $progTmp = $ProgressPreference $ProgressPreference = 'SilentlyContinue' try { foreach ($endpoint in $TestEndpoints) { $checkName = "Invoke-WebRequest rejects ``$endpoint``" try { - $res = Invoke-WebRequest -Uri $endpoint -UseBasicParsing -SkipHttpErrorCheck -TimeoutSec 15 + $res = Invoke-WebRequest -Uri $endpoint -UseBasicParsing -TimeoutSec 15 Record-Check $checkName 'FAIL' "Connection succeeded with HTTP $($res.StatusCode) (certificate was trusted)" $GatingFailures++ } catch { + if ($null -ne $_.Exception.Response) { + Record-Check $checkName 'FAIL' "Connection succeeded with HTTP $([int]$_.Exception.Response.StatusCode) (certificate was trusted)" + $GatingFailures++ + continue + } $certRejected = $false $hostnameMismatchOnly = $false $ex = $_.Exception while ($null -ne $ex) { if ( - ($ex -is [System.Security.Authentication.AuthenticationException] -and $ex.Message -match 'certificate chain|UntrustedRoot|RemoteCertificateChainErrors') -or + ($ex -is [System.Security.Authentication.AuthenticationException] -and $ex.Message -match 'certificate chain|UntrustedRoot|RemoteCertificateChainErrors|remote certificate is invalid') -or ($ex -is [System.Net.WebException] -and $ex.Status -eq [System.Net.WebExceptionStatus]::TrustFailure) ) { $certRejected = $true From aa052a7b1c11c134c328b0ada683de27ac96cce2 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 09:07:31 -0800 Subject: [PATCH 2/3] CI: enumerate ConvertFrom-Json arrays on Windows PowerShell 5.1 --- .../upgrade-ca-certificates/emudhra_intermediates.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 b/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 index a505ed8af63..c9c2f8c2bab 100644 --- a/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 +++ b/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 @@ -29,7 +29,10 @@ function Get-CertificateSha256([System.Security.Cryptography.X509Certificates.X5 # Returns the certificates as X509Certificate2 objects; callers dispose them. # Works on both Windows PowerShell 5.1 and PowerShell 7+. function Get-EmudhraIntermediates { - $entries = @(Get-Content -LiteralPath $EmudhraIntermediatesPath -Raw | ConvertFrom-Json) + # Windows PowerShell 5.1's ConvertFrom-Json emits a JSON array as a single + # object; piping it through ForEach-Object enumerates it on both versions. + $parsed = Get-Content -LiteralPath $EmudhraIntermediatesPath -Raw | ConvertFrom-Json + $entries = @($parsed | ForEach-Object { $_ }) if ($entries.Count -ne $EmudhraIntermediatesCount) { throw "Expected $EmudhraIntermediatesCount certificates in $EmudhraIntermediatesPath, found $($entries.Count)." } From deb1b4a407ef71aaa60ff910dd52805ad4f3f572 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 3 Nov 2026 09:35:31 -0800 Subject: [PATCH 3/3] CI: drop the broad 'remote certificate is invalid' match from the trust probe --- ci/tools/runner_trust_probe.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/tools/runner_trust_probe.ps1 b/ci/tools/runner_trust_probe.ps1 index aaa3ad355f4..5955281734f 100644 --- a/ci/tools/runner_trust_probe.ps1 +++ b/ci/tools/runner_trust_probe.ps1 @@ -84,7 +84,7 @@ try { $ex = $_.Exception while ($null -ne $ex) { if ( - ($ex -is [System.Security.Authentication.AuthenticationException] -and $ex.Message -match 'certificate chain|UntrustedRoot|RemoteCertificateChainErrors|remote certificate is invalid') -or + ($ex -is [System.Security.Authentication.AuthenticationException] -and $ex.Message -match 'certificate chain|UntrustedRoot|RemoteCertificateChainErrors') -or ($ex -is [System.Net.WebException] -and $ex.Status -eq [System.Net.WebExceptionStatus]::TrustFailure) ) { $certRejected = $true