@@ -149,23 +149,77 @@ function Get-ExplicitlyDistrustedCountFresh {
149149 return [int ]$output [0 ]
150150}
151151
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
152+ # Restarts the CryptSvc service that maintains the CTLs after an experiment
153+ # change, then records the explicit-distrust count, certutil matches, TLS
154+ # outcomes and TLS chains observed afterwards. Returns the count and whether
155+ # any TLS connection still trusted its chain.
156+ function Measure-ExperimentState ([ string ] $Label ) {
157157 Restart-Service - Name CryptSvc - Force
158158 $policy = " DisableRootAutoUpdate=$ ( Get-RegistryValueText $CtlPolicyPath ' DisableRootAutoUpdate' ) , " +
159159 " EnableDisallowedCertAutoUpdate=$ ( Get-RegistryValueText $CtlPolicyPath ' EnableDisallowedCertAutoUpdate' ) "
160160 $count = Get-ExplicitlyDistrustedCountFresh
161161 # Out-Host keeps the recorded rows out of this function's return value.
162162 Record- Check " Experiment: $Label " ' INFO' " $count of $EmudhraIntermediatesCount explicitly distrusted after CryptSvc restart ($policy )" | Out-Host
163163 Record- CtlMatch " Experiment: $Label , CTL matches" | Out-Host
164+ $tlsTrusted = $false
164165 foreach ($endpoint in $TestEndpoints ) {
165166 $outcome = Get-TlsOutcomeFresh $endpoint
167+ if ($outcome.Kind -in ' Trusted' , ' NameMismatchOnly' ) {
168+ $tlsTrusted = $true
169+ }
166170 Record- Check " Experiment: $Label , Invoke-WebRequest `` $endpoint `` " ' INFO' " $ ( $outcome.Kind ) : $ ( $outcome.Detail ) " | Out-Host
171+ $chainCheck = " Experiment: $Label , TLS chain `` $endpoint `` "
172+ Invoke-Diagnostic $chainCheck { Record- Check $chainCheck ' INFO' (Get-TlsChainFresh $endpoint ) } | Out-Host
173+ }
174+ return [pscustomobject ]@ { Count = $count ; TlsTrusted = $tlsTrusted }
175+ }
176+
177+ # Runs a function defined in this script in a fresh pwsh process, so no TLS or
178+ # chain state cached by this process carries over, and returns its result.
179+ function Invoke-FunctionFresh ([string ]$Name , [string ]$Argument ) {
180+ $command = " function $Name { $ ( (Get-Item - LiteralPath " function:$Name " ).ScriptBlock) }; " +
181+ " $Name '$Argument ' | ConvertTo-Json -Compress"
182+ $encoded = [Convert ]::ToBase64String([System.Text.Encoding ]::Unicode.GetBytes($command ))
183+ $output = @ (& (Get-Process - Id $PID ).Path - NoProfile - NonInteractive - EncodedCommand $encoded 2>&1 | ForEach-Object { " $_ " })
184+ if ($LASTEXITCODE -ne 0 -or $output.Count -ne 1 ) {
185+ throw " Fresh $Name failed (exit $LASTEXITCODE ): $ ( $output -join ' ' ) "
186+ }
187+ return ($output [0 ] | ConvertFrom-Json )
188+ }
189+
190+ # Adds the compromised certificates missing from LocalMachine\Disallowed, as
191+ # the upgrade-ca-certificates action does, and returns their thumbprints.
192+ function Add-DisallowedStoreEntries {
193+ $certs = @ (Get-EmudhraIntermediates )
194+ $store = [System.Security.Cryptography.X509Certificates.X509Store ]::new(' Disallowed' , ' LocalMachine' )
195+ try {
196+ $store.Open (' ReadWrite' )
197+ $added = @ ()
198+ foreach ($cert in $certs ) {
199+ if ($store.Certificates.Find (' FindByThumbprint' , $cert.Thumbprint , $false ).Count -eq 0 ) {
200+ $store.Add ($cert )
201+ $added += $cert.Thumbprint
202+ }
203+ }
204+ return $added
205+ } finally {
206+ $store.Dispose ()
207+ $certs | ForEach-Object { $_.Dispose () }
208+ }
209+ }
210+
211+ function Remove-DisallowedStoreEntries ([string []]$Thumbprints ) {
212+ $store = [System.Security.Cryptography.X509Certificates.X509Store ]::new(' Disallowed' , ' LocalMachine' )
213+ try {
214+ $store.Open (' ReadWrite' )
215+ foreach ($thumbprint in $Thumbprints ) {
216+ foreach ($cert in $store.Certificates.Find (' FindByThumbprint' , $thumbprint , $false )) {
217+ $store.Remove ($cert )
218+ }
219+ }
220+ } finally {
221+ $store.Dispose ()
167222 }
168- return $count
169223}
170224
171225# Connects to an endpoint with Invoke-WebRequest and classifies the result as
@@ -200,17 +254,48 @@ function Get-TlsOutcome {
200254 }
201255}
202256
203- # Runs Get-TlsOutcome in a fresh pwsh process so no TLS or chain state cached
204- # by this process carries over.
205257function Get-TlsOutcomeFresh ([string ]$Endpoint ) {
206- $command = " function Get-TlsOutcome { ${function: Get-TlsOutcome} }; " +
207- " `$ o = Get-TlsOutcome '$Endpoint '; `"`$ (`$ o.Kind)`` t`$ (`$ o.Detail)`" "
208- $encoded = [Convert ]::ToBase64String([System.Text.Encoding ]::Unicode.GetBytes($command ))
209- $output = @ (& (Get-Process - Id $PID ).Path - NoProfile - NonInteractive - EncodedCommand $encoded 2>&1 | ForEach-Object { " $_ " })
210- if ($LASTEXITCODE -ne 0 -or $output.Count -ne 1 -or $output [0 ] -notmatch " ^(\w+)`t (.*)$" ) {
211- throw " Fresh TLS check failed (exit $LASTEXITCODE ): $ ( $output -join ' ' ) "
258+ return Invoke-FunctionFresh ' Get-TlsOutcome' $Endpoint
259+ }
260+
261+ # Connects to an endpoint with SslStream and describes the chain .NET built
262+ # for the server certificate: the policy errors, the chain status and each
263+ # element with its status flags. The validation callback accepts every
264+ # certificate, so this observes the chain without gating on it.
265+ function Get-TlsChain {
266+ param ([string ]$Endpoint )
267+ $uri = [Uri ]$Endpoint
268+ $lines = [System.Collections.Generic.List [string ]]::new()
269+ $callback = {
270+ param ($source , $certificate , $chain , $errors )
271+ $flagsOf = { param ($statuses ) $f = @ ($statuses | ForEach-Object { $_.Status }) -join ' ,' ; if ($f ) { $f } else { ' NoError' } }
272+ $lines.Add (" policy errors $errors ; chain status $ ( & $flagsOf $chain.ChainStatus ) " )
273+ foreach ($element in $chain.ChainElements ) {
274+ $cert = $element.Certificate
275+ $name = $cert.GetNameInfo (' SimpleName' , $false )
276+ if ($cert.Subject -ne $cert.Issuer ) {
277+ $name += " (issued by $ ( $cert.GetNameInfo (' SimpleName' , $true )) )"
278+ }
279+ $lines.Add (" $name [$ ( & $flagsOf $element.ChainElementStatus ) ]" )
280+ }
281+ return $true
282+ }.GetNewClosure()
283+ $tcp = [System.Net.Sockets.TcpClient ]::new($uri.Host , $uri.Port )
284+ try {
285+ $ssl = [System.Net.Security.SslStream ]::new($tcp.GetStream (), $false , $callback )
286+ try {
287+ $ssl.AuthenticateAsClient ($uri.Host )
288+ } finally {
289+ $ssl.Dispose ()
290+ }
291+ } finally {
292+ $tcp.Dispose ()
212293 }
213- return [pscustomobject ]@ { Kind = $Matches [1 ]; Detail = $Matches [2 ] }
294+ return ($lines -join ' -> ' )
295+ }
296+
297+ function Get-TlsChainFresh ([string ]$Endpoint ) {
298+ return Invoke-FunctionFresh ' Get-TlsChain' $Endpoint
214299}
215300
216301$GatingFailures = 0
@@ -285,29 +370,39 @@ foreach ($endpoint in $TestEndpoints) {
285370 }
286371 }
287372}
373+ foreach ($endpoint in $TestEndpoints ) {
374+ Invoke-Diagnostic " TLS chain `` $endpoint `` " { Record- Check " TLS chain `` $endpoint `` " ' INFO' (Get-TlsChain $endpoint ) }
375+ }
288376
289377# 5. Disallowed CTL currently published on Windows Update (informational). -f
290378# forces a download; this runs after the gating checks so it cannot change
291379# what they observed.
292380Invoke-Diagnostic ' Windows Update Disallowed CTL' { Record- CertutilCtl ' Windows Update Disallowed CTL' @ (' -f' , ' -verifyCTL' , ' DisallowedWU' ) }
293381
294- # 6. CTL policy experiment (informational). Tests whether the root auto-update
295- # policy is why a Disallowed CTL that lists the compromised keys is not applied.
296- # It changes machine policy and restarts CryptSvc, so it runs last and only on
297- # ephemeral GitHub-hosted runners where DisableRootAutoUpdate=1.
382+ # 6. Trust experiment (informational). First tests the upgrade-ca-certificates
383+ # workaround (LocalMachine\Disallowed entries) under the image's policy, then
384+ # removes those entries again. Then tests whether the root auto-update policy
385+ # is why a Disallowed CTL that lists the compromised keys is not applied: set
386+ # EnableDisallowedCertAutoUpdate=1, and if chain building or TLS still trusts
387+ # the certificates, also remove DisableRootAutoUpdate. It changes machine
388+ # trust state and restarts CryptSvc, so it runs last and only on ephemeral
389+ # GitHub-hosted runners where DisableRootAutoUpdate=1.
298390if ($env: RUNNER_ENVIRONMENT -ne ' github-hosted' ) {
299391 Record- Check ' Experiment: CTL policy' ' INFO' " Skipped: runner environment is '$env: RUNNER_ENVIRONMENT ', not github-hosted"
300392} elseif ((Get-RegistryValueText $CtlPolicyPath ' DisableRootAutoUpdate' ) -ne ' 1' ) {
301393 Record- Check ' Experiment: CTL policy' ' INFO' ' Skipped: DisableRootAutoUpdate is not 1'
302394} else {
303395 Invoke-Diagnostic ' Experiment: CTL policy' {
304- $count = Invoke-PolicyExperimentStep ' EnableDisallowedCertAutoUpdate=1' {
305- Set-ItemProperty - LiteralPath $CtlPolicyPath - Name ' EnableDisallowedCertAutoUpdate' - Type DWord - Value 1
306- }
307- if ($count -lt $EmudhraIntermediatesCount ) {
308- [void ](Invoke-PolicyExperimentStep ' DisableRootAutoUpdate removed' {
309- Remove-ItemProperty - LiteralPath $CtlPolicyPath - Name ' DisableRootAutoUpdate'
310- })
396+ $added = @ (Add-DisallowedStoreEntries )
397+ [void ](Measure-ExperimentState " LocalMachine\Disallowed import ($ ( $added.Count ) added)" )
398+ Remove-DisallowedStoreEntries $added
399+ [void ](Measure-ExperimentState ' LocalMachine\Disallowed import removed' )
400+
401+ Set-ItemProperty - LiteralPath $CtlPolicyPath - Name ' EnableDisallowedCertAutoUpdate' - Type DWord - Value 1
402+ $state = Measure-ExperimentState ' EnableDisallowedCertAutoUpdate=1'
403+ if ($state.Count -lt $EmudhraIntermediatesCount -or $state.TlsTrusted ) {
404+ Remove-ItemProperty - LiteralPath $CtlPolicyPath - Name ' DisableRootAutoUpdate'
405+ [void ](Measure-ExperimentState ' DisableRootAutoUpdate removed' )
311406 }
312407 }
313408}
0 commit comments