77
88$ErrorActionPreference = ' Stop'
99
10- . (Join-Path $PSScriptRoot ' ..\..\.github\actions\upgrade-ca-certificates\emudhra_intermediates.ps1' )
10+ $EmudhraHelperPath = Join-Path $PSScriptRoot ' ..\..\.github\actions\upgrade-ca-certificates\emudhra_intermediates.ps1'
11+ . $EmudhraHelperPath
1112
1213$EmSignG3Hex = ' 86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B'
1314# One host per path: via emSign ECC Root CA - G3, and via the Sectigo
@@ -95,6 +96,74 @@ function Record-CertutilCtl([string]$Name, [string[]]$CertutilArgs) {
9596 Record- Check $Name ' INFO' $details
9697}
9798
99+ # Asks certutil whether the local Disallowed CTL lists each compromised
100+ # certificate's key. certutil prints "Certificate match found" and exits 0 on a
101+ # match, and exits 0x80094005 when the CTL has no entry for the certificate.
102+ function Record-CtlMatch ([string ]$Name ) {
103+ $noMatchExitCode = [int ]0x80094005
104+ $certs = @ (Get-EmudhraIntermediates )
105+ $workDir = Join-Path ([System.IO.Path ]::GetTempPath()) " emudhra-ctl-match-$PID "
106+ New-Item - ItemType Directory - Path $workDir - Force | Out-Null
107+ try {
108+ $matched = @ (); $notMatched = @ (); $unclear = @ ()
109+ for ($i = 0 ; $i -lt $certs.Count ; $i ++ ) {
110+ $certFile = Join-Path $workDir " cert$i .crt"
111+ [System.IO.File ]::WriteAllBytes($certFile , $certs [$i ].RawData)
112+ $output = @ (& certutil.exe - verifyCTL Disallowed $workDir $certFile 2>&1 | ForEach-Object { " $_ " })
113+ $exitCode = $LASTEXITCODE
114+ if ($exitCode -eq 0 -and ($output -match ' match found' )) {
115+ $matched += $certs [$i ].EmudhraLabel
116+ } elseif ($exitCode -eq $noMatchExitCode ) {
117+ $notMatched += $certs [$i ].EmudhraLabel
118+ } else {
119+ $unclear += " $ ( $certs [$i ].EmudhraLabel) (exit $exitCode )"
120+ if ($unclear.Count -eq 1 ) {
121+ Write-Host " ::group::certutil -verifyCTL Disallowed for $ ( $certs [$i ].EmudhraLabel) (exit $exitCode )"
122+ $output | ForEach-Object { Write-Host $_ }
123+ Write-Host ' ::endgroup::'
124+ }
125+ }
126+ }
127+ $details = " $ ( $matched.Count ) of $ ( $certs.Count ) matched"
128+ if ($notMatched.Count -gt 0 ) { $details += " ; no match: $ ( $notMatched -join ' ; ' ) " }
129+ if ($unclear.Count -gt 0 ) { $details += " ; unexpected result: $ ( $unclear -join ' ; ' ) " }
130+ Record- Check $Name ' INFO' $details
131+ } finally {
132+ $certs | ForEach-Object { $_.Dispose () }
133+ Remove-Item - LiteralPath $workDir - Recurse - Force
134+ }
135+ }
136+
137+ # Counts the compromised certificates that Windows reports as explicitly
138+ # distrusted, in a fresh pwsh process so no chain state cached by this
139+ # process carries over.
140+ function Get-ExplicitlyDistrustedCountFresh {
141+ $command = " . '$EmudhraHelperPath '; " +
142+ ' $certs = @(Get-EmudhraIntermediates); ' +
143+ ' try { @($certs | Where-Object { Test-ExplicitlyDistrusted $_ }).Count } ' +
144+ ' finally { $certs | ForEach-Object { $_.Dispose() } }'
145+ $output = @ (& (Get-Process - Id $PID ).Path - NoProfile - NonInteractive - Command $command 2>&1 | ForEach-Object { " $_ " })
146+ if ($LASTEXITCODE -ne 0 -or $output.Count -ne 1 -or $output [0 ] -notmatch ' ^\d+$' ) {
147+ throw " Fresh distrust check failed (exit $LASTEXITCODE ): $ ( $output -join ' ' ) "
148+ }
149+ return [int ]$output [0 ]
150+ }
151+
152+ # Applies one CTL policy change, restarts the CryptSvc service that maintains
153+ # the CTLs, and records the explicit-distrust count and certutil matches
154+ # observed afterwards.
155+ function Invoke-PolicyExperimentStep ([string ]$Label , [scriptblock ]$Change ) {
156+ & $Change
157+ Restart-Service - Name CryptSvc - Force
158+ $policy = " DisableRootAutoUpdate=$ ( Get-RegistryValueText $CtlPolicyPath ' DisableRootAutoUpdate' ) , " +
159+ " EnableDisallowedCertAutoUpdate=$ ( Get-RegistryValueText $CtlPolicyPath ' EnableDisallowedCertAutoUpdate' ) "
160+ $count = Get-ExplicitlyDistrustedCountFresh
161+ # Out-Host keeps the recorded rows out of this function's return value.
162+ Record- Check " Experiment: $Label " ' INFO' " $count of $EmudhraIntermediatesCount explicitly distrusted after CryptSvc restart ($policy )" | Out-Host
163+ Record- CtlMatch " Experiment: $Label , CTL matches" | Out-Host
164+ return $count
165+ }
166+
98167$GatingFailures = 0
99168
100169Append- Summary " ### $ProbeLabel "
@@ -116,6 +185,10 @@ Record-Check 'emSign ECC Root CA - G3 store state' 'INFO' "LocalMachine\Root=$in
116185# 2. CTL update state (informational). Microsoft delivers its eMudhra block
117186# through the Disallowed CTL, which reaches a machine only through automatic
118187# CTL updates or an explicit import.
188+ Invoke-Diagnostic ' Windows version' {
189+ $cv = Get-ItemProperty - LiteralPath ' HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
190+ Record- Check ' Windows version' ' INFO' " $ ( $cv.ProductName ) $ ( $cv.DisplayVersion ) , build $ ( $cv.CurrentBuild ) .$ ( $cv.UBR ) , runner environment $env: RUNNER_ENVIRONMENT "
191+ }
119192Invoke-Diagnostic ' CTL auto-update policy' {
120193 Record- Check ' CTL auto-update policy' ' INFO' (" DisableRootAutoUpdate=$ ( Get-RegistryValueText $CtlPolicyPath ' DisableRootAutoUpdate' ) , " +
121194 " EnableDisallowedCertAutoUpdate=$ ( Get-RegistryValueText $CtlPolicyPath ' EnableDisallowedCertAutoUpdate' ) " )
@@ -126,6 +199,7 @@ Invoke-Diagnostic 'CTL auto-update registry state' {
126199 " DisallowedCertEncodedCtl=$ ( Get-RegistryValueText $CtlAutoUpdatePath ' DisallowedCertEncodedCtl' ) " )
127200}
128201Invoke-Diagnostic ' Local Disallowed CTL' { Record- CertutilCtl ' Local Disallowed CTL' @ (' -verifyCTL' , ' Disallowed' ) }
202+ Invoke-Diagnostic ' Local Disallowed CTL matches' { Record- CtlMatch ' Local Disallowed CTL matches' }
129203
130204# 3. All compromised intermediates and cross-signs explicitly distrusted (gating)
131205$certs = @ (Get-EmudhraIntermediates )
@@ -188,6 +262,27 @@ try {
188262# what they observed.
189263Invoke-Diagnostic ' Windows Update Disallowed CTL' { Record- CertutilCtl ' Windows Update Disallowed CTL' @ (' -f' , ' -verifyCTL' , ' DisallowedWU' ) }
190264
265+ # 6. CTL policy experiment (informational). Tests whether the root auto-update
266+ # policy is why a Disallowed CTL that lists the compromised keys is not applied.
267+ # It changes machine policy and restarts CryptSvc, so it runs last and only on
268+ # ephemeral GitHub-hosted runners where DisableRootAutoUpdate=1.
269+ if ($env: RUNNER_ENVIRONMENT -ne ' github-hosted' ) {
270+ Record- Check ' Experiment: CTL policy' ' INFO' " Skipped: runner environment is '$env: RUNNER_ENVIRONMENT ', not github-hosted"
271+ } elseif ((Get-RegistryValueText $CtlPolicyPath ' DisableRootAutoUpdate' ) -ne ' 1' ) {
272+ Record- Check ' Experiment: CTL policy' ' INFO' ' Skipped: DisableRootAutoUpdate is not 1'
273+ } else {
274+ Invoke-Diagnostic ' Experiment: CTL policy' {
275+ $count = Invoke-PolicyExperimentStep ' EnableDisallowedCertAutoUpdate=1' {
276+ Set-ItemProperty - LiteralPath $CtlPolicyPath - Name ' EnableDisallowedCertAutoUpdate' - Type DWord - Value 1
277+ }
278+ if ($count -lt $EmudhraIntermediatesCount ) {
279+ [void ](Invoke-PolicyExperimentStep ' DisableRootAutoUpdate removed' {
280+ Remove-ItemProperty - LiteralPath $CtlPolicyPath - Name ' DisableRootAutoUpdate'
281+ })
282+ }
283+ }
284+ }
285+
191286Append- Summary " "
192287
193288if ($GatingFailures -gt 0 ) {
0 commit comments