From 08cd05000c8b45607f75dad26549e6c4c2b2941a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:53:58 -0700 Subject: [PATCH] fix(ci): verify the binary inside the installers, not the loose target copy 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) --- .gitea/workflows/build-app.yml | 1 - CLAUDE.md | 16 ++-- scripts/windows-verify-signatures.ps1 | 101 ++++++++++++++++++-------- 3 files changed, 82 insertions(+), 36 deletions(-) diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 3fa5f49..96be408 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index c1ea7e0..0f9d3d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/scripts/windows-verify-signatures.ps1 b/scripts/windows-verify-signatures.ps1 index 02003fb..1ddc74f 100644 --- a/scripts/windows-verify-signatures.ps1 +++ b/scripts/windows-verify-signatures.ps1 @@ -1,11 +1,19 @@ -# windows-verify-signatures.ps1 ... - fail unless every file -# carries a valid, timestamped Authenticode signature. +# windows-verify-signatures.ps1 ... - 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.'