Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .github/workflows/desktop-signing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
name: Desktop Windows signing

on:
workflow_dispatch:
inputs:
signing-mode:
description: Opt in to signing with the protected Azure Key Vault certificate
type: choice
options: [none, keyvault]
default: none
required: true
# A label permits validation before this workflow exists on main. New pushes
# run only the checks: remove/reapply the label to request signing a new head.
pull_request:
types: [opened, synchronize, reopened, labeled]
paths:
- desktop/**
- .github/workflows/desktop-signing.yml
- docs/windows-signing.md

permissions:
contents: read

concurrency:
group: desktop-windows-signing-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false

jobs:
checks:
name: Signing regressions (no Azure access)
runs-on: windows-2025
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: PowerShell signing regressions
shell: pwsh
run: ./desktop/scripts/test-windows-signing.ps1

sign:
name: Sign and verify app and NSIS installer
needs: checks
if: >-
(github.event_name == 'workflow_dispatch' && inputs.signing-mode == 'keyvault') ||
(github.event_name == 'pull_request' && github.event.action == 'labeled' &&
github.event.label.name == 'windows-signing' &&
github.event.pull_request.head.repo.full_name == github.repository)
environment: windows-signing
permissions:
contents: read
id-token: write
runs-on: windows-2025
timeout-minutes: 60
defaults:
run:
shell: pwsh
env:
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
AZURE_KEY_VAULT_URL: ${{ vars.AZURE_KEY_VAULT_URL }}
CODE_SIGNING_CERT_NAME: ${{ vars.CODE_SIGNING_CERT_NAME }}
SIGNING_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
AZURE_CORE_OUTPUT: none
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Check signing configuration
run: |
foreach ($name in @('AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID', 'AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Missing windows-signing environment variable: $name"
}
}
if ((git rev-parse HEAD) -ne $env:SIGNING_SOURCE_SHA) { throw 'Checkout does not match requested source SHA.' }
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Check certificate access
run: |
az keyvault certificate show --id "$env:AZURE_KEY_VAULT_URL/certificates/$env:CODE_SIGNING_CERT_NAME" --query id --output tsv --only-show-errors
if ($LASTEXITCODE -ne 0) { throw 'Could not read signing certificate.' }
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with:
toolchain: stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: desktop/src-tauri
key: windows-signing
save-if: false
- name: Install pinned AzureSignTool
run: |
$toolDirectory = Join-Path $env:RUNNER_TEMP 'azuresigntool-7.0.1'
New-Item -ItemType Directory -Path $toolDirectory -Force | Out-Null
$tool = Join-Path $toolDirectory 'AzureSignTool.exe'
Invoke-WebRequest 'https://github.com/vcsjones/AzureSignTool/releases/download/v7.0.1/AzureSignTool-x64.exe' -OutFile $tool
if ((Get-FileHash -LiteralPath $tool -Algorithm SHA256).Hash -ne 'DC85A3F24BCD5978C63FCFD167A9B41313AF9116722B99B831400F05F387FCBA') {
throw 'AzureSignTool download hash mismatch.'
}
$toolDirectory | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
- run: bun install --frozen-lockfile
working-directory: desktop
- run: bun run typecheck
working-directory: desktop
- name: Build and sign
env:
WINDOWS_SIGNING: keyvault
run: bun run tauri build --config src-tauri/tauri.windows-signing.conf.json --bundles nsis
working-directory: desktop
# Tauri restores the unsigned build output after bundling. Verify the app
# employees receive by extracting its signed payload from the installer.
- name: Extract signed app from NSIS installer
run: |
$installers = @(Get-ChildItem 'desktop/src-tauri/target/release/bundle/nsis/*-setup.exe' -File)
if ($installers.Count -ne 1) { throw 'Expected exactly one NSIS installer.' }
& 7z e $installers[0].FullName '-odesktop/signed-app' '-r' '-y' 'openbot-desktop.exe'
if ($LASTEXITCODE -ne 0) { throw 'Could not extract signed app from NSIS installer.' }
- name: Verify publisher, trust, and timestamp on both executables
run: ./desktop/scripts/verify-windows-signatures.ps1
- name: Retain verified binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openbot-windows-signed-${{ github.run_id }}-${{ github.run_attempt }}
path: |
desktop/signed-app/openbot-desktop.exe
desktop/src-tauri/target/release/bundle/nsis/*-setup.exe
if-no-files-found: error
retention-days: 14
- name: Retain verification evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openbot-windows-signatures-${{ github.run_id }}-${{ github.run_attempt }}
path: desktop/signing-evidence/
if-no-files-found: warn
retention-days: 14
39 changes: 39 additions & 0 deletions desktop/scripts/sign-windows.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#Requires -Version 7.0
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Path)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

if ($env:WINDOWS_SIGNING -ne 'keyvault') {
throw 'Signing requires WINDOWS_SIGNING=keyvault.'
}
foreach ($name in @('AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Missing required signing configuration: $name"
}
}
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Signing input does not exist: $Path"
}
$file = (Resolve-Path -LiteralPath $Path).Path
Get-Command az, AzureSignTool.exe -ErrorAction Stop | Out-Null

# Request at each invocation: Tauri may spend a long time building before it signs.
# Never put this token in GITHUB_ENV, outputs, a transcript, or a file. AzureSignTool
# accepts it through -kva; Tauri sees only this wrapper's non-secret command line.
try {
$env:AZURE_ACCESS_TOKEN = az account get-access-token --resource https://vault.azure.net --query accessToken --output tsv --only-show-errors
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($env:AZURE_ACCESS_TOKEN)) {
throw 'Could not obtain an Azure Key Vault access token.'
}
Write-Host "::add-mask::$env:AZURE_ACCESS_TOKEN"
& AzureSignTool.exe sign -fd sha256 -tr http://timestamp.digicert.com -td sha256 `
-kvu $env:AZURE_KEY_VAULT_URL -kvc $env:CODE_SIGNING_CERT_NAME `
-kva $env:AZURE_ACCESS_TOKEN -d OpenBot $file
if ($LASTEXITCODE -ne 0) {
throw "AzureSignTool failed for $file (exit $LASTEXITCODE)."
}
} finally {
Remove-Item Env:AZURE_ACCESS_TOKEN -ErrorAction SilentlyContinue
}
169 changes: 169 additions & 0 deletions desktop/scripts/test-windows-signing.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
#Requires -Version 7.0
# Command-boundary regressions; no Azure credentials, certificate store changes,
# network calls, or real signatures. The protected job verifies real artifacts.
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$passed = 0
function Assert-True([bool]$Condition, [string]$Message) {
if (-not $Condition) { throw $Message }
}
function Assert-Throws([scriptblock]$Operation, [string]$Message) {
try { & $Operation | Out-Null } catch {
if ($_.Exception.Message -notlike "*$Message*") { throw }
$script:passed++
return
}
throw "Expected failure containing: $Message"
}

$directory = Join-Path ([System.IO.Path]::GetTempPath()) "openbot signing $([Guid]::NewGuid())"
$installerDirectory = Join-Path $directory 'installers'
$evidenceDirectory = Join-Path $directory 'evidence'
New-Item -ItemType Directory -Path $installerDirectory -Force | Out-Null
$app = Join-Path $directory 'OpenBot app.exe'
$installer = Join-Path $installerDirectory 'OpenBot test-setup.exe'
Set-Content -LiteralPath $app -Value 'unsigned app fixture'
Set-Content -LiteralPath $installer -Value 'unsigned installer fixture'
$environmentNames = @('WINDOWS_SIGNING', 'AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME', 'AZURE_ACCESS_TOKEN')
$originalEnvironment = @{}
foreach ($name in $environmentNames) { $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) }

# Native exit statuses are separate from PowerShell exceptions. These stubs
# exercise exactly that boundary, including a successful command with empty output.
$global:SigningTestState = @{
azExit = 0; signExit = 0; verifyExit = 0; token = 'synthetic-test-token'
signCalls = 0; signArguments = @(); verifyCalls = @(); status = 'Valid'
publisher = 'Tawkit, Inc.'; timestamp = $true; invalidFile = ''
}
function global:az {
$global:LASTEXITCODE = $global:SigningTestState.azExit
$global:SigningTestState.token
}
function global:AzureSignTool.exe {
$global:SigningTestState.signCalls++
$global:SigningTestState.signArguments = $args
$global:LASTEXITCODE = $global:SigningTestState.signExit
}
function global:Test-SignTool {
$global:SigningTestState.verifyCalls += ,$args
$global:LASTEXITCODE = $global:SigningTestState.verifyExit
'Synthetic SignTool verification output'
}
function global:Get-AuthenticodeSignature {
param([string]$LiteralPath)
$certificate = [pscustomobject]@{ Subject = 'CN="Tawkit, Inc."'; Thumbprint = 'TEST-CERTIFICATE' }
$certificate | Add-Member ScriptMethod GetNameInfo { return $global:SigningTestState.publisher }
[pscustomobject]@{
Status = if ($global:SigningTestState.invalidFile -eq '' -or $LiteralPath -eq $global:SigningTestState.invalidFile) { $global:SigningTestState.status } else { 'Valid' }
SignerCertificate = $certificate
TimeStamperCertificate = if ($global:SigningTestState.timestamp) { $certificate } else { $null }
}
}

try {
$sign = Join-Path $PSScriptRoot 'sign-windows.ps1'
$verify = Join-Path $PSScriptRoot 'verify-windows-signatures.ps1'
$verifyParameters = @{
AppPath = $app; InstallerDirectory = $installerDirectory
EvidenceDirectory = $evidenceDirectory; SignToolPath = 'Test-SignTool'; SourceSha = 'test-source-sha'
}
$env:WINDOWS_SIGNING = ''
Assert-Throws { & $sign -Path $app } 'WINDOWS_SIGNING=keyvault'
$env:WINDOWS_SIGNING = 'keyvault'
foreach ($missing in @('AZURE_KEY_VAULT_URL', 'CODE_SIGNING_CERT_NAME')) {
$env:AZURE_KEY_VAULT_URL = 'https://test.vault.azure.net'
$env:CODE_SIGNING_CERT_NAME = 'test-certificate'
[Environment]::SetEnvironmentVariable($missing, '')
Assert-Throws { & $sign -Path $app } "Missing required signing configuration: $missing"
}
$env:CODE_SIGNING_CERT_NAME = 'test-certificate'
Assert-Throws { & $sign -Path (Join-Path $directory 'absent.exe') } 'Signing input does not exist'
Assert-True ($global:SigningTestState.signCalls -eq 0) 'Refused input reached signer.'
$global:SigningTestState.azExit = 1
Assert-Throws { & $sign -Path $app } 'Could not obtain'
Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived failed acquisition.'
$global:SigningTestState.azExit = 0
$global:SigningTestState.token = ''
Assert-Throws { & $sign -Path $app } 'Could not obtain'
$global:SigningTestState.token = 'synthetic-test-token'
$global:SigningTestState.signExit = 1
Assert-Throws { & $sign -Path $app } 'AzureSignTool failed'
Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived signer failure.'
$global:SigningTestState.signExit = 0
$signOutput = & $sign -Path $app 6>&1 | Out-String
Assert-True ($signOutput.Contains('::add-mask::synthetic-test-token')) 'Token was not registered for masking.'
Assert-True ($global:SigningTestState.signArguments[-1] -eq $app) 'Path with spaces was split.'
Assert-True ([string]::IsNullOrEmpty($env:AZURE_ACCESS_TOKEN)) 'Token survived successful signing.'
$passed++

# Both the app and installer must reject unsigned and tampered signatures.
foreach ($invalidFile in @($app, $installer)) {
$global:SigningTestState.invalidFile = $invalidFile
foreach ($status in @('NotSigned', 'HashMismatch', 'NotTrusted')) {
$global:SigningTestState.status = $status
Assert-Throws { & $verify @verifyParameters } "Invalid Authenticode signature on $([System.IO.Path]::GetFileName($invalidFile)): $status"
}
}
$global:SigningTestState.status = 'Valid'
$global:SigningTestState.publisher = 'Tawkit, Inc. imposter'
Assert-Throws { & $verify @verifyParameters } 'Unexpected publisher'
$global:SigningTestState.publisher = 'Tawkit, Inc.'
$global:SigningTestState.timestamp = $false
Assert-Throws { & $verify @verifyParameters } 'Missing timestamp'
$global:SigningTestState.timestamp = $true
foreach ($verifyExit in @(1, 2)) {
$global:SigningTestState.verifyExit = $verifyExit
Assert-Throws { & $verify @verifyParameters } 'SignTool verification failed'
}
$global:SigningTestState.verifyExit = 0
$global:SigningTestState.verifyCalls = @()
& $verify @verifyParameters | Out-Null
$report = Get-Content -LiteralPath (Join-Path $evidenceDirectory 'signatures.json') -Raw | ConvertFrom-Json
Assert-True ($report.files.Count -eq 2 -and $report.sourceSha -eq 'test-source-sha') 'Evidence does not identify both files and source.'
Assert-True ($report.files[0].sha256 -eq (Get-FileHash -LiteralPath $app).Hash) 'App evidence digest is incorrect.'
Assert-True ($report.files[1].sha256 -eq (Get-FileHash -LiteralPath $installer).Hash) 'Installer evidence digest is incorrect.'
Assert-True ($global:SigningTestState.verifyCalls.Count -eq 2) 'SignTool did not verify both files.'
foreach ($call in $global:SigningTestState.verifyCalls) {
Assert-True (($call[0..4] -join ' ') -eq 'verify /pa /all /v /tw') 'Trust or timestamp verification was omitted.'
}
$passed++
Remove-Item -LiteralPath $app
Assert-Throws { & $verify @verifyParameters } 'Application executable is missing'
Set-Content -LiteralPath $app -Value 'restored fixture'
Remove-Item -LiteralPath $installer
Assert-Throws { & $verify @verifyParameters } 'Expected exactly one NSIS installer'
Set-Content -LiteralPath $installer -Value 'restored installer'
Set-Content -LiteralPath (Join-Path $installerDirectory 'stale-setup.exe') -Value 'stale installer'
Assert-Throws { & $verify @verifyParameters } 'Expected exactly one NSIS installer'

# Tauri restores the unsigned build output after packaging. Exercise the
# default paths against that real layout, without overriding AppPath.
$layout = Join-Path $directory 'packaged desktop'
$release = Join-Path $layout 'src-tauri/target/release'
foreach ($relative in @('scripts', 'signed-app', 'src-tauri/target/release/bundle/nsis')) {
New-Item -ItemType Directory -Path (Join-Path $layout $relative) -Force | Out-Null
}
Copy-Item -LiteralPath $verify -Destination (Join-Path $layout 'scripts/verify-windows-signatures.ps1')
$restored = Join-Path $release 'openbot-desktop.exe'
$payload = Join-Path $layout 'signed-app/openbot-desktop.exe'
Set-Content -LiteralPath $restored -Value 'unsigned restored build output'
Set-Content -LiteralPath $payload -Value 'signed installer payload'
Set-Content -LiteralPath (Join-Path $release 'bundle/nsis/OpenBot test-setup.exe') -Value 'signed installer'
$global:SigningTestState.invalidFile = $restored
$global:SigningTestState.status = 'NotSigned'
& (Join-Path $layout 'scripts/verify-windows-signatures.ps1') -SignToolPath Test-SignTool -SourceSha test-source-sha | Out-Null
$payloadReport = Get-Content (Join-Path $layout 'signing-evidence/signatures.json') -Raw | ConvertFrom-Json
Assert-True ($payloadReport.files[0].sha256 -eq (Get-FileHash -LiteralPath $payload).Hash) 'Default verification selected restored build output instead of installer payload.'
$passed++

$baseConfig = Get-Content "$PSScriptRoot/../src-tauri/tauri.conf.json" -Raw | ConvertFrom-Json -AsHashtable
Assert-True (-not $baseConfig.bundle.ContainsKey('windows') -or -not $baseConfig.bundle.windows.ContainsKey('signCommand')) 'Base Tauri build enables signing.'
Assert-True (-not (Test-Path "$PSScriptRoot/../src-tauri/tauri.windows.conf.json")) 'Signing overlay could be loaded automatically.'
$passed++
Write-Host "Passed $passed Windows signing regression cases."
} finally {
foreach ($name in $environmentNames) { [Environment]::SetEnvironmentVariable($name, $originalEnvironment[$name]) }
Remove-Item Function:az, Function:AzureSignTool.exe, Function:Test-SignTool, Function:Get-AuthenticodeSignature
Remove-Variable SigningTestState -Scope Global
Remove-Item -LiteralPath $directory -Recurse -Force
}
Loading
Loading