#
.SYNOPSIS
Self-sign PowerToys sparse MSIX shell-extension packages with a machine-trusted TEST certificate so
they register on unsigned CI builds, letting UI tests exercise the real end-user workflow (the modern
Windows 11 context menu) instead of signing-free fallbacks.
.DESCRIPTION
CI PR-validation builds are produced with codeSign:false, so every sparse-MSIX shell extension
(ImageResizer / PowerRename / FileLocksmith / NewPlus context menus, the CmdPal PowerToysSparse
package) ships UNSIGNED. PowerToys registers these at module-enable time via
PackageManager.AddPackageByUriAsync, which requires a signature that chains to a trusted root, so on
CI they fail with 0x800B0100 (TRUST_E_NOSIGNATURE) and the modern context menu never appears.
Run this on the test agent AFTER the build is downloaded/installed and BEFORE PowerToys enables the
module. For every package it:
1. reads the manifest Publisher subject,
2. ensures a self-signed code-signing certificate with that exact subject exists,
3. force-trusts that certificate (LocalMachine + CurrentUser Root and TrustedPeople), and
4. signs the package with signtool.
This asserts NO security -- it is a test-only trust anchor for validating normal app usage. It only
signs packages that are not already validly signed (unless -Force), so real framework packages
(VCLibs, WindowsAppSDK) are left untouched.
.PARAMETER PackageRoot
One or more folders to search recursively for sparse packages. Missing folders are skipped, so you
can pass both the run-in-place build tree and the installed location:
-PackageRoot "$(Pipeline.Workspace)\build-x64-Release", "$env:ProgramFiles\PowerToys"
.PARAMETER Include
Filename patterns to sign. Defaults to *.msix and *.appx.
.PARAMETER RequiredPackage
Filename patterns that must be found and end with a Valid signature. Missing, unsigned, or untrusted
matches make the script fail after attempting all packages.
.PARAMETER RequiredAuthenticodeFile
Filename patterns for unpackaged companion binaries that must be found and signed with the same
machine-trusted TEST identity. This is used for authenticated PowerToys IPC on unsigned CI builds.
.PARAMETER AuthenticodePublisher
Certificate subject used to sign RequiredAuthenticodeFile matches. The default matches the
Microsoft publisher identity required by PowerToys' Release IPC caller authentication.
.PARAMETER CertificateMarkerPath
Optional durable text file that receives each test certificate thumbprint used by this invocation.
CI uses the marker before signing and from an always() cleanup step to remove the trust anchor
and private key, including after an interrupted prior job.
.PARAMETER Force
Re-sign even packages that already carry a valid signature.
.PARAMETER SkipLocalTrust
Sign without importing the test certificate into this machine's trust stores. Use when signing on a
build host and registering the package somewhere else (for example packaging a UI-test payload on the
host and running it in a VM), so the build machine never gains a test trust anchor.
.PARAMETER ExportCertificatePath
Write the public certificate to this path so the machine that registers the package can trust it.
.EXAMPLE
.\signSparsePackages.ps1 -PackageRoot "$env:ProgramFiles\PowerToys" `
-RequiredPackage ImageResizerContextMenuPackage.msix
.EXAMPLE
# Local sideloading into a UI-test VM runtime:
.\signSparsePackages.ps1 -PackageRoot "C:\PowerToysUiTestRun\PowerToys"
.EXAMPLE
# Sign a payload on the host, trust it only inside the VM that will register it:
.\signSparsePackages.ps1 -PackageRoot "X:\payload\product" -SkipLocalTrust `
-ExportCertificatePath "X:\payload\pt-test-signer.cer"
#>
param(
[Parameter(Mandatory = $true)]
[string[]]$PackageRoot,
[Parameter()]
[string[]]$Include = @('*.msix', '*.appx'),
[Parameter()]
[string[]]$RequiredPackage = @(),
[Parameter()]
[string[]]$RequiredAuthenticodeFile = @(),
[Parameter()]
[string]$AuthenticodePublisher = 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US',
[Parameter()]
[string]$CertificateMarkerPath,
[switch]$Force,
[switch]$SkipLocalTrust,
[Parameter()]
[string]$ExportCertificatePath
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$testCertificateFriendlyName = 'PowerToys UI Test Signing'
function Select-SignToolByArch {
param([string[]]$Paths)
$paths = @($Paths | Where-Object { $_ } | Select-Object -Unique)
if (-not $paths) { return $null }
$archPref = @($env:PROCESSOR_ARCHITECTURE, 'x64', 'x86', 'arm64') |
ForEach-Object { $_.ToLower() } | Select-Object -Unique
foreach ($arch in $archPref) {
$match = $paths | Where-Object { $_ -match "\\$arch\\" } | Select-Object -First 1
if ($match) { return $match }
}
return $paths[0]
}
# Locate signtool.exe on the agent: PATH, then any Windows Kits install (all versions/layouts,
# including the App Certification Kit). The pinned, signature-verified NuGet fallback is separate.
function Find-SignTool {
$cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
$found = @()
$kitRoots = @(
"${env:ProgramFiles(x86)}\Windows Kits",
"$env:ProgramFiles\Windows Kits",
"$env:ProgramW6432\Windows Kits"
) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
foreach ($root in $kitRoots) {
# Scope to bin\ and the App Certification Kit so the huge Include\ / Lib\ trees are skipped.
$scopes = Get-ChildItem -Path $root -Directory -Recurse -Depth 1 -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq 'bin' -or $_.Name -eq 'App Certification Kit' } |
Select-Object -ExpandProperty FullName
foreach ($scope in $scopes) {
$found += Get-ChildItem -Path $scope -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
}
}
return Select-SignToolByArch -Paths $found
}
# Last resort when the agent has no Windows SDK: fetch signtool from the public
# Microsoft.Windows.SDK.BuildTools NuGet package. Best-effort.
function Get-SignToolFromNuget {
$nupkg = $null
$archive = $null
try {
[xml]$centralPackages = Get-Content (Join-Path $PSScriptRoot '..\Directory.Packages.props') -Raw
$versionNode = $centralPackages.SelectSingleNode(
"/Project/ItemGroup/PackageVersion[@Include='Microsoft.Windows.SDK.BuildTools']")
$version = if ($versionNode) { $versionNode.GetAttribute('Version') } else { $null }
if ($version -notmatch '^\d+\.\d+\.\d+\.\d+$') {
throw 'Microsoft.Windows.SDK.BuildTools must have a concrete four-part version in Directory.Packages.props.'
}
$dest = Join-Path $env:TEMP "pt-sdk-buildtools-$version"
$nupkg = Join-Path $env:TEMP "sdk-buildtools-$version.nupkg"
$archive = Join-Path $env:TEMP "sdk-buildtools-$version.zip"
$packageFileName = "microsoft.windows.sdk.buildtools.$version.nupkg"
$cachedPackage = @($env:NUGET_PACKAGES, (Join-Path $env:USERPROFILE '.nuget\packages')) |
Where-Object { $_ } |
ForEach-Object { Join-Path $_ "microsoft.windows.sdk.buildtools\$version\$packageFileName" } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
if ($cachedPackage) {
Write-Host "signtool not found on the agent; verifying cached Windows SDK BuildTools $version."
Copy-Item -LiteralPath $cachedPackage -Destination $nupkg -Force
}
else {
Write-Host "signtool not found on the agent; fetching Windows SDK BuildTools $version from NuGet."
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.windows.sdk.buildtools/$version/$packageFileName" -OutFile $nupkg -UseBasicParsing
}
$verificationOutput = @(& dotnet nuget verify $nupkg --all 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "NuGet signature verification failed for Microsoft.Windows.SDK.BuildTools ${version}: $($verificationOutput -join [Environment]::NewLine)"
}
$verificationOutput | ForEach-Object { Write-Host $_ }
Copy-Item $nupkg $archive -Force
Remove-Item $dest -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $archive -DestinationPath $dest -Force
$paths = Get-ChildItem -Path $dest -Recurse -Filter 'signtool.exe' -File -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
return Select-SignToolByArch -Paths $paths
}
catch {
Write-Warning "Could not obtain signtool from NuGet: $($_.Exception.Message)"
return $null
}
finally {
@($nupkg, $archive) | Where-Object { $_ } | ForEach-Object {
Remove-Item -LiteralPath $_ -Force -ErrorAction SilentlyContinue
}
}
}
function Get-VerifiedSignTool {
$path = Find-SignTool
if (-not $path) { $path = Get-SignToolFromNuget }
if (-not $path) {
throw 'signtool.exe not found and could not be fetched from NuGet. Install the Windows SDK.'
}
$signature = Get-AuthenticodeSignature -FilePath $path
if ($signature.Status -ne 'Valid' -or
-not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -notmatch '(^|,\s*)O=Microsoft Corporation(,|$)') {
throw "signtool.exe is not validly signed by Microsoft: $path"
}
return $path
}
# Read straight out of the .msix/.appx (a zip) without extracting it.
function Get-PackagePublisher {
param([string]$PackagePath)
$zip = [System.IO.Compression.ZipFile]::OpenRead($PackagePath)
try {
$entry = $zip.GetEntry('AppxManifest.xml')
if (-not $entry) { return $null }
$reader = New-Object System.IO.StreamReader($entry.Open())
try { $xml = [xml]$reader.ReadToEnd() } finally { $reader.Dispose() }
return $xml.Package.Identity.Publisher
}
finally {
$zip.Dispose()
}
}
function Import-CertTrust {
param(
[string]$CerPath,
[string]$Thumbprint,
[string]$StorePath,
[switch]$Optional
)
if (Get-ChildItem $StorePath -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $Thumbprint }) {
return $true
}
try {
Import-Certificate -FilePath $CerPath -CertStoreLocation $StorePath -ErrorAction Stop | Out-Null
return $true
}
catch {
if ($Optional) {
Write-Warning "Could not import test cert into $StorePath (admin may be required): $($_.Exception.Message)"
return $false
}
throw
}
}
$certCache = @{}
function Get-TrustedSigningCert {
param([string]$Subject)
if ($certCache.ContainsKey($Subject)) { return $certCache[$Subject] }
$cert = Get-ChildItem Cert:\CurrentUser\My |
Where-Object {
$_.Subject -eq $Subject -and
$_.HasPrivateKey -and
$_.FriendlyName -eq $testCertificateFriendlyName
} |
Sort-Object NotAfter -Descending | Select-Object -First 1
if (-not $cert) {
Write-Host "Creating self-signed test certificate for: $Subject"
$cert = New-SelfSignedCertificate -Subject $Subject `
-CertStoreLocation Cert:\CurrentUser\My `
-FriendlyName $testCertificateFriendlyName `
-KeyAlgorithm RSA -KeyLength 2048 `
-Type CodeSigningCert -HashAlgorithm SHA256 `
-NotAfter (Get-Date).AddYears(1)
}
if ($CertificateMarkerPath) {
$markerParent = Split-Path $CertificateMarkerPath -Parent
if ($markerParent -and -not (Test-Path $markerParent)) {
New-Item $markerParent -ItemType Directory -Force | Out-Null
}
$recordedThumbprints = if (Test-Path $CertificateMarkerPath) {
@(Get-Content $CertificateMarkerPath | Where-Object { $_ })
} else {
@()
}
if ($recordedThumbprints -notcontains $cert.Thumbprint) {
Add-Content -Path $CertificateMarkerPath -Value $cert.Thumbprint -Encoding ascii
}
}
# Force-trust so AddPackageByUriAsync accepts the signature. A self-signed cert is its own root,
# so it must live in a Root store (chain) and TrustedPeople (AppX sideload allow-list). Use the
# LocalMachine stores: they import silently and the elevated CI test agent can write them.
# CurrentUser\Root is deliberately NOT used -- importing into the user Root store raises a CryptoAPI
# consent dialog that fails non-interactively ("UI is not allowed in this operation"), even elevated.
$cerPath = Join-Path $env:TEMP ("pt-test-signer-{0}.cer" -f $cert.Thumbprint)
Export-Certificate -Cert $cert -FilePath $cerPath -Force | Out-Null
if ($ExportCertificatePath) {
$exportParent = Split-Path $ExportCertificatePath -Parent
if ($exportParent -and -not (Test-Path $exportParent)) {
New-Item $exportParent -ItemType Directory -Force | Out-Null
}
Copy-Item $cerPath $ExportCertificatePath -Force
Write-Host "Exported public certificate to: $ExportCertificatePath"
}
if ($SkipLocalTrust) {
Write-Host "Skipping local trust for '$Subject'; trust the exported certificate where the package is registered."
$certCache[$Subject] = $cert
return $cert
}
$rootTrusted = Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\LocalMachine\Root' -Optional
Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\LocalMachine\TrustedPeople' -Optional | Out-Null
Import-CertTrust -CerPath $cerPath -Thumbprint $cert.Thumbprint -StorePath 'Cert:\CurrentUser\TrustedPeople' -Optional | Out-Null
if (-not $rootTrusted) {
Write-Warning "Could not establish machine root trust for '$Subject' (run elevated). Signed packages may not register."
}
$certCache[$Subject] = $cert
return $cert
}
$packages = @()
foreach ($root in $PackageRoot) {
if (-not (Test-Path $root)) {
Write-Host "Skipping missing package root: $root"
continue
}
$packages += Get-ChildItem -Path $root -Recurse -File -Include $Include -ErrorAction SilentlyContinue
}
$packages = $packages | Sort-Object FullName -Unique
$requiredAuthenticodeFiles = @()
foreach ($pattern in ($RequiredAuthenticodeFile | Where-Object { $_ } | Select-Object -Unique)) {
$matches = @()
foreach ($root in $PackageRoot) {
if (Test-Path $root) {
$matches += Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
}
}
$matches = @($matches | Sort-Object FullName -Unique)
if ($matches.Count -eq 0) {
throw "Required Authenticode file '$pattern' was not found under: $($PackageRoot -join ', ')."
}
$requiredAuthenticodeFiles += $matches
}
$requiredAuthenticodeFiles = @($requiredAuthenticodeFiles | Sort-Object FullName -Unique)
if (-not $packages -and $RequiredPackage.Count -gt 0) {
throw "No packages found under '$($PackageRoot -join ', ')' while requiring: $($RequiredPackage -join ', ')."
}
if (-not $packages -and $requiredAuthenticodeFiles.Count -eq 0) {
Write-Host "No packages or required Authenticode files found under: $($PackageRoot -join ', ')"
return
}
$requiredPackages = @()
foreach ($pattern in ($RequiredPackage | Where-Object { $_ } | Select-Object -Unique)) {
$matches = @($packages | Where-Object { $_.Name -like $pattern })
if ($matches.Count -eq 0) {
throw "Required sparse package '$pattern' was not found under: $($PackageRoot -join ', ')."
}
$requiredPackages += $matches
}
$requiredPackages = @($requiredPackages | Sort-Object FullName -Unique)
$signed = 0
$signtool = $null
foreach ($pkg in $packages) {
if (-not $Force) {
$existing = Get-AuthenticodeSignature -FilePath $pkg.FullName
if ($existing.Status -eq 'Valid') {
Write-Host "Already validly signed, skipping: $($pkg.Name)"
continue
}
}
$publisher = $null
try { $publisher = Get-PackagePublisher -PackagePath $pkg.FullName } catch { }
if (-not $publisher) {
Write-Host "No manifest publisher, skipping: $($pkg.Name)"
continue
}
if (-not $signtool) {
$signtool = Get-VerifiedSignTool
Write-Host "Using signtool: $signtool"
}
$cert = Get-TrustedSigningCert -Subject $publisher
Write-Host "Signing $($pkg.Name) (Publisher: $publisher)"
& $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $pkg.FullName
if ($LASTEXITCODE -ne 0) {
Write-Warning "signtool failed for $($pkg.Name) (exit $LASTEXITCODE)"
continue
}
$verify = Get-AuthenticodeSignature -FilePath $pkg.FullName
if ($verify.Status -eq 'Valid') {
$signed++
}
else {
Write-Warning "Signature not Valid after signing $($pkg.Name): $($verify.Status)"
}
}
if ($requiredPackages.Count -gt 0) {
$invalidRequiredPackages = @($requiredPackages | Where-Object {
(Get-AuthenticodeSignature -FilePath $_.FullName).Status -ne 'Valid'
})
if ($invalidRequiredPackages.Count -gt 0) {
throw "Required sparse package(s) are not validly signed and trusted: $($invalidRequiredPackages.FullName -join ', ')."
}
Write-Host "Verified required sparse package(s): $($requiredPackages.FullName -join ', ')"
}
if ($requiredAuthenticodeFiles.Count -gt 0) {
if (-not $signtool) {
$signtool = Get-VerifiedSignTool
Write-Host "Using signtool: $signtool"
}
$filesToSign = @($requiredAuthenticodeFiles | Where-Object {
$existing = Get-AuthenticodeSignature -FilePath $_.FullName
$Force -or $existing.Status -ne 'Valid'
})
$testSignedPaths = @{}
$cert = if ($filesToSign.Count -gt 0) {
Get-TrustedSigningCert -Subject $AuthenticodePublisher
} else {
$null
}
foreach ($file in $filesToSign) {
Write-Host "Signing companion binary: $($file.FullName)"
& $signtool sign /fd SHA256 /sha1 $cert.Thumbprint $file.FullName
if ($LASTEXITCODE -ne 0) {
throw "signtool failed for required Authenticode file '$($file.FullName)' (exit $LASTEXITCODE)."
}
$testSignedPaths[$file.FullName] = $true
}
$invalidAuthenticodeFiles = @($requiredAuthenticodeFiles | Where-Object {
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
$signerName = if ($signature.SignerCertificate) {
$signature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)
} else {
$null
}
if ($testSignedPaths.ContainsKey($_.FullName)) {
-not $signature.SignerCertificate -or
$signature.SignerCertificate.Thumbprint -ne $cert.Thumbprint -or
(-not $SkipLocalTrust -and $signature.Status -ne 'Valid')
} else {
-not $signature.SignerCertificate -or
$signerName -ne 'Microsoft Corporation' -or
$signature.Status -ne 'Valid'
}
})
if ($invalidAuthenticodeFiles.Count -gt 0) {
throw "Required Authenticode file(s) are not signed with the trusted test identity: $($invalidAuthenticodeFiles.FullName -join ', ')."
}
Write-Host "Verified required Authenticode file(s): $($requiredAuthenticodeFiles.FullName -join ', ')"
}
Write-Host "Signed $signed package(s) with a trusted test certificate."