feat(bootstrap): verify the publisher of the extracted agent binaries before --install (D-227)

The SHA-256 check proves the ZIP is the one version.json names, but that
manifest lives in the same container as the ZIP: it cannot outlive a
compromise of the download host. The expected publisher travels inside this
script, which the loader verified before running it, so the chain from the
file an administrator assigns to the process that starts is publisher-anchored
end to end.

Install-AutopilotMonitor.ps1 v2.4 -> v2.5:
- Get-UnverifiedAgentBinaries checks every AutopilotMonitor.*.exe/.dll in the
  extracted payload for status Valid and the same subject pin the loader
  carries; the gate runs immediately before --install and throws on any
  rejection, exactly like the hash check.
- Third-party assemblies keep their own publisher's signature and are not
  checked: System.Management.Automation.dll ships unsigned, so a gate over all
  files would refuse the live payload.
- An extracted folder with no binary of ours is a rejection, not a pass.
- The SHA-256 manifest check is unchanged and still runs first: it proves the
  bytes, the signature proves the author.

Verified against the live payload 2.0.1454 (all five of our PEs Valid and
glueckkanja AG, gate passes); appending one byte to AutopilotMonitor.Shared.dll
makes it reject. Pester 55/55.

The portal privacy and about pages carried the pre-signing wording and are
corrected in the same change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-08 23:11:16 +02:00
parent 78bfad0ec6
commit e86f7efed3
5 changed files with 188 additions and 7 deletions
@@ -14,6 +14,22 @@
BeforeAll {
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
function New-FakeSignature {
param([string]$Status, [string]$Subject)
$cert = if ($Subject) {
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
} else { $null }
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
}
function New-FakePayload {
param([string[]]$Files)
$dir = Join-Path $TestDrive ([guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $dir -Force | Out-Null
foreach ($f in $Files) { Set-Content -Path (Join-Path $dir $f) -Value 'x' }
return $dir
}
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
$p = Join-Path $TestDrive $Name
@@ -305,3 +321,93 @@ Describe 'logging purity' {
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
}
}
Describe 'Get-UnverifiedAgentBinaries' {
# The publisher gate on the extracted payload. What matters is the DECISION: which
# payload is accepted, which is refused, and which files are looked at at all.
BeforeEach {
Mock Write-Log { }
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
}
It 'accepts a payload where every binary of ours is validly signed by us' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
}
It 'checks the binaries we author and leaves third-party assemblies alone' {
$dir = New-FakePayload -Files @(
'AutopilotMonitor.Agent.exe',
'AutopilotMonitor.Agent.exe.config',
'AutopilotMonitor.Shared.dll',
'Newtonsoft.Json.dll',
'System.Management.Automation.dll'
)
$script:checked = @()
Mock Get-AuthenticodeSignature {
$script:checked += (Split-Path $FilePath -Leaf)
New-FakeSignature -Status 'Valid' -Subject $script:ours
}
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
}
It 'refuses an unsigned agent executable' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
}
It 'refuses a tampered binary (hash mismatch)' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
}
It 'refuses a signature whose chain does not validate' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
}
It 'refuses a validly signed binary from a different publisher' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
}
It 'refuses the whole payload when a single DLL of ours is swapped' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Mock Get-AuthenticodeSignature {
if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') {
New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US'
} else {
New-FakeSignature -Status 'Valid' -Subject $script:ours
}
}
$rejected = Get-UnverifiedAgentBinaries -Path $dir
$rejected.Count | Should -Be 1
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*'
}
It 'refuses when the signature check itself throws' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { throw 'access denied' }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
}
It 'refuses an empty payload instead of passing it' {
$dir = New-FakePayload -Files @('Newtonsoft.Json.dll')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
}
}
Describe 'publisher pin' {
It 'pins the expected publisher by subject, not by thumbprint' {
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
}
}
+78 -3
View File
@@ -12,8 +12,9 @@
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
5. Verifies the Authenticode signature and publisher of every binary we author
6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
7. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
@@ -76,6 +77,11 @@
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
payload must carry a valid Authenticode signature from the
expected publisher before --install runs. The SHA-256 manifest
check stays and is unchanged; it proves the bytes, the signature
proves the author.
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
(Windows365 registry key AND CloudManagedDesktopExtension
service) now triggers the guard 2+3 relax alongside OOBE
@@ -128,7 +134,12 @@ param(
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.4"
$ScriptVersion = "2.5"
# The publisher every binary we author must carry. A -like pattern on the subject, NOT a
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
# the loader pins for this script.
$ExpectedPublisher = "*O=glueckkanja AG*"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -329,6 +340,56 @@ function Get-BootstrapDecision {
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# Returns the list of reasons why the extracted payload is not ours; an empty result means
# every binary we author carries a valid Authenticode signature from the expected publisher.
# Third-party assemblies keep their own publisher's signature and are not checked here.
# Any error is a rejection: an unreadable signature is an unverified binary.
function Get-UnverifiedAgentBinaries {
param([string]$Path)
$rejected = @()
$ourBinaries = @(
Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Extension -in @('.exe', '.dll') }
)
# An empty set is a rejection, not a pass: it means the extraction produced nothing we
# recognise, and the alternative would be a gate that silently approves an empty folder.
# The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to
# a bare string, and the caller reads .Count and indexes into it.
if ($ourBinaries.Count -eq 0) {
return , @("no AutopilotMonitor binaries found in $Path")
}
foreach ($binary in $ourBinaries) {
try {
$sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop
}
catch {
$rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))"
continue
}
if ($null -eq $sig) {
$rejected += "$($binary.Name): signature check returned nothing"
continue
}
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<unsigned>' }
if ($sig.Status -ne 'Valid') {
$rejected += "$($binary.Name): signature status $($sig.Status)"
continue
}
if ($subject -notlike $ExpectedPublisher) {
$rejected += "$($binary.Name): unexpected publisher $subject"
continue
}
}
return , $rejected
}
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
# at the bottom); never on dot-source.
function Invoke-Bootstrap {
@@ -445,6 +506,20 @@ function Invoke-Bootstrap {
}
}
# Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
# of the download host. The expected publisher travels inside THIS script, which the
# loader verified before running it, so the chain from the file an administrator
# assigns to the process that starts here stays publisher-anchored end to end.
# Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the
# risk being removed.
Write-Log "Verifying Authenticode signatures of the agent binaries..."
$unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath
if ($unverified.Count -gt 0) {
throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher."
}
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE