ci: sign Windows builds with Azure Artifact Signing
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m19s
Build App (Preview) / test (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 5m18s
Build App (Preview) / build-windows (pull_request) Failing after 5m32s
Build App (Preview) / prune-previews (pull_request) Skipped
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 4s
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 3m19s
Build App (Preview) / test (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 5m18s
Build App (Preview) / build-windows (pull_request) Failing after 5m32s
Build App (Preview) / prune-previews (pull_request) Skipped
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) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,7 @@ on:
|
|||||||
- "app/**"
|
- "app/**"
|
||||||
- "VERSION"
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app-preview.yml"
|
- ".gitea/workflows/build-app-preview.yml"
|
||||||
|
- "scripts/windows-*.ps1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -690,16 +691,45 @@ jobs:
|
|||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
npm run build
|
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
|
- name: Build Tauri app
|
||||||
working-directory: ./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:
|
env:
|
||||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
|
||||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
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: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
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
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ on:
|
|||||||
- "app/**"
|
- "app/**"
|
||||||
- "VERSION"
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
|
- "scripts/windows-*.ps1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
# 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%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
npm run build
|
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
|
- name: Build Tauri app
|
||||||
working-directory: ./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:
|
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: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
rem Every Tauri bundler it downloads - candle.exe, light.exe and
|
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.
|
rem runner running as a normal user needs no such patch.
|
||||||
cargo tauri build --bundles msi,nsis
|
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
|
- name: Collect artifacts
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ screenshot_for_fix/
|
|||||||
|
|
||||||
# Package files pulled in by ad-hoc verification runs.
|
# Package files pulled in by ad-hoc verification runs.
|
||||||
*.deb
|
*.deb
|
||||||
|
|
||||||
|
# Windows CI code signing (scripts/windows-signing-setup.ps1)
|
||||||
|
.code-signing/
|
||||||
|
|||||||
@@ -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
|
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.
|
`--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
|
## Testing
|
||||||
|
|
||||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
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,54 @@
|
|||||||
|
# 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 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
|
||||||
@@ -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.'
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# windows-verify-signatures.ps1 <path-or-wildcard>... - 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."
|
||||||
Reference in New Issue
Block a user