CI: sign Windows builds with Azure Artifact Signing #62
@@ -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.
|
||||
@@ -669,7 +671,10 @@ 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, 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
|
||||
run: |
|
||||
@@ -692,13 +697,21 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
# 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:
|
||||
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 }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo tauri build
|
||||
cargo tauri build --config "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
|
||||
- name: Collect artifacts
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
@@ -611,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: |
|
||||
@@ -632,10 +637,31 @@ 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
|
||||
# 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 }}
|
||||
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.
|
||||
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
|
||||
@@ -650,7 +676,20 @@ 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: >-
|
||||
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass
|
||||
-File scripts\windows-verify-signatures.ps1
|
||||
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: |
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -836,6 +836,72 @@ 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
|
||||
|
||||
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,
|
||||
**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
|
||||
`AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` from the environment.
|
||||
**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 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
|
||||
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
|
||||
SYSTEM, where that waits forever for a browser.
|
||||
- **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.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# windows-sign.ps1 <file> - 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 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
|
||||
# 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'
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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, 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.
|
||||
# * 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 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,
|
||||
# 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'
|
||||
$ClientSha512 = '98f06a691f4fc2fa22f19dcf8556733e98607fbef91a312c453b9b0798cc9088dae0acb36e389b552a11b4d2320324785b8541c2b51091a724c05bc5df5cbf95'
|
||||
$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' 'SHA512' $ClientSha512
|
||||
$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. beforeBuildCommand is
|
||||
# blanked because the job builds the frontend in its own step.
|
||||
$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
|
||||
$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'
|
||||
$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.
|
||||
$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"
|
||||
"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"
|
||||
)
|
||||
[IO.File]::AppendAllText($env:GITHUB_ENV, (($lines -join "`n") + "`n"), $utf8)
|
||||
Write-Host 'Code signing prepared.'
|
||||
@@ -0,0 +1,107 @@
|
||||
# windows-verify-signatures.ps1 <path-or-wildcard>... - 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)
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
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'
|
||||
$output = & $env:TRIPLE_C_SIGNTOOL verify /pa $File.FullName 2>&1 | ForEach-Object { "$_" }
|
||||
$signtoolOk = ($LASTEXITCODE -eq 0)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $signtoolOk) { $output | 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 $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
|
||||
}
|
||||
}
|
||||
|
||||
# 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) {
|
||||
$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)"
|
||||
}
|
||||
$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 $($uninstaller[-1])"
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed.Count -gt 0) { throw "Not validly signed: $($failed -join ', ')" }
|
||||
Write-Host 'Everything that ships is signed and timestamped.'
|
||||
Reference in New Issue
Block a user