Skip to content

Commit 6a8f552

Browse files
authored
feat(desktop): sign Windows builds through Azure Key Vault (#534)
* feat(desktop): add Azure Key Vault Windows signing * fix(desktop): verify the signed app packaged inside NSIS
1 parent 841e9f0 commit 6a8f552

8 files changed

Lines changed: 554 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
name: Desktop Windows signing
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
signing-mode:
7+
description: Opt in to signing with the protected Azure Key Vault certificate
8+
type: choice
9+
options: [none, keyvault]
10+
default: none
11+
required: true
12+
# A label permits validation before this workflow exists on main. New pushes
13+
# run only the checks: remove/reapply the label to request signing a new head.
14+
pull_request:
15+
types: [opened, synchronize, reopened, labeled]
16+
paths:
17+
- desktop/**
18+
- .github/workflows/desktop-signing.yml
19+
- docs/windows-signing.md
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: desktop-windows-signing-${{ github.event.pull_request.number || github.ref }}
26+
cancel-in-progress: false
27+
28+
jobs:
29+
checks:
30+
name: Signing regressions (no Azure access)
31+
runs-on: windows-2025
32+
timeout-minutes: 10
33+
steps:
34+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35+
with:
36+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
37+
persist-credentials: false
38+
- name: PowerShell signing regressions
39+
shell: pwsh
40+
run: ./desktop/scripts/test-windows-signing.ps1
41+
42+
sign:
43+
name: Sign and verify app and NSIS installer
44+
needs: checks
45+
if: >-
46+
(github.event_name == 'workflow_dispatch' && inputs.signing-mode == 'keyvault') ||
47+
(github.event_name == 'pull_request' && github.event.action == 'labeled' &&
48+
github.event.label.name == 'windows-signing' &&
49+
github.event.pull_request.head.repo.full_name == github.repository)
50+
environment: windows-signing
51+
permissions:
52+
contents: read
53+
id-token: write
54+
runs-on: windows-2025
55+
timeout-minutes: 60
56+
defaults:
57+
run:
58+
shell: pwsh
59+
env:
60+
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
61+
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
62+
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
63+
AZURE_KEY_VAULT_URL: ${{ vars.AZURE_KEY_VAULT_URL }}
64+
CODE_SIGNING_CERT_NAME: ${{ vars.CODE_SIGNING_CERT_NAME }}
65+
SIGNING_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
66+
AZURE_CORE_OUTPUT: none
67+
steps:
68+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69+
with:
70+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
71+
persist-credentials: false
72+
- name: Check signing configuration
73+
run: |
74+
foreach ($name in @('AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', 'AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) {
75+
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
76+
throw "Missing windows-signing environment variable: $name"
77+
}
78+
}
79+
if ((git rev-parse HEAD) -ne $env:SIGNING_SOURCE_SHA) { throw 'Checkout does not match requested source SHA.' }
80+
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
81+
with:
82+
client-id: ${{ vars.AZURE_CLIENT_ID }}
83+
tenant-id: ${{ vars.AZURE_TENANT_ID }}
84+
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
85+
- name: Check certificate access
86+
run: |
87+
az keyvault certificate show --id "$env:AZURE_KEY_VAULT_URL/certificates/$env:CODE_SIGNING_CERT_NAME" --query id --output tsv --only-show-errors
88+
if ($LASTEXITCODE -ne 0) { throw 'Could not read signing certificate.' }
89+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
90+
with:
91+
bun-version: 1.3.14
92+
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
93+
with:
94+
toolchain: stable
95+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
96+
with:
97+
workspaces: desktop/src-tauri
98+
key: windows-signing
99+
save-if: false
100+
- name: Install pinned AzureSignTool
101+
run: |
102+
$toolDirectory = Join-Path $env:RUNNER_TEMP 'azuresigntool-7.0.1'
103+
New-Item -ItemType Directory -Path $toolDirectory -Force | Out-Null
104+
$tool = Join-Path $toolDirectory 'AzureSignTool.exe'
105+
Invoke-WebRequest 'https://github.com/vcsjones/AzureSignTool/releases/download/v7.0.1/AzureSignTool-x64.exe' -OutFile $tool
106+
if ((Get-FileHash -LiteralPath $tool -Algorithm SHA256).Hash -ne 'DC85A3F24BCD5978C63FCFD167A9B41313AF9116722B99B831400F05F387FCBA') {
107+
throw 'AzureSignTool download hash mismatch.'
108+
}
109+
$toolDirectory | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
110+
- run: bun install --frozen-lockfile
111+
working-directory: desktop
112+
- run: bun run typecheck
113+
working-directory: desktop
114+
- name: Build and sign
115+
env:
116+
WINDOWS_SIGNING: keyvault
117+
run: bun run tauri build --config src-tauri/tauri.windows-signing.conf.json --bundles nsis
118+
working-directory: desktop
119+
# Tauri restores the unsigned build output after bundling. Verify the app
120+
# employees receive by extracting its signed payload from the installer.
121+
- name: Extract signed app from NSIS installer
122+
run: |
123+
$installers = @(Get-ChildItem 'desktop/src-tauri/target/release/bundle/nsis/*-setup.exe' -File)
124+
if ($installers.Count -ne 1) { throw 'Expected exactly one NSIS installer.' }
125+
& 7z e $installers[0].FullName '-odesktop/signed-app' '-r' '-y' 'openbot-desktop.exe'
126+
if ($LASTEXITCODE -ne 0) { throw 'Could not extract signed app from NSIS installer.' }
127+
- name: Verify publisher, trust, and timestamp on both executables
128+
run: ./desktop/scripts/verify-windows-signatures.ps1
129+
- name: Retain verified binaries
130+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
131+
with:
132+
name: openbot-windows-signed-${{ github.run_id }}-${{ github.run_attempt }}
133+
path: |
134+
desktop/signed-app/openbot-desktop.exe
135+
desktop/src-tauri/target/release/bundle/nsis/*-setup.exe
136+
if-no-files-found: error
137+
retention-days: 14
138+
- name: Retain verification evidence
139+
if: ${{ always() }}
140+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
141+
with:
142+
name: openbot-windows-signatures-${{ github.run_id }}-${{ github.run_attempt }}
143+
path: desktop/signing-evidence/
144+
if-no-files-found: warn
145+
retention-days: 14

‎desktop/scripts/sign-windows.ps1‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#Requires -Version 7.0
2+
[CmdletBinding()]
3+
param([Parameter(Mandatory)][string]$Path)
4+
5+
$ErrorActionPreference = 'Stop'
6+
Set-StrictMode -Version Latest
7+
8+
if ($env:WINDOWS_SIGNING -ne 'keyvault') {
9+
throw 'Signing requires WINDOWS_SIGNING=keyvault.'
10+
}
11+
foreach ($name in @('AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) {
12+
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
13+
throw "Missing required signing configuration: $name"
14+
}
15+
}
16+
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
17+
throw "Signing input does not exist: $Path"
18+
}
19+
$file = (Resolve-Path -LiteralPath $Path).Path
20+
Get-Command az, AzureSignTool.exe -ErrorAction Stop | Out-Null
21+
22+
# Request at each invocation: Tauri may spend a long time building before it signs.
23+
# Never put this token in GITHUB_ENV, outputs, a transcript, or a file. AzureSignTool
24+
# accepts it through -kva; Tauri sees only this wrapper's non-secret command line.
25+
try {
26+
$env:AZURE_ACCESS_TOKEN = az account get-access-token --resource https://vault.azure.net --query accessToken --output tsv --only-show-errors
27+
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($env:AZURE_ACCESS_TOKEN)) {
28+
throw 'Could not obtain an Azure Key Vault access token.'
29+
}
30+
Write-Host "::add-mask::$env:AZURE_ACCESS_TOKEN"
31+
& AzureSignTool.exe sign -fd sha256 -tr http://timestamp.digicert.com -td sha256 `
32+
-kvu $env:AZURE_KEY_VAULT_URL -kvc $env:CODE_SIGNING_CERT_NAME `
33+
-kva $env:AZURE_ACCESS_TOKEN -d OpenBot $file
34+
if ($LASTEXITCODE -ne 0) {
35+
throw "AzureSignTool failed for $file (exit $LASTEXITCODE)."
36+
}
37+
} finally {
38+
Remove-Item Env:AZURE_ACCESS_TOKEN -ErrorAction SilentlyContinue
39+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#Requires -Version 7.0
2+
# Command-boundary regressions; no Azure credentials, certificate store changes,
3+
# network calls, or real signatures. The protected job verifies real artifacts.
4+
$ErrorActionPreference = 'Stop'
5+
Set-StrictMode -Version Latest
6+
$passed = 0
7+
function Assert-True([bool]$Condition, [string]$Message) {
8+
if (-not $Condition) { throw $Message }
9+
}
10+
function Assert-Throws([scriptblock]$Operation, [string]$Message) {
11+
try { & $Operation | Out-Null } catch {
12+
if ($_.Exception.Message -notlike "*$Message*") { throw }
13+
$script:passed++
14+
return
15+
}
16+
throw "Expected failure containing: $Message"
17+
}
18+
19+
$directory = Join-Path ([System.IO.Path]::GetTempPath()) "openbot signing $([Guid]::NewGuid())"
20+
$installerDirectory = Join-Path $directory 'installers'
21+
$evidenceDirectory = Join-Path $directory 'evidence'
22+
New-Item -ItemType Directory -Path $installerDirectory -Force | Out-Null
23+
$app = Join-Path $directory 'OpenBot app.exe'
24+
$installer = Join-Path $installerDirectory 'OpenBot test-setup.exe'
25+
Set-Content -LiteralPath $app -Value 'unsigned app fixture'
26+
Set-Content -LiteralPath $installer -Value 'unsigned installer fixture'
27+
$environmentNames = @('WINDOWS_SIGNING', 'AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME', 'AZURE_ACCESS_TOKEN')
28+
$originalEnvironment = @{}
29+
foreach ($name in $environmentNames) { $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) }
30+
31+
# Native exit statuses are separate from PowerShell exceptions. These stubs
32+
# exercise exactly that boundary, including a successful command with empty output.
33+
$global:SigningTestState = @{
34+
azExit = 0; signExit = 0; verifyExit = 0; token = 'synthetic-test-token'
35+
signCalls = 0; signArguments = @(); verifyCalls = @(); status = 'Valid'
36+
publisher = 'Tawkit, Inc.'; timestamp = $true; invalidFile = ''
37+
}
38+
function global:az {
39+
$global:LASTEXITCODE = $global:SigningTestState.azExit
40+
$global:SigningTestState.token
41+
}
42+
function global:AzureSignTool.exe {
43+
$global:SigningTestState.signCalls++
44+
$global:SigningTestState.signArguments = $args
45+
$global:LASTEXITCODE = $global:SigningTestState.signExit
46+
}
47+
function global:Test-SignTool {
48+
$global:SigningTestState.verifyCalls += ,$args
49+
$global:LASTEXITCODE = $global:SigningTestState.verifyExit
50+
'Synthetic SignTool verification output'
51+
}
52+
function global:Get-AuthenticodeSignature {
53+
param([string]$LiteralPath)
54+
$certificate = [pscustomobject]@{ Subject = 'CN="Tawkit, Inc."'; Thumbprint = 'TEST-CERTIFICATE' }
55+
$certificate | Add-Member ScriptMethod GetNameInfo { return $global:SigningTestState.publisher }
56+
[pscustomobject]@{
57+
Status = if ($global:SigningTestState.invalidFile -eq '' -or $LiteralPath -eq $global:SigningTestState.invalidFile) { $global:SigningTestState.status } else { 'Valid' }
58+
SignerCertificate = $certificate
59+
TimeStamperCertificate = if ($global:SigningTestState.timestamp) { $certificate } else { $null }
60+
}
61+
}
62+
63+
try {
64+
$sign = Join-Path $PSScriptRoot 'sign-windows.ps1'
65+
$verify = Join-Path $PSScriptRoot 'verify-windows-signatures.ps1'
66+
$verifyParameters = @{
67+
AppPath = $app; InstallerDirectory = $installerDirectory
68+
EvidenceDirectory = $evidenceDirectory; SignToolPath = 'Test-SignTool'; SourceSha = 'test-source-sha'
69+
}
70+
$env:WINDOWS_SIGNING = ''
71+
Assert-Throws { & $sign -Path $app } 'WINDOWS_SIGNING=keyvault'
72+
$env:WINDOWS_SIGNING = 'keyvault'
73+
foreach ($missing in @('AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) {
74+
$env:AZURE_KEY_VAULT_URL = 'https://test.vault.azure.net'
75+
$env:CODE_SIGNING_CERT_NAME = 'test-certificate'
76+
[Environment]::SetEnvironmentVariable($missing, '')
77+
Assert-Throws { & $sign -Path $app } "Missing required signing configuration: $missing"
78+
}
79+
$env:CODE_SIGNING_CERT_NAME = 'test-certificate'
80+
Assert-Throws { & $sign -Path (Join-Path $directory 'absent.exe') } 'Signing input does not exist'
81+
Assert-True ($global:SigningTestState.signCalls -eq 0) 'Refused input reached signer.'
82+
$global:SigningTestState.azExit = 1
83+
Assert-Throws { & $sign -Path $app } 'Could not obtain'
84+
Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived failed acquisition.'
85+
$global:SigningTestState.azExit = 0
86+
$global:SigningTestState.token = ''
87+
Assert-Throws { & $sign -Path $app } 'Could not obtain'
88+
$global:SigningTestState.token = 'synthetic-test-token'
89+
$global:SigningTestState.signExit = 1
90+
Assert-Throws { & $sign -Path $app } 'AzureSignTool failed'
91+
Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived signer failure.'
92+
$global:SigningTestState.signExit = 0
93+
$signOutput = & $sign -Path $app 6>&1 | Out-String
94+
Assert-True ($signOutput.Contains('::add-mask::synthetic-test-token')) 'Token was not registered for masking.'
95+
Assert-True ($global:SigningTestState.signArguments[-1] -eq $app) 'Path with spaces was split.'
96+
Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived successful signing.'
97+
$passed++
98+
99+
# Both the app and installer must reject unsigned and tampered signatures.
100+
foreach ($invalidFile in @($app, $installer)) {
101+
$global:SigningTestState.invalidFile = $invalidFile
102+
foreach ($status in @('NotSigned', 'HashMismatch', 'NotTrusted')) {
103+
$global:SigningTestState.status = $status
104+
Assert-Throws { & $verify @verifyParameters } "Invalid Authenticode signature on $([System.IO.Path]::GetFileName($invalidFile)): $status"
105+
}
106+
}
107+
$global:SigningTestState.status = 'Valid'
108+
$global:SigningTestState.publisher = 'Tawkit, Inc. imposter'
109+
Assert-Throws { & $verify @verifyParameters } 'Unexpected publisher'
110+
$global:SigningTestState.publisher = 'Tawkit, Inc.'
111+
$global:SigningTestState.timestamp = $false
112+
Assert-Throws { & $verify @verifyParameters } 'Missing timestamp'
113+
$global:SigningTestState.timestamp = $true
114+
foreach ($verifyExit in @(1, 2)) {
115+
$global:SigningTestState.verifyExit = $verifyExit
116+
Assert-Throws { & $verify @verifyParameters } 'SignTool verification failed'
117+
}
118+
$global:SigningTestState.verifyExit = 0
119+
$global:SigningTestState.verifyCalls = @()
120+
& $verify @verifyParameters | Out-Null
121+
$report = Get-Content -LiteralPath (Join-Path $evidenceDirectory 'signatures.json') -Raw | ConvertFrom-Json
122+
Assert-True ($report.files.Count -eq 2 -and $report.sourceSha -eq 'test-source-sha') 'Evidence does not identify both files and source.'
123+
Assert-True ($report.files[0].sha256 -eq (Get-FileHash -LiteralPath $app).Hash) 'App evidence digest is incorrect.'
124+
Assert-True ($report.files[1].sha256 -eq (Get-FileHash -LiteralPath $installer).Hash) 'Installer evidence digest is incorrect.'
125+
Assert-True ($global:SigningTestState.verifyCalls.Count -eq 2) 'SignTool did not verify both files.'
126+
foreach ($call in $global:SigningTestState.verifyCalls) {
127+
Assert-True (($call[0..4] -join ' ') -eq 'verify /pa /all /v /tw') 'Trust or timestamp verification was omitted.'
128+
}
129+
$passed++
130+
Remove-Item -LiteralPath $app
131+
Assert-Throws { & $verify @verifyParameters } 'Application executable is missing'
132+
Set-Content -LiteralPath $app -Value 'restored fixture'
133+
Remove-Item -LiteralPath $installer
134+
Assert-Throws { & $verify @verifyParameters } 'Expected exactly one NSIS installer'
135+
Set-Content -LiteralPath $installer -Value 'restored installer'
136+
Set-Content -LiteralPath (Join-Path $installerDirectory 'stale-setup.exe') -Value 'stale installer'
137+
Assert-Throws { & $verify @verifyParameters } 'Expected exactly one NSIS installer'
138+
139+
# Tauri restores the unsigned build output after packaging. Exercise the
140+
# default paths against that real layout, without overriding AppPath.
141+
$layout = Join-Path $directory 'packaged desktop'
142+
$release = Join-Path $layout 'src-tauri/target/release'
143+
foreach ($relative in @('scripts', 'signed-app', 'src-tauri/target/release/bundle/nsis')) {
144+
New-Item -ItemType Directory -Path (Join-Path $layout $relative) -Force | Out-Null
145+
}
146+
Copy-Item -LiteralPath $verify -Destination (Join-Path $layout 'scripts/verify-windows-signatures.ps1')
147+
$restored = Join-Path $release 'openbot-desktop.exe'
148+
$payload = Join-Path $layout 'signed-app/openbot-desktop.exe'
149+
Set-Content -LiteralPath $restored -Value 'unsigned restored build output'
150+
Set-Content -LiteralPath $payload -Value 'signed installer payload'
151+
Set-Content -LiteralPath (Join-Path $release 'bundle/nsis/OpenBot test-setup.exe') -Value 'signed installer'
152+
$global:SigningTestState.invalidFile = $restored
153+
$global:SigningTestState.status = 'NotSigned'
154+
& (Join-Path $layout 'scripts/verify-windows-signatures.ps1') -SignToolPath Test-SignTool -SourceSha test-source-sha | Out-Null
155+
$payloadReport = Get-Content (Join-Path $layout 'signing-evidence/signatures.json') -Raw | ConvertFrom-Json
156+
Assert-True ($payloadReport.files[0].sha256 -eq (Get-FileHash -LiteralPath $payload).Hash) 'Default verification selected restored build output instead of installer payload.'
157+
$passed++
158+
159+
$baseConfig = Get-Content "$PSScriptRoot/../src-tauri/tauri.conf.json" -Raw | ConvertFrom-Json -AsHashtable
160+
Assert-True (-not $baseConfig.bundle.ContainsKey('windows') -or -not $baseConfig.bundle.windows.ContainsKey('signCommand')) 'Base Tauri build enables signing.'
161+
Assert-True (-not (Test-Path "$PSScriptRoot/../src-tauri/tauri.windows.conf.json")) 'Signing overlay could be loaded automatically.'
162+
$passed++
163+
Write-Host "Passed $passed Windows signing regression cases."
164+
} finally {
165+
foreach ($name in $environmentNames) { [Environment]::SetEnvironmentVariable($name, $originalEnvironment[$name]) }
166+
Remove-Item Function:az, Function:AzureSignTool.exe, Function:Test-SignTool, Function:Get-AuthenticodeSignature
167+
Remove-Variable SigningTestState -Scope Global
168+
Remove-Item -LiteralPath $directory -Recurse -Force
169+
}

0 commit comments

Comments
 (0)