Skip to content

Commit 1c25840

Browse files
committed
ci: test the Disallowed store workaround and log TLS chains in the trust probe
The previous experiment showed EnableDisallowedCertAutoUpdate=1 makes chain building distrust all 16 certificates while Invoke-WebRequest still trusts the test endpoints. Extend the experiment to narrow that down: - Log the chain .NET builds for each TLS connection (SslStream validation callback; policy errors, chain status and per-element status flags), at baseline and after each experiment step. - Before the policy changes, add the 16 certificates to LocalMachine\Disallowed as the upgrade-ca-certificates action does, measure, then remove them again and measure. - Remove DisableRootAutoUpdate when chain building or TLS still trusts the certificates after EnableDisallowedCertAutoUpdate=1, not only when the explicit-distrust count is short. Every step restarts CryptSvc and measures in fresh pwsh processes.
1 parent 1d23828 commit 1c25840

1 file changed

Lines changed: 121 additions & 26 deletions

File tree

‎ci/tools/runner_trust_probe.ps1‎

Lines changed: 121 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
205257
function 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.
292380
Invoke-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.
298390
if ($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

Comments
 (0)