diff --git a/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 b/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 index 0324c5df448..c9c2f8c2bab 100644 --- a/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 +++ b/.github/actions/upgrade-ca-certificates/emudhra_intermediates.ps1 @@ -14,15 +14,33 @@ $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) + # 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)." } 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 +58,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..5955281734f 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,17 +61,24 @@ 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