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."