Compare commits

..
Author SHA1 Message Date
jknappandClaude Opus 5.5 f8e3ec1150 CI: sign Windows releases with Azure Artifact Signing (#62)
Build App / compute-version (push) Successful in 17s
Secret Scan / scan (push) Successful in 7s
Build App / build-macos (push) Successful in 3m6s
Build App / build-linux (push) Successful in 5m15s
Build App / build-windows (push) Successful in 5m52s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 1m50s
Windows releases are now signed with Azure Artifact Signing: the app binary, the MSI, the NSIS installer and its uninstaller, 5 signatures per release. Build-time WiX and NSIS DLLs are skipped. "Verify signatures" fails a release unless the installers, the binaries inside the MSI, and the logged app-binary and uninstaller signatures are all valid and timestamped.

Previews are not signed and don't reference the signing secrets. The sign command reaches Tauri through `--config`; the v2 CLI never read TAURI_CONFIG, so the old inline override was a no-op.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 02:09:48 +00:00
3 changed files with 82 additions and 36 deletions
-1
View File
@@ -682,7 +682,6 @@ jobs:
run: >- run: >-
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
-File scripts\windows-verify-signatures.ps1 -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\msi\*.msi
app\src-tauri\target\release\bundle\nsis\*.exe 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 - `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 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. `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 **It signs only an allowlist of what ships**: 5 signatures per release (the app binary twice,
presents build-time tools: the WiX extension DLLs, the NSIS plugins, and the app binary a because Tauri re-patches it between the MSI and NSIS bundles; the MSI; the NSIS installer;
second time for the second bundle type. Signing those would roughly triple the metered count and its uninstaller). Tauri also presents build-time tools, the WiX extension DLLs and NSIS
for no user-visible benefit. If the app ever ships resource DLLs or sidecars, extend the 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 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`, "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. `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`, Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`,
`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the `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 # windows-verify-signatures.ps1 <path-or-wildcard>... - fail unless everything
# carries a valid, timestamped Authenticode signature. # that ships carries a valid, timestamped Authenticode signature.
# #
# The check that makes signing load-bearing rather than hopeful: Tauri skips # The check that makes signing load-bearing rather than hopeful: Tauri skips
# signing silently in some configurations (no sign command, --no-sign), and an # signing silently in some configurations (no sign command, --no-sign), and an
# unsigned installer looks exactly like a signed one until SmartScreen blocks # 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 # it on a user's machine. Every pattern must match at least one file, so a
# bundle that was never produced cannot pass either. # 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) param([Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)][string[]]$Patterns)
@@ -18,49 +26,82 @@ $files = foreach ($pattern in $Patterns) {
$found $found
} }
$failed = @() function Test-Signature([IO.FileInfo]$File, [string]$Label) {
foreach ($file in $files) { # signtool's own check - chain to a trusted root under the default
# signtool's own check: chain to a trusted root under the default # Authenticode policy - with Stop relaxed for the native call, as in
# Authenticode policy. # windows-sign.ps1.
# Stop relaxed for the native call, as in windows-sign.ps1.
$ErrorActionPreference = 'Continue' $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) $signtoolOk = ($LASTEXITCODE -eq 0)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not $signtoolOk) { $verifyOutput | Write-Host } if (-not $signtoolOk) { $output | Write-Host }
# And the timestamp, which signtool verify does not require. # 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 $timestamped = $null -ne $sig.TimeStamperCertificate
if ($signtoolOk -and $sig.Status -eq 'Valid' -and $timestamped) { if ($signtoolOk -and $sig.Status -eq 'Valid' -and $timestamped) {
Write-Host "OK $($file.Name) - $($sig.SignerCertificate.Subject)" Write-Host "OK $Label - $($sig.SignerCertificate.Subject)"
} else { return $true
Write-Host "FAIL $($file.Name) - status $($sig.Status), signtool $(if ($signtoolOk) {'ok'} else {'failed'}), timestamped $timestamped" }
$failed += $file.Name 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 # What the NSIS installer carries but cannot be unpacked here. windows-sign.ps1
# command's exit code, and it ends up embedded in the installer where the # logs every file it signs. The app binary is signed in place under
# checks above cannot reach it. windows-sign.ps1 logs every file it signs; the # target\release; the uninstaller is the file makensis wrote under the job's
# uninstaller is the one makensis wrote under the job's temp directory (see # temp directory (see windows-signing-setup.ps1) - and makensis ignores the
# windows-signing-setup.ps1), so require at least one logged path there. # sign command's exit code for it, so without this a failure there is silent.
$nsisBuilt = @($files | Where-Object { $_.FullName -match '\\bundle\\nsis\\' }).Count -gt 0
if ($nsisBuilt) { if ($nsisBuilt) {
$tmp = $env:TRIPLE_C_SIGN_TMP $log = Get-SignedLog
$log = $env:TRIPLE_C_SIGN_LOG $appSigned = @($log | Where-Object { $_ -match '\\target\\release\\[^\\]+\.exe$' })
$signedInTmp = @() if ($appSigned.Count -eq 0) {
if ($tmp -and $log -and (Test-Path $log)) { Write-Host 'FAIL app binary - no signature was logged for it before packaging'
$signedInTmp = @(Get-Content $log | Where-Object { $_.StartsWith($tmp, [StringComparison]::OrdinalIgnoreCase) }) $failed += 'app binary'
} else {
Write-Host "OK app binary - signed before packaging ($($appSigned.Count)x)"
} }
if ($signedInTmp.Count -eq 0) { $tmp = $env:TRIPLE_C_SIGN_TMP
Write-Host 'FAIL NSIS uninstaller - no successful signature was logged for it' $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' $failed += 'NSIS uninstaller'
} else { } 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 ', ')" } 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.'