CI: sign Windows builds with Azure Artifact Signing #62

Merged
jknapp merged 5 commits from ci/windows-code-signing into main 2026-09-24 02:09:49 +00:00
3 changed files with 82 additions and 36 deletions
Showing only changes of commit 08cd05000c - Show all commits
-1
View File
@@ -682,7 +682,6 @@ jobs:
run: >-
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
-File scripts\windows-verify-signatures.ps1
app\src-tauri\target\release\triple-c.exe
app\src-tauri\target\release\bundle\msi\*.msi
app\src-tauri\target\release\bundle\nsis\*.exe
+11 -5
View File
@@ -857,14 +857,20 @@ merge, dispatch `build-app.yml` on the branch. Every publishing step there is ga
- `scripts/windows-sign.ps1` is the sign command: `signtool sign /dlib` with SHA-256 and the
Microsoft timestamp server, retried. Credentials never reach a command line — the dlib reads
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
**It signs only an allowlist of what ships**, about 4 signatures per release. Tauri also
presents build-time tools: the WiX extension DLLs, the NSIS plugins, and the app binary a
second time for the second bundle type. Signing those would roughly triple the metered count
for no user-visible benefit. If the app ever ships resource DLLs or sidecars, extend the
**It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice,
because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer;
and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS
plugins, and signing those would more than double the metered count for no user-visible
benefit. If the app ever ships resource DLLs or sidecars, extend the
allowlist, or they will go out unsigned. Tauri reports a failed sign command only as
"failed to run powershell", so the script keeps a transcript (`.code-signing/sign-output.log`,
`signtool /debug` included), and the job prints it on failure.
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp.
- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp on the
installers, and on the binaries **inside** the MSI (an administrative `msiexec /a` extract).
It deliberately does not check `target\release\triple-c.exe`: Tauri patches that file again
after packaging, so the loose copy is unsigned by design and is not what ships. For the NSIS
installer, which cannot be unpacked that way, it requires the signing log to show the app
binary and the uninstaller were signed.
Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the
+71 -30
View File
@@ -1,11 +1,19 @@
# windows-verify-signatures.ps1 <path-or-wildcard>... - fail unless every file
# carries a valid, timestamped Authenticode signature.
# windows-verify-signatures.ps1 <path-or-wildcard>... - fail unless everything
# that ships carries a valid, timestamped Authenticode signature.
#
# The check that makes signing load-bearing rather than hopeful: Tauri skips
# signing silently in some configurations (no sign command, --no-sign), and an
# unsigned installer looks exactly like a signed one until SmartScreen blocks
# it on a user's machine. Every pattern must match at least one file, so a
# bundle that was never produced cannot pass either.
#
# Pass the installers, not target\release\triple-c.exe. The app binary users
# get is the copy inside each installer: Tauri patches the loose file with
# bundle-type information before each bundle, signs it, packages it, and
# patches it again, so the loose copy ends up unsigned by design. The MSI is
# unpacked with an administrative install and its binaries checked directly;
# the NSIS installer cannot be unpacked that way, so for it the signing log
# must show the app binary and the uninstaller were signed.
param([Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)][string[]]$Patterns)
@@ -18,49 +26,82 @@ $files = foreach ($pattern in $Patterns) {
$found
}
$failed = @()
foreach ($file in $files) {
# signtool's own check: chain to a trusted root under the default
# Authenticode policy.
# Stop relaxed for the native call, as in windows-sign.ps1.
function Test-Signature([IO.FileInfo]$File, [string]$Label) {
# signtool's own check - chain to a trusted root under the default
# Authenticode policy - with Stop relaxed for the native call, as in
# windows-sign.ps1.
$ErrorActionPreference = 'Continue'
$verifyOutput = & $env:TRIPLE_C_SIGNTOOL verify /pa $file.FullName 2>&1 | ForEach-Object { "$_" }
$output = & $env:TRIPLE_C_SIGNTOOL verify /pa $File.FullName 2>&1 | ForEach-Object { "$_" }
$signtoolOk = ($LASTEXITCODE -eq 0)
$ErrorActionPreference = 'Stop'
if (-not $signtoolOk) { $verifyOutput | Write-Host }
if (-not $signtoolOk) { $output | Write-Host }
# And the timestamp, which signtool verify does not require.
$sig = Get-AuthenticodeSignature -FilePath $file.FullName
$sig = Get-AuthenticodeSignature -FilePath $File.FullName
$timestamped = $null -ne $sig.TimeStamperCertificate
if ($signtoolOk -and $sig.Status -eq 'Valid' -and $timestamped) {
Write-Host "OK $($file.Name) - $($sig.SignerCertificate.Subject)"
} else {
Write-Host "FAIL $($file.Name) - status $($sig.Status), signtool $(if ($signtoolOk) {'ok'} else {'failed'}), timestamped $timestamped"
$failed += $file.Name
Write-Host "OK $Label - $($sig.SignerCertificate.Subject)"
return $true
}
Write-Host "FAIL $Label - status $($sig.Status), signtool $(if ($signtoolOk) {'ok'} else {'failed'}), timestamped $timestamped"
return $false
}
function Get-SignedLog {
if ($env:TRIPLE_C_SIGN_LOG -and (Test-Path $env:TRIPLE_C_SIGN_LOG)) { return @(Get-Content $env:TRIPLE_C_SIGN_LOG) }
return @()
}
$failed = @()
$nsisBuilt = $false
foreach ($file in $files) {
if (-not (Test-Signature $file $file.Name)) { $failed += $file.Name }
if ($file.FullName -match '\\bundle\\nsis\\') { $nsisBuilt = $true }
if ($file.Extension -eq '.msi') {
$extract = Join-Path ([IO.Path]::GetTempPath()) ('msi-verify-' + [guid]::NewGuid().ToString('N'))
$proc = Start-Process msiexec.exe -Wait -PassThru `
-ArgumentList '/a', "`"$($file.FullName)`"", '/qn', "TARGETDIR=`"$extract`""
$inner = @()
if ($proc.ExitCode -eq 0) { $inner = @(Get-ChildItem -Path $extract -Recurse -File -Include *.exe, *.dll) }
if ($proc.ExitCode -ne 0) {
Write-Host "FAIL $($file.Name) - administrative extract exited $($proc.ExitCode)"
$failed += "$($file.Name) (extract)"
} elseif ($inner.Count -eq 0) {
Write-Host "FAIL $($file.Name) - contains no executable to check"
$failed += "$($file.Name) (no executable)"
}
foreach ($f in $inner) {
if (-not (Test-Signature $f "$($file.Name) > $($f.Name)")) { $failed += "$($file.Name) > $($f.Name)" }
}
Remove-Item -Recurse -Force $extract -ErrorAction SilentlyContinue
}
}
# The NSIS uninstaller is signed from inside makensis, which ignores the sign
# command's exit code, and it ends up embedded in the installer where the
# checks above cannot reach it. windows-sign.ps1 logs every file it signs; the
# uninstaller is the one makensis wrote under the job's temp directory (see
# windows-signing-setup.ps1), so require at least one logged path there.
$nsisBuilt = @($files | Where-Object { $_.FullName -match '\\bundle\\nsis\\' }).Count -gt 0
# What the NSIS installer carries but cannot be unpacked here. windows-sign.ps1
# logs every file it signs. The app binary is signed in place under
# target\release; the uninstaller is the file makensis wrote under the job's
# temp directory (see windows-signing-setup.ps1) - and makensis ignores the
# sign command's exit code for it, so without this a failure there is silent.
if ($nsisBuilt) {
$tmp = $env:TRIPLE_C_SIGN_TMP
$log = $env:TRIPLE_C_SIGN_LOG
$signedInTmp = @()
if ($tmp -and $log -and (Test-Path $log)) {
$signedInTmp = @(Get-Content $log | Where-Object { $_.StartsWith($tmp, [StringComparison]::OrdinalIgnoreCase) })
$log = Get-SignedLog
$appSigned = @($log | Where-Object { $_ -match '\\target\\release\\[^\\]+\.exe$' })
if ($appSigned.Count -eq 0) {
Write-Host 'FAIL app binary - no signature was logged for it before packaging'
$failed += 'app binary'
} else {
Write-Host "OK app binary - signed before packaging ($($appSigned.Count)x)"
}
if ($signedInTmp.Count -eq 0) {
Write-Host 'FAIL NSIS uninstaller - no successful signature was logged for it'
$tmp = $env:TRIPLE_C_SIGN_TMP
$uninstaller = @()
if ($tmp) { $uninstaller = @($log | Where-Object { $_.StartsWith($tmp, [StringComparison]::OrdinalIgnoreCase) }) }
if ($uninstaller.Count -eq 0) {
Write-Host 'FAIL NSIS uninstaller - no signature was logged for it'
$failed += 'NSIS uninstaller'
} else {
Write-Host "OK NSIS uninstaller - signed as $($signedInTmp[-1])"
Write-Host "OK NSIS uninstaller - signed as $($uninstaller[-1])"
}
}
if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" }
Write-Host "All $(@($files).Count) files are signed and timestamped."
Write-Host 'Everything that ships is signed and timestamped.'