From 44e9bd291667c11d73f2f0149d61e931a9368e1e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 17:57:32 -0700 Subject: [PATCH 1/5] ci: sign Windows builds with Azure Artifact Signing Releases and PR previews now sign the app binary, the MSI, the NSIS installer and its uninstaller. "Verify signatures" fails the job on any unsigned or untimestamped .exe/.msi, so an unsigned installer can't ship quietly. - windows-signing-setup.ps1 fetches Microsoft.ArtifactSigning.Client 1.0.128 and a job-local .NET 10.0.12 runtime, each pinned by hash. Nothing is installed on the build VM. It also writes the dlib metadata and exports TAURI_CONFIG with bundle.windows.signCommand. - windows-sign.ps1 runs the installed signtool with /dlib, SHA-256 and the Microsoft timestamp server, with retries. Credentials come only from the AZURE_* environment. The metadata excludes every credential type except EnvironmentCredential, because InteractiveBrowserCredential would hang a job running as SYSTEM. - The signing files go in the workspace, not %TEMP%, because the uninstaller is signed from 32-bit makensis and WOW64 redirects SYSTEM's %TEMP%. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/build-app-preview.yml | 32 +++++- .gitea/workflows/build-app.yml | 31 +++++- .gitignore | 3 + CLAUDE.md | 35 ++++++ scripts/windows-sign.ps1 | 54 ++++++++++ scripts/windows-signing-setup.ps1 | 141 +++++++++++++++++++++++++ scripts/windows-verify-signatures.ps1 | 44 ++++++++ 7 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 scripts/windows-sign.ps1 create mode 100644 scripts/windows-signing-setup.ps1 create mode 100644 scripts/windows-verify-signatures.ps1 diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 0070862..4e38e8f 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -75,6 +75,7 @@ on: - "app/**" - "VERSION" - ".gitea/workflows/build-app-preview.yml" + - "scripts/windows-*.ps1" workflow_dispatch: jobs: @@ -690,16 +691,45 @@ jobs: set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm run build + # Previews are signed exactly like releases (build-app.yml): a preview is + # what gets installed for testing, and SmartScreen treats an unsigned one + # no differently from malware. The setup fetches the Artifact Signing + # client and a job-local .NET runtime, and exports TAURI_CONFIG with the + # sign command - which is why "Build Tauri app" no longer sets it. + - name: Prepare code signing + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} + ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }} + run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1 + - name: Build Tauri app working-directory: ./app + # No TAURI_CONFIG here: "Prepare code signing" exports it with the sign + # command, and a step-level value would override it and silently drop + # signing. env: - TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}" # See the matching comment on the Linux job's "Build Tauri app" step. TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} + # Read by the signing dlib itself, never passed on a command line. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" cargo tauri build + - name: Verify signatures + 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 + - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 0664f37..ca19088 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -7,6 +7,7 @@ on: - "app/**" - "VERSION" - ".gitea/workflows/build-app.yml" + - "scripts/windows-*.ps1" workflow_dispatch: # Deliberately **not** on pull_request. Every publishing step here is gated on @@ -632,10 +633,30 @@ jobs: set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm run build + # Releases are signed with Azure Artifact Signing (scripts/windows-*.ps1). + # The setup fetches the signing client and a job-local .NET runtime, and + # exports TAURI_CONFIG with the sign command - which is why "Build Tauri + # app" no longer sets it. A missing secret fails here, before the build. + - name: Prepare code signing + env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} + ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }} + run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1 + - name: Build Tauri app working-directory: ./app + # No TAURI_CONFIG here: "Prepare code signing" exports it with the sign + # command, and a step-level value would override it and silently drop + # signing. env: - TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}" + # Read by the signing dlib itself, never passed on a command line. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" rem Every Tauri bundler it downloads - candle.exe, light.exe and @@ -652,6 +673,14 @@ jobs: rem runner running as a normal user needs no such patch. cargo tauri build --bundles msi,nsis + - name: Verify signatures + 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 + - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" diff --git a/.gitignore b/.gitignore index 733330e..1cbdf74 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ screenshot_for_fix/ # Package files pulled in by ad-hoc verification runs. *.deb + +# Windows CI code signing (scripts/windows-signing-setup.ps1) +.code-signing/ diff --git a/CLAUDE.md b/CLAUDE.md index 75da2c1..85ed900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -836,6 +836,41 @@ verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMCl the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses `--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed. +### Windows code signing + +Every Windows build — releases (`build-app.yml`) and PR previews (`build-app-preview.yml`) — is +signed with **Azure Artifact Signing**: the app binary, the MSI, the NSIS installer and its +uninstaller. Three scripts do it, and "Verify signatures" fails the job if any `.exe`/`.msi` is +unsigned or untimestamped, so an unsigned installer cannot ship quietly. + +- `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client + (`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace, + **each pinned by version and hash**, writes the dlib's `metadata.json`, and exports + `TAURI_CONFIG` with `bundle.windows.signCommand`. Nothing is installed on the build VM. To bump + a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download. +- `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. +- `scripts/windows-verify-signatures.ps1` checks `signtool verify /pa` plus a timestamp. + +Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`, +`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. Four things are load-bearing: + +- **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses + `DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as + SYSTEM, where that waits forever for a browser. +- **The "Build Tauri app" steps must not set `TAURI_CONFIG`.** A step-level value overrides the + one the setup exported and drops the sign command without an error — "Verify signatures" is + what would catch it. +- **The signing files live in the workspace, not `%TEMP%`.** The NSIS uninstaller is signed from + inside 32-bit `makensis`, which runs the sign command under 32-bit PowerShell; WOW64 redirects + SYSTEM's `%TEMP%` (under System32) for that process. The workspace is under + `systemprofile\.cache`, which the VM junctions for exactly this (see the comment on + `build-app.yml`'s "Build Tauri app"). +- **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the + `winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's + `signtool` (10.0.26100) but no .NET — hence the job-local runtime. + ## Testing Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file: diff --git a/scripts/windows-sign.ps1 b/scripts/windows-sign.ps1 new file mode 100644 index 0000000..f62e285 --- /dev/null +++ b/scripts/windows-sign.ps1 @@ -0,0 +1,54 @@ +# windows-sign.ps1 - sign one file with Azure Artifact Signing. +# +# Tauri's bundle.windows.signCommand, set up by windows-signing-setup.ps1. +# Tauri calls it once per file it signs and fails the build on a non-zero exit. +# +# This may run as 32-bit PowerShell: the NSIS uninstaller is signed from inside +# makensis, which is 32-bit and resolves `powershell` to the SysWOW64 copy. So +# nothing here depends on $env:ProgramFiles or other per-bitness paths - every +# path comes in absolute from the setup script, and signtool is always the x64 +# build, since that is what loads the x64 dlib. +# +# Credentials never touch a command line: the dlib reads AZURE_TENANT_ID, +# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment itself. + +param([Parameter(Mandatory = $true)][string]$Path) + +$ErrorActionPreference = 'Stop' + +foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', + 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($name)) { + throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" + } +} +if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } + +# /d names the product in the UAC prompt, which for an MSI would otherwise show +# a temporary file name. The timestamp is what keeps the signature valid after +# the short-lived Artifact Signing certificate expires, so it is not optional. +$arguments = @( + 'sign', '/v', + '/fd', 'SHA256', + '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', + '/d', 'Triple-C', + '/dlib', $env:TRIPLE_C_SIGN_DLIB, + '/dmdf', $env:TRIPLE_C_SIGN_METADATA, + $Path +) + +# Timestamp servers and the signing endpoint both fail transiently now and +# then; a retry is cheaper than a failed three-platform release. +# +# Stop is relaxed around the call: Tauri captures this script's output, and +# PowerShell 5.1 turns a native command's stderr into error records when its +# own streams are redirected - under Stop, signtool's first warning would kill +# the script before its exit code is read. +$ErrorActionPreference = 'Continue' +for ($attempt = 1; $attempt -le 3; $attempt++) { + & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -eq 0) { exit 0 } + Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)" + if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } +} +exit 1 diff --git a/scripts/windows-signing-setup.ps1 b/scripts/windows-signing-setup.ps1 new file mode 100644 index 0000000..61b0d66 --- /dev/null +++ b/scripts/windows-signing-setup.ps1 @@ -0,0 +1,141 @@ +# windows-signing-setup.ps1 - prepare Azure Artifact Signing for a Windows CI job. +# +# Run once per job, before `cargo tauri build`. It fetches the two things the +# build VM does not carry, checks each against a pinned hash, and hands the +# rest of the job what `windows-sign.ps1` needs through $GITHUB_ENV: +# +# * Microsoft.ArtifactSigning.Client - the signtool "dlib" that forwards the +# digest to Azure instead of signing with a local certificate. +# * the .NET runtime that dlib is hosted on. It asks for 8.0 with +# rollForward=Major, so 10 LTS satisfies it; 8 goes out of support in +# November 2026 and 10 is supported to 2028. +# +# Everything lands inside the job's workspace and is gone with it. Nothing is +# installed on the VM: the two runners on it (winvm-builder, virtual-builder) +# share one machine, and a system-wide install would be state neither job +# owns. The workspace, not %TEMP%, because the runners run as SYSTEM and the +# NSIS uninstaller is signed from inside 32-bit makensis: WOW64 redirects a +# 32-bit process's view of System32 - where SYSTEM's %TEMP% lives - to +# SysWOW64. The workspace sits under systemprofile\.cache, which the VM +# junctions so both views resolve (see "Build Tauri app" in build-app.yml). +# +# Bumping a pin: take the new version's hash from nuget.org / the .NET +# release metadata (releases.json), never from a download you just made. +# +# Required environment (repository secrets): AZURE_TENANT_ID, AZURE_CLIENT_ID, +# AZURE_CLIENT_SECRET, ARTIFACT_SIGNING_ENDPOINT, ARTIFACT_SIGNING_ACCOUNT_NAME, +# ARTIFACT_SIGNING_PROFILE_NAME. A missing one fails the job: an unsigned +# installer must not reach a release by accident. + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$ClientVersion = '1.0.128' +$ClientSha256 = '74bd7d27e6ce1051409c38d9b46bc8df0400ecd643d51ffbf2ac00869061e40b' +$ClientUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.artifactsigning.client/$ClientVersion/microsoft.artifactsigning.client.$ClientVersion.nupkg" + +$DotnetVersion = '10.0.12' +$DotnetSha512 = '844fa99e16fd6f44e0a7c29def7a82d7846902334d6a955248a9519a4dddb3f5acceb9c9223bef69f8c83b8ae2417537e5b76dddf79fb7117dc85b5039bc1297' +$DotnetUrl = "https://builds.dotnet.microsoft.com/dotnet/Runtime/$DotnetVersion/dotnet-runtime-$DotnetVersion-win-x64.zip" + +$TimestampUrl = 'http://timestamp.acs.microsoft.com' + +$required = 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', + 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT_NAME', 'ARTIFACT_SIGNING_PROFILE_NAME' +$missing = @($required | Where-Object { -not [Environment]::GetEnvironmentVariable($_) }) +if ($missing.Count -gt 0) { + throw "Code signing is not configured: missing $($missing -join ', '). Add them as repository secrets." +} +if (-not $env:GITHUB_ENV) { throw 'GITHUB_ENV is not set - this script only runs inside a CI job.' } + +$workspace = if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path } +$root = Join-Path $workspace '.code-signing' +if (Test-Path $root) { Remove-Item -Recurse -Force $root } +New-Item -ItemType Directory -Path $root | Out-Null +Add-Type -AssemblyName System.IO.Compression.FileSystem + +function Get-Verified([string]$Url, [string]$Name, [string]$Algorithm, [string]$Expected) { + $file = Join-Path $root $Name + Write-Host "Downloading $Url" + Invoke-WebRequest -Uri $Url -OutFile $file -UseBasicParsing + $actual = (Get-FileHash -Path $file -Algorithm $Algorithm).Hash + if ($actual -ne $Expected) { + throw "$Name failed its $Algorithm check: expected $Expected, got $actual" + } + Write-Host "$Name $Algorithm verified" + return $file +} + +# The signing client. A .nupkg is a zip; extract it with the framework rather +# than Expand-Archive, which on PowerShell 5.1 refuses any extension but .zip. +$nupkg = Get-Verified $ClientUrl 'client.nupkg' 'SHA256' $ClientSha256 +$clientDir = Join-Path $root 'client' +[IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $clientDir) +$dlib = Join-Path $clientDir 'bin\x64\Azure.CodeSigning.Dlib.dll' +if (-not (Test-Path $dlib)) { throw "Signing client $ClientVersion has no $dlib" } + +# The runtime the dlib is hosted on, found through DOTNET_ROOT. +$dotnetZip = Get-Verified $DotnetUrl 'dotnet-runtime.zip' 'SHA512' $DotnetSha512 +$dotnetDir = Join-Path $root 'dotnet' +[IO.Compression.ZipFile]::ExtractToDirectory($dotnetZip, $dotnetDir) +if (-not (Test-Path (Join-Path $dotnetDir "shared\Microsoft.NETCore.App\$DotnetVersion"))) { + throw ".NET runtime $DotnetVersion did not extract where expected" +} +Remove-Item $nupkg, $dotnetZip + +# signtool comes with the Windows SDK the VM already has. The x64 build, to +# match the x64 dlib; the newest SDK if several are installed. +$signtool = $env:SIGNTOOL_PATH +if (-not $signtool) { + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.*\x64\signtool.exe" -ErrorAction SilentlyContinue | + Sort-Object { [version]$_.Directory.Parent.Name } | Select-Object -Last 1 -ExpandProperty FullName +} +if (-not $signtool -or -not (Test-Path $signtool)) { + throw 'signtool.exe (x64) not found - install the Windows SDK or set SIGNTOOL_PATH' +} +Write-Host "Using $signtool" + +# The dlib authenticates through DefaultAzureCredential, which tries a chain +# of credentials. Everything but EnvironmentCredential (the three AZURE_* +# variables) is excluded: the chain ends in InteractiveBrowserCredential, and +# a SYSTEM process waiting on a browser that never opens is a hung build. +$metadata = [ordered]@{ + Endpoint = $env:ARTIFACT_SIGNING_ENDPOINT + CodeSigningAccountName = $env:ARTIFACT_SIGNING_ACCOUNT_NAME + CertificateProfileName = $env:ARTIFACT_SIGNING_PROFILE_NAME + ExcludeCredentials = @( + 'ManagedIdentityCredential', 'WorkloadIdentityCredential', 'SharedTokenCacheCredential', + 'VisualStudioCredential', 'VisualStudioCodeCredential', 'AzureCliCredential', + 'AzurePowerShellCredential', 'AzureDeveloperCliCredential', 'InteractiveBrowserCredential' + ) +} +$metadataPath = Join-Path $root 'metadata.json' +$utf8 = New-Object System.Text.UTF8Encoding $false +[IO.File]::WriteAllText($metadataPath, ($metadata | ConvertTo-Json), $utf8) + +# Tauri runs this for every file it signs - the app binary, the MSI, the NSIS +# installer and (from inside makensis) the uninstaller - with %1 replaced by +# the path. Object form, so paths with spaces survive. +$signScript = Join-Path $workspace 'scripts\windows-sign.ps1' +$tauriConfig = @{ + build = @{ beforeBuildCommand = '' } + bundle = @{ windows = @{ signCommand = @{ + cmd = 'powershell' + args = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $signScript, '%1') + } } } +} | ConvertTo-Json -Depth 8 -Compress + +# $GITHUB_ENV is KEY=VALUE lines. Written without a BOM: PowerShell 5.1's +# utf8 encoding adds one, which would corrupt the first key. +$lines = @( + "TRIPLE_C_SIGNTOOL=$signtool" + "TRIPLE_C_SIGN_DLIB=$dlib" + "TRIPLE_C_SIGN_METADATA=$metadataPath" + "TRIPLE_C_SIGN_TIMESTAMP=$TimestampUrl" + "DOTNET_ROOT=$dotnetDir" + "DOTNET_ROOT_X64=$dotnetDir" + "TAURI_CONFIG=$tauriConfig" +) +[IO.File]::AppendAllText($env:GITHUB_ENV, (($lines -join "`n") + "`n"), $utf8) +Write-Host 'Code signing prepared.' diff --git a/scripts/windows-verify-signatures.ps1 b/scripts/windows-verify-signatures.ps1 new file mode 100644 index 0000000..21fb882 --- /dev/null +++ b/scripts/windows-verify-signatures.ps1 @@ -0,0 +1,44 @@ +# windows-verify-signatures.ps1 ... - fail unless every file +# 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. + +param([Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)][string[]]$Patterns) + +$ErrorActionPreference = 'Stop' +if (-not $env:TRIPLE_C_SIGNTOOL) { throw 'TRIPLE_C_SIGNTOOL is not set - run windows-signing-setup.ps1 first' } + +$files = foreach ($pattern in $Patterns) { + $found = @(Get-ChildItem -Path $pattern -File -ErrorAction SilentlyContinue) + if ($found.Count -eq 0) { throw "Nothing to verify matches $pattern" } + $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. + $ErrorActionPreference = 'Continue' + $verifyOutput = & $env:TRIPLE_C_SIGNTOOL verify /pa $file.FullName 2>&1 | ForEach-Object { "$_" } + $signtoolOk = ($LASTEXITCODE -eq 0) + $ErrorActionPreference = 'Stop' + if (-not $signtoolOk) { $verifyOutput | Write-Host } + + # And the timestamp, which signtool verify does not require. + $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 + } +} +if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" } +Write-Host "All $(@($files).Count) files are signed and timestamped." -- 2.52.0 From cc274f39a886b809bb8e4b1165cc0585d3269c4d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:05:03 -0700 Subject: [PATCH 2/5] fix(ci): pass the sign command with --config and prove the uninstaller was signed The first signing run built nothing signed, and "Verify signatures" failed it as intended. The Tauri 2 CLI never reads TAURI_CONFIG. It only sets that variable for tauri-build, so the sign command was dropped silently, just as the inline beforeBuildCommand override had been for as long as the Windows jobs have set it. The setup now writes a config file, and the build passes it with `cargo tauri build --config`. Review follow-ups: - The NSIS uninstaller is written to %TEMP% and signed from 32-bit makensis. SYSTEM's %TEMP% sits under System32, which WOW64 redirects for makensis but not for the x64 signtool, so they would disagree about where the file is. %TEMP% and %TMP% now point into the workspace. makensis ignores the sign command's exit code for the uninstaller, so windows-sign.ps1 logs every file it signs, and the verify step requires a logged signature under that temp directory. - The signing client is pinned by SHA-512, so the pin can be checked against nuget.org's published packageHash. - tauri-cli on Windows is pinned to =2.11.0 --locked, the @tauri-apps/cli version the Linux and macOS jobs run from the lockfile, instead of "^2". Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/build-app-preview.yml | 19 +++++++----- .gitea/workflows/build-app.yml | 19 +++++++----- CLAUDE.md | 24 +++++++++------- scripts/windows-sign.ps1 | 9 +++++- scripts/windows-signing-setup.ps1 | 40 ++++++++++++++++++++------ scripts/windows-verify-signatures.ps1 | 22 ++++++++++++++ 6 files changed, 100 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 4e38e8f..7426355 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -670,7 +670,11 @@ jobs: - name: Install Tauri CLI via cargo run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo install tauri-cli --version "^2" + rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which + rem the Linux and macOS jobs run: the Windows code-signing path (sign + rem command, NSIS uninstaller signing) was verified against it, and "^2" + rem would change it underneath the pipeline on any Tauri release. + cargo install tauri-cli --version "=2.11.0" --locked - name: Fix npm platform detection run: | @@ -694,8 +698,8 @@ jobs: # Previews are signed exactly like releases (build-app.yml): a preview is # what gets installed for testing, and SmartScreen treats an unsigned one # no differently from malware. The setup fetches the Artifact Signing - # client and a job-local .NET runtime, and exports TAURI_CONFIG with the - # sign command - which is why "Build Tauri app" no longer sets it. + # client and a job-local .NET runtime, and writes the Tauri config holding + # the sign command, which "Build Tauri app" passes with --config. - name: Prepare code signing env: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -708,9 +712,10 @@ jobs: - name: Build Tauri app working-directory: ./app - # No TAURI_CONFIG here: "Prepare code signing" exports it with the sign - # command, and a step-level value would override it and silently drop - # signing. + # The sign command comes in through --config, from the file "Prepare + # code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that + # variable (the inline one this step used to set was a no-op), and + # "Verify signatures" is what caught it. env: # See the matching comment on the Linux job's "Build Tauri app" step. TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} @@ -720,7 +725,7 @@ jobs: AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo tauri build + cargo tauri build --config "%TRIPLE_C_TAURI_SIGN_CONFIG%" - name: Verify signatures run: >- diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index ca19088..40dfad3 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -612,7 +612,11 @@ jobs: - name: Install Tauri CLI via cargo run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo install tauri-cli --version "^2" + rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which + rem the Linux and macOS jobs run: the Windows code-signing path (sign + rem command, NSIS uninstaller signing) was verified against it, and "^2" + rem would change it underneath the pipeline on any Tauri release. + cargo install tauri-cli --version "=2.11.0" --locked - name: Fix npm platform detection run: | @@ -635,8 +639,8 @@ jobs: # Releases are signed with Azure Artifact Signing (scripts/windows-*.ps1). # The setup fetches the signing client and a job-local .NET runtime, and - # exports TAURI_CONFIG with the sign command - which is why "Build Tauri - # app" no longer sets it. A missing secret fails here, before the build. + # writes the Tauri config holding the sign command, which "Build Tauri + # app" passes with --config. A missing secret fails here, before the build. - name: Prepare code signing env: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -649,9 +653,10 @@ jobs: - name: Build Tauri app working-directory: ./app - # No TAURI_CONFIG here: "Prepare code signing" exports it with the sign - # command, and a step-level value would override it and silently drop - # signing. + # The sign command comes in through --config, from the file "Prepare + # code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that + # variable (the inline one this step used to set was a no-op), and + # "Verify signatures" is what caught it. env: # Read by the signing dlib itself, never passed on a command line. AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -671,7 +676,7 @@ jobs: rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the rem System32 originals, which makes the redirected view resolve. A rem runner running as a normal user needs no such patch. - cargo tauri build --bundles msi,nsis + cargo tauri build --bundles msi,nsis --config "%TRIPLE_C_TAURI_SIGN_CONFIG%" - name: Verify signatures run: >- diff --git a/CLAUDE.md b/CLAUDE.md index 85ed900..d9afab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -845,8 +845,9 @@ unsigned or untimestamped, so an unsigned installer cannot ship quietly. - `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client (`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace, - **each pinned by version and hash**, writes the dlib's `metadata.json`, and exports - `TAURI_CONFIG` with `bundle.windows.signCommand`. Nothing is installed on the build VM. To bump + **each pinned by version and hash**, writes the dlib's `metadata.json`, and writes a Tauri + config file with `bundle.windows.signCommand` that the build passes as + `cargo tauri build --config`. Nothing is installed on the build VM. To bump a pin, take the hash from nuget.org / the .NET `releases.json`, never from your own download. - `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 @@ -859,14 +860,17 @@ Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT` - **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses `DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as SYSTEM, where that waits forever for a browser. -- **The "Build Tauri app" steps must not set `TAURI_CONFIG`.** A step-level value overrides the - one the setup exported and drops the sign command without an error — "Verify signatures" is - what would catch it. -- **The signing files live in the workspace, not `%TEMP%`.** The NSIS uninstaller is signed from - inside 32-bit `makensis`, which runs the sign command under 32-bit PowerShell; WOW64 redirects - SYSTEM's `%TEMP%` (under System32) for that process. The workspace is under - `systemprofile\.cache`, which the VM junctions for exactly this (see the comment on - `build-app.yml`'s "Build Tauri app"). +- **The sign command goes in through `--config`, never `TAURI_CONFIG`.** The v2 CLI does not read + that variable — it only sets it, for tauri-build — so a config put there is dropped without an + error. The Windows jobs set an inline `TAURI_CONFIG` for years and it never applied; + "Verify signatures" is what exposed it, and it is what would catch a regression. +- **The signing files and the job's `%TEMP%` live in the workspace.** The NSIS uninstaller is + written to `%TEMP%` and signed from inside 32-bit `makensis`, under 32-bit PowerShell; WOW64 + redirects SYSTEM's own `%TEMP%` (under System32) for those processes but not for the x64 + signtool, so they would disagree about where the file is. The workspace is under + `systemprofile\.cache`, which the VM junctions so both views resolve. makensis also ignores + the sign command's exit code for the uninstaller, so `windows-sign.ps1` logs every file it + signs and the verify step requires a logged signature under that temp directory. - **The build VM is `WindowsBuilder` (VM 110 on the Proxmox host `pve4`)**, carrying both the `winvm-builder` and `virtual-builder` runners in host mode. It has the Windows SDK's `signtool` (10.0.26100) but no .NET — hence the job-local runtime. diff --git a/scripts/windows-sign.ps1 b/scripts/windows-sign.ps1 index f62e285..bbd3580 100644 --- a/scripts/windows-sign.ps1 +++ b/scripts/windows-sign.ps1 @@ -47,7 +47,14 @@ $arguments = @( $ErrorActionPreference = 'Continue' for ($attempt = 1; $attempt -le 3; $attempt++) { & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" } - if ($LASTEXITCODE -eq 0) { exit 0 } + if ($LASTEXITCODE -eq 0) { + # The evidence "Verify signatures" needs for files it cannot see + # afterwards - the NSIS uninstaller is embedded in the installer. + if ($env:TRIPLE_C_SIGN_LOG) { + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$Path`n", (New-Object System.Text.UTF8Encoding $false)) + } + exit 0 + } Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)" if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } } diff --git a/scripts/windows-signing-setup.ps1 b/scripts/windows-signing-setup.ps1 index 61b0d66..a3bafaf 100644 --- a/scripts/windows-signing-setup.ps1 +++ b/scripts/windows-signing-setup.ps1 @@ -2,7 +2,10 @@ # # Run once per job, before `cargo tauri build`. It fetches the two things the # build VM does not carry, checks each against a pinned hash, and hands the -# rest of the job what `windows-sign.ps1` needs through $GITHUB_ENV: +# rest of the job what `windows-sign.ps1` needs through $GITHUB_ENV, including +# TRIPLE_C_TAURI_SIGN_CONFIG - a config file for `cargo tauri build --config`. +# Not the TAURI_CONFIG variable: the v2 CLI never reads it (it only *sets* it, +# for tauri-build), so a sign command put there is silently ignored. # # * Microsoft.ArtifactSigning.Client - the signtool "dlib" that forwards the # digest to Azure instead of signing with a local certificate. @@ -19,8 +22,10 @@ # SysWOW64. The workspace sits under systemprofile\.cache, which the VM # junctions so both views resolve (see "Build Tauri app" in build-app.yml). # -# Bumping a pin: take the new version's hash from nuget.org / the .NET -# release metadata (releases.json), never from a download you just made. +# Bumping a pin: take the new version's hash from the publisher, never from a +# download you just made - the client's SHA-512 is the base64 `packageHash` in +# its nuget.org catalog entry (hex here), the runtime's is in .NET's +# releases.json. # # Required environment (repository secrets): AZURE_TENANT_ID, AZURE_CLIENT_ID, # AZURE_CLIENT_SECRET, ARTIFACT_SIGNING_ENDPOINT, ARTIFACT_SIGNING_ACCOUNT_NAME, @@ -32,7 +37,7 @@ $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $ClientVersion = '1.0.128' -$ClientSha256 = '74bd7d27e6ce1051409c38d9b46bc8df0400ecd643d51ffbf2ac00869061e40b' +$ClientSha512 = '98f06a691f4fc2fa22f19dcf8556733e98607fbef91a312c453b9b0798cc9088dae0acb36e389b552a11b4d2320324785b8541c2b51091a724c05bc5df5cbf95' $ClientUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.artifactsigning.client/$ClientVersion/microsoft.artifactsigning.client.$ClientVersion.nupkg" $DotnetVersion = '10.0.12' @@ -69,7 +74,7 @@ function Get-Verified([string]$Url, [string]$Name, [string]$Algorithm, [string]$ # The signing client. A .nupkg is a zip; extract it with the framework rather # than Expand-Archive, which on PowerShell 5.1 refuses any extension but .zip. -$nupkg = Get-Verified $ClientUrl 'client.nupkg' 'SHA256' $ClientSha256 +$nupkg = Get-Verified $ClientUrl 'client.nupkg' 'SHA512' $ClientSha512 $clientDir = Join-Path $root 'client' [IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $clientDir) $dlib = Join-Path $clientDir 'bin\x64\Azure.CodeSigning.Dlib.dll' @@ -116,7 +121,8 @@ $utf8 = New-Object System.Text.UTF8Encoding $false # Tauri runs this for every file it signs - the app binary, the MSI, the NSIS # installer and (from inside makensis) the uninstaller - with %1 replaced by -# the path. Object form, so paths with spaces survive. +# the path. Object form, so paths with spaces survive. beforeBuildCommand is +# blanked because the job builds the frontend in its own step. $signScript = Join-Path $workspace 'scripts\windows-sign.ps1' $tauriConfig = @{ build = @{ beforeBuildCommand = '' } @@ -124,7 +130,21 @@ $tauriConfig = @{ cmd = 'powershell' args = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $signScript, '%1') } } } -} | ConvertTo-Json -Depth 8 -Compress +} | ConvertTo-Json -Depth 8 +$tauriConfigPath = Join-Path $root 'tauri.signing.conf.json' +[IO.File]::WriteAllText($tauriConfigPath, $tauriConfig, $utf8) + +# The job's temp directory moves into the workspace too. makensis writes the +# uninstaller to %TEMP% before signing it, and SYSTEM's own %TEMP% is under +# System32: the 32-bit makensis and PowerShell would see the SysWOW64 copy of +# that path while the x64 signtool opens the real one, and fail to find the +# file. makensis ignores the sign command's exit code for the uninstaller +# (Tauri emits `!uninstfinalize` without a compare), so that failure would be +# silent - hence the signing log, which "Verify signatures" reads to require +# that a file under this directory, i.e. the uninstaller, really was signed. +$tmpDir = Join-Path $root 'tmp' +New-Item -ItemType Directory -Path $tmpDir | Out-Null +$signLog = Join-Path $root 'signed.log' # $GITHUB_ENV is KEY=VALUE lines. Written without a BOM: PowerShell 5.1's # utf8 encoding adds one, which would corrupt the first key. @@ -135,7 +155,11 @@ $lines = @( "TRIPLE_C_SIGN_TIMESTAMP=$TimestampUrl" "DOTNET_ROOT=$dotnetDir" "DOTNET_ROOT_X64=$dotnetDir" - "TAURI_CONFIG=$tauriConfig" + "TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath" + "TRIPLE_C_SIGN_LOG=$signLog" + "TRIPLE_C_SIGN_TMP=$tmpDir" + "TEMP=$tmpDir" + "TMP=$tmpDir" ) [IO.File]::AppendAllText($env:GITHUB_ENV, (($lines -join "`n") + "`n"), $utf8) Write-Host 'Code signing prepared.' diff --git a/scripts/windows-verify-signatures.ps1 b/scripts/windows-verify-signatures.ps1 index 21fb882..02003fb 100644 --- a/scripts/windows-verify-signatures.ps1 +++ b/scripts/windows-verify-signatures.ps1 @@ -40,5 +40,27 @@ foreach ($file in $files) { $failed += $file.Name } } + +# 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 +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) }) + } + if ($signedInTmp.Count -eq 0) { + Write-Host 'FAIL NSIS uninstaller - no successful signature was logged for it' + $failed += 'NSIS uninstaller' + } else { + Write-Host "OK NSIS uninstaller - signed as $($signedInTmp[-1])" + } +} + if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" } Write-Host "All $(@($files).Count) files are signed and timestamped." -- 2.52.0 From a9432324a7d9ebe4458edbad7a747a4fdc5db607 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:16:32 -0700 Subject: [PATCH 3/5] ci: sign only what ships, stop signing previews, log signing output Artifact Signing is metered at about 1000 signatures a month, and there were 67 Windows builds last month. - windows-sign.ps1 signs only what reaches users: the app binary, the MSI, the NSIS installer and the uninstaller. It skips the WiX extension DLLs and NSIS plugins that Tauri also offers, and any file that is already validly signed. That is 4 signatures per release. - Previews are no longer signed, so the preview workflow no longer references the signing secrets. A PR's workflow runs the PR's own code. The inline TAURI_CONFIG there, which the v2 CLI never read, becomes a real `--config`. - Tauri reports a failed sign command only as "failed to run powershell". The script now keeps a transcript, signtool /debug included, and the release job prints it on failure. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitea/workflows/build-app-preview.yml | 50 +++-------- .gitea/workflows/build-app.yml | 6 ++ scripts/windows-sign.ps1 | 115 +++++++++++++++++-------- scripts/windows-signing-setup.ps1 | 2 + 4 files changed, 100 insertions(+), 73 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 7426355..63c9701 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -7,6 +7,8 @@ name: Build App (Preview) # This is also the **PR build check**: it compiles Linux, macOS and Windows, so # a push that breaks any of them fails here. Its `test` job runs vitest and # `cargo test` too, so a push that breaks either suite fails here as well. +# Previews are not code-signed (releases are, in build-app.yml): see the +# comment on the Windows job's "Build Tauri app" step. # build-app.yml used to do the build-check job in parallel and publish nothing, # which meant six OS builds per push and one unreachable set of bundles; it is # now releases-only. @@ -75,7 +77,6 @@ on: - "app/**" - "VERSION" - ".gitea/workflows/build-app-preview.yml" - - "scripts/windows-*.ps1" workflow_dispatch: jobs: @@ -671,9 +672,8 @@ jobs: run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" rem Pinned to the @tauri-apps/cli version in app/package-lock.json, which - rem the Linux and macOS jobs run: the Windows code-signing path (sign - rem command, NSIS uninstaller signing) was verified against it, and "^2" - rem would change it underneath the pipeline on any Tauri release. + rem the Linux and macOS jobs run, and kept identical to build-app.yml so + rem a preview is built by the same bundler as the release it previews. cargo install tauri-cli --version "=2.11.0" --locked - name: Fix npm platform detection @@ -695,45 +695,23 @@ jobs: set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" npm run build - # Previews are signed exactly like releases (build-app.yml): a preview is - # what gets installed for testing, and SmartScreen treats an unsigned one - # no differently from malware. The setup fetches the Artifact Signing - # client and a job-local .NET runtime, and writes the Tauri config holding - # the sign command, which "Build Tauri app" passes with --config. - - name: Prepare code signing - env: - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} - ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }} - ARTIFACT_SIGNING_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_PROFILE_NAME }} - run: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\windows-signing-setup.ps1 - - name: Build Tauri app working-directory: ./app - # The sign command comes in through --config, from the file "Prepare - # code signing" wrote. Not TAURI_CONFIG: the v2 CLI never reads that - # variable (the inline one this step used to set was a no-op), and - # "Verify signatures" is what caught it. + # Previews are not code-signed: signing is metered, previews are built + # on every PR push, and a PR's workflow runs the PR's own code - so the + # signing secrets stay out of this workflow entirely. Releases are + # signed in build-app.yml. + # + # beforeBuildCommand is blanked through --config because the frontend + # was built in the step above. Not TAURI_CONFIG: the v2 CLI never + # reads that variable, and the inline one this step used to set was a + # no-op. env: # See the matching comment on the Linux job's "Build Tauri app" step. TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }} - # Read by the signing dlib itself, never passed on a command line. - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" - cargo tauri build --config "%TRIPLE_C_TAURI_SIGN_CONFIG%" - - - name: Verify signatures - 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 + cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}" - name: Collect artifacts run: | diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 40dfad3..3fa5f49 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -686,6 +686,12 @@ jobs: app\src-tauri\target\release\bundle\msi\*.msi app\src-tauri\target\release\bundle\nsis\*.exe + # Tauri reports a failed sign command as just "failed to run powershell"; + # windows-sign.ps1 keeps its own transcript, signtool /debug included. + - name: Show signing output + if: failure() + run: if exist .code-signing\sign-output.log type .code-signing\sign-output.log + - name: Collect artifacts run: | set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" diff --git a/scripts/windows-sign.ps1 b/scripts/windows-sign.ps1 index bbd3580..56d4da7 100644 --- a/scripts/windows-sign.ps1 +++ b/scripts/windows-sign.ps1 @@ -1,7 +1,18 @@ # windows-sign.ps1 - sign one file with Azure Artifact Signing. # # Tauri's bundle.windows.signCommand, set up by windows-signing-setup.ps1. -# Tauri calls it once per file it signs and fails the build on a non-zero exit. +# Tauri calls it once per file it wants signed and fails the build on a +# non-zero exit - but shows none of this script's output when it does, so +# everything is also appended to $TRIPLE_C_SIGN_OUTPUT, which the workflow +# prints if the job fails. +# +# Only what ships is signed. Tauri also offers build-time tools - the WiX +# extension DLLs candle/light load, the NSIS plugins makensis embeds - and +# each signature is metered (about 1000 a month), so those are skipped. The +# allowlist below is the whole of what reaches users: the app binary, the MSI, +# the NSIS installer, and the uninstaller makensis writes to the job's temp +# directory. A file that already carries a valid signature is skipped too: +# Tauri presents the app binary once per bundle type. # # This may run as 32-bit PowerShell: the NSIS uninstaller is signed from inside # makensis, which is 32-bit and resolves `powershell` to the SysWOW64 copy. So @@ -15,47 +26,77 @@ param([Parameter(Mandatory = $true)][string]$Path) $ErrorActionPreference = 'Stop' +$utf8 = New-Object System.Text.UTF8Encoding $false -foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', - 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { - if (-not [Environment]::GetEnvironmentVariable($name)) { - throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" +function Write-Log([string]$Text) { + Write-Host $Text + if ($env:TRIPLE_C_SIGN_OUTPUT) { + try { [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_OUTPUT, "$Text`n", $utf8) } catch { } } } -if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } -# /d names the product in the UAC prompt, which for an MSI would otherwise show -# a temporary file name. The timestamp is what keeps the signature valid after -# the short-lived Artifact Signing certificate expires, so it is not optional. -$arguments = @( - 'sign', '/v', - '/fd', 'SHA256', - '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', - '/d', 'Triple-C', - '/dlib', $env:TRIPLE_C_SIGN_DLIB, - '/dmdf', $env:TRIPLE_C_SIGN_METADATA, - $Path -) - -# Timestamp servers and the signing endpoint both fail transiently now and -# then; a retry is cheaper than a failed three-platform release. -# -# Stop is relaxed around the call: Tauri captures this script's output, and -# PowerShell 5.1 turns a native command's stderr into error records when its -# own streams are redirected - under Stop, signtool's first warning would kill -# the script before its exit code is read. -$ErrorActionPreference = 'Continue' -for ($attempt = 1; $attempt -le 3; $attempt++) { - & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { "$_" } - if ($LASTEXITCODE -eq 0) { - # The evidence "Verify signatures" needs for files it cannot see - # afterwards - the NSIS uninstaller is embedded in the installer. - if ($env:TRIPLE_C_SIGN_LOG) { - [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$Path`n", (New-Object System.Text.UTF8Encoding $false)) +try { + foreach ($name in 'TRIPLE_C_SIGNTOOL', 'TRIPLE_C_SIGN_DLIB', 'TRIPLE_C_SIGN_METADATA', 'TRIPLE_C_SIGN_TIMESTAMP', + 'TRIPLE_C_SIGN_TMP', 'TRIPLE_C_SIGN_LOG', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($name)) { + throw "$name is not set - run windows-signing-setup.ps1 first and pass the AZURE_* secrets to this step" } + } + if (-not (Test-Path -LiteralPath $Path)) { throw "No such file to sign: $Path" } + $full = (Resolve-Path -LiteralPath $Path).ProviderPath + Write-Log "== $full" + + $ships = ($full -match '\\target\\release\\[^\\]+\.exe$') -or + ($full -match '\\target\\release\\bundle\\(msi|nsis)\\[^\\]+\.(msi|exe)$') -or + $full.StartsWith($env:TRIPLE_C_SIGN_TMP.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase) + if (-not $ships) { + Write-Log 'skipped: build-time file, not shipped' exit 0 } - Write-Host "signtool exited $LASTEXITCODE signing $Path (attempt $attempt of 3)" - if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + + $existing = Get-AuthenticodeSignature -LiteralPath $full + if ($existing.Status -eq 'Valid' -and $existing.TimeStamperCertificate) { + Write-Log "skipped: already signed by $($existing.SignerCertificate.Subject)" + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8) + exit 0 + } + + # /d names the product in the UAC prompt, which for an MSI would otherwise + # show a temporary file name. The timestamp is what keeps the signature + # valid after the short-lived Artifact Signing certificate expires, so it + # is not optional. /debug makes the dlib say why it failed, into the log. + $arguments = @( + 'sign', '/v', '/debug', + '/fd', 'SHA256', + '/tr', $env:TRIPLE_C_SIGN_TIMESTAMP, '/td', 'SHA256', + '/d', 'Triple-C', + '/dlib', $env:TRIPLE_C_SIGN_DLIB, + '/dmdf', $env:TRIPLE_C_SIGN_METADATA, + $full + ) + + # Stop is relaxed around the call: Tauri captures this script's output, + # and PowerShell 5.1 turns a native command's stderr into error records + # when its own streams are redirected - under Stop, signtool's first + # warning would kill the script before its exit code is read. Timestamp + # servers and the signing endpoint fail transiently now and then, hence + # the retries. + $ErrorActionPreference = 'Continue' + for ($attempt = 1; $attempt -le 3; $attempt++) { + & $env:TRIPLE_C_SIGNTOOL @arguments 2>&1 | ForEach-Object { Write-Log "$_" } + $code = $LASTEXITCODE + if ($code -eq 0) { + # The evidence "Verify signatures" needs for the file it cannot see + # afterwards - the NSIS uninstaller is embedded in the installer. + [IO.File]::AppendAllText($env:TRIPLE_C_SIGN_LOG, "$full`n", $utf8) + Write-Log 'signed' + exit 0 + } + Write-Log "signtool exited $code (attempt $attempt of 3)" + if ($attempt -lt 3) { Start-Sleep -Seconds (10 * $attempt) } + } + exit 1 +} catch { + Write-Log "windows-sign.ps1 failed: $($_.Exception.Message)" + exit 1 } -exit 1 diff --git a/scripts/windows-signing-setup.ps1 b/scripts/windows-signing-setup.ps1 index a3bafaf..a123b74 100644 --- a/scripts/windows-signing-setup.ps1 +++ b/scripts/windows-signing-setup.ps1 @@ -145,6 +145,7 @@ $tauriConfigPath = Join-Path $root 'tauri.signing.conf.json' $tmpDir = Join-Path $root 'tmp' New-Item -ItemType Directory -Path $tmpDir | Out-Null $signLog = Join-Path $root 'signed.log' +$signOutput = Join-Path $root 'sign-output.log' # $GITHUB_ENV is KEY=VALUE lines. Written without a BOM: PowerShell 5.1's # utf8 encoding adds one, which would corrupt the first key. @@ -157,6 +158,7 @@ $lines = @( "DOTNET_ROOT_X64=$dotnetDir" "TRIPLE_C_TAURI_SIGN_CONFIG=$tauriConfigPath" "TRIPLE_C_SIGN_LOG=$signLog" + "TRIPLE_C_SIGN_OUTPUT=$signOutput" "TRIPLE_C_SIGN_TMP=$tmpDir" "TEMP=$tmpDir" "TMP=$tmpDir" -- 2.52.0 From 947bb8e0207abad92ca64ae53d7a0bf1ace9cd13 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:35:12 -0700 Subject: [PATCH 4/5] docs: Windows signing is release-only, allowlisted, and gated by branch protection Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9afab0..c1ea7e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -838,10 +838,15 @@ the bundle sets it, which is what lets the shell match the window to the entry. ### Windows code signing -Every Windows build — releases (`build-app.yml`) and PR previews (`build-app-preview.yml`) — is -signed with **Azure Artifact Signing**: the app binary, the MSI, the NSIS installer and its -uninstaller. Three scripts do it, and "Verify signatures" fails the job if any `.exe`/`.msi` is -unsigned or untimestamped, so an unsigned installer cannot ship quietly. +Windows **releases** (`build-app.yml`) are signed with **Azure Artifact Signing**: the app binary, +the MSI, the NSIS installer and its uninstaller. Three scripts do it, and "Verify signatures" +fails the job if any of them is unsigned or untimestamped, so an unsigned installer cannot ship +quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml` must not +reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month, +against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a +secret available there is available to whoever can push a branch. To exercise signing before a +merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on +`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing. - `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client (`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace, @@ -852,10 +857,26 @@ unsigned or untimestamped, so an unsigned installer cannot ship quietly. - `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 + 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. Secrets (repository): the three `AZURE_*` above plus `ARTIFACT_SIGNING_ENDPOINT`, -`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. Four things are load-bearing: +`ARTIFACT_SIGNING_ACCOUNT_NAME`, `ARTIFACT_SIGNING_PROFILE_NAME`. They are referenced only by the +two Windows steps of `build-app.yml` that need them ("Prepare code signing" and "Build Tauri +app"), never by the preview workflow, never echoed, and never on a command line. The repo is +public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's +`/debug` output carries no tokens (checked against its strings). Anyone who can push to this +repo can reach the secrets through a workflow file, so repo write access is the boundary. +`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed +release only ever comes from a merged, visible change. The +Azure side should hold the rest: an app registration with only the signer role on this one +certificate profile, and a client secret with an expiry. Four things are load-bearing: - **`metadata.json` excludes every credential but `EnvironmentCredential`.** The dlib uses `DefaultAzureCredential`, whose chain ends in `InteractiveBrowserCredential`; the runners run as -- 2.52.0 From 08cd05000c8b45607f75dad26549e6c4c2b2941a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 18:53:58 -0700 Subject: [PATCH 5/5] 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.' -- 2.52.0