From 44e9bd291667c11d73f2f0149d61e931a9368e1e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 23 Sep 2026 17:57:32 -0700 Subject: [PATCH] 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."