fix(ci): verify the binary inside the installers, not the loose target copy
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / test (pull_request) Successful in 4m42s
Build App (Preview) / build-macos (pull_request) Successful in 3m9s
Build App (Preview) / build-windows (pull_request) Successful in 5m7s
Build App (Preview) / build-linux (pull_request) Successful in 10m24s
Build App (Preview) / prune-previews (pull_request) Successful in 7s

The first fully signed run (signer CN=OLS Digital LLC) signed the app
binary, the MSI, the NSIS installer and the uninstaller, and skipped the
seven build-time DLLs. It still failed verification on
target\release\triple-c.exe. Tauri patches that file with bundle-type
information before each bundle, signs it, packages it, and patches it
again, so the loose copy is unsigned by design and is not what ships.

Verification now:
- checks the installers themselves;
- unpacks the MSI with `msiexec /a` and checks the binaries inside it;
- for NSIS, which can't be unpacked that way, requires the signing log to
  show that the app binary and the uninstaller were signed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 18:53:58 -07:00
co-authored by Claude Opus 5.5
parent 947bb8e020
commit 08cd05000c
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.'