From cc66522948d273522c52a79ea45f009427bdc0b7 Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 14 Sep 2026 09:54:30 -0700 Subject: [PATCH 1/2] feat(desktop): add Azure Key Vault Windows signing --- .github/workflows/desktop-signing.yml | 137 ++++++++++++++++ desktop/scripts/sign-windows.ps1 | 39 +++++ desktop/scripts/test-windows-signing.ps1 | 149 ++++++++++++++++++ desktop/scripts/verify-windows-signatures.ps1 | 78 +++++++++ desktop/signing/azure-federation.json | 7 + .../src-tauri/tauri.windows-signing.conf.json | 18 +++ docs/README.md | 1 + docs/windows-signing.md | 94 +++++++++++ 8 files changed, 523 insertions(+) create mode 100644 .github/workflows/desktop-signing.yml create mode 100644 desktop/scripts/sign-windows.ps1 create mode 100644 desktop/scripts/test-windows-signing.ps1 create mode 100644 desktop/scripts/verify-windows-signatures.ps1 create mode 100644 desktop/signing/azure-federation.json create mode 100644 desktop/src-tauri/tauri.windows-signing.conf.json create mode 100644 docs/windows-signing.md diff --git a/.github/workflows/desktop-signing.yml b/.github/workflows/desktop-signing.yml new file mode 100644 index 000000000..b77891e1d --- /dev/null +++ b/.github/workflows/desktop-signing.yml @@ -0,0 +1,137 @@ +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 + - 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/src-tauri/target/release/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 diff --git a/desktop/scripts/sign-windows.ps1 b/desktop/scripts/sign-windows.ps1 new file mode 100644 index 000000000..f4319b0f9 --- /dev/null +++ b/desktop/scripts/sign-windows.ps1 @@ -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 +} diff --git a/desktop/scripts/test-windows-signing.ps1 b/desktop/scripts/test-windows-signing.ps1 new file mode 100644 index 000000000..d78914176 --- /dev/null +++ b/desktop/scripts/test-windows-signing.ps1 @@ -0,0 +1,149 @@ +#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' + + $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 +} diff --git a/desktop/scripts/verify-windows-signatures.ps1 b/desktop/scripts/verify-windows-signatures.ps1 new file mode 100644 index 000000000..28773f991 --- /dev/null +++ b/desktop/scripts/verify-windows-signatures.ps1 @@ -0,0 +1,78 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string]$AppPath = "$PSScriptRoot/../src-tauri/target/release/openbot-desktop.exe", + [string]$InstallerDirectory = "$PSScriptRoot/../src-tauri/target/release/bundle/nsis", + [string]$EvidenceDirectory = "$PSScriptRoot/../signing-evidence", + [string]$SignToolPath, + [string]$SourceSha = $env:SIGNING_SOURCE_SHA +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not (Test-Path -LiteralPath $AppPath -PathType Leaf)) { + throw "Application executable is missing: $AppPath" +} +$installers = @(Get-ChildItem -LiteralPath $InstallerDirectory -Filter '*-setup.exe' -File) +if ($installers.Count -ne 1) { + throw "Expected exactly one NSIS installer, found $($installers.Count)." +} +if (-not $SignToolPath) { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($command) { + $SignToolPath = $command.Source + } else { + $sdkTools = @(Get-ChildItem -Path "${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/signtool.exe" -File | + Sort-Object FullName -Descending) + if ($sdkTools.Count -eq 0) { + throw 'Windows SDK signtool.exe was not found.' + } + $SignToolPath = $sdkTools[0].FullName + } +} + +New-Item -ItemType Directory -Path $EvidenceDirectory -Force | Out-Null +$records = @() +foreach ($file in @((Get-Item -LiteralPath $AppPath), $installers[0])) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne 'Valid') { + throw "Invalid Authenticode signature on $($file.Name): $($signature.Status)" + } + $publisher = $signature.SignerCertificate.GetNameInfo( + [System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + if ($publisher -cne 'Tawkit, Inc.') { + throw "Unexpected publisher on $($file.Name): $publisher" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Missing timestamp on $($file.Name)." + } + + # /pa applies Authenticode policy; /all checks every signature. /tw produces + # exit 2 when a timestamp is absent, and warnings fail this gate too. + $verification = & $SignToolPath verify /pa /all /v /tw $file.FullName 2>&1 + $verificationExit = $LASTEXITCODE + $verification | Set-Content -LiteralPath (Join-Path $EvidenceDirectory "$($file.Name).signtool.txt") + $verification | Write-Output + if ($verificationExit -ne 0) { + throw "SignTool verification failed for $($file.Name) (exit $verificationExit)." + } + $records += [ordered]@{ + file = $file.Name + sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + status = [string]$signature.Status + publisher = $publisher + signerSubject = $signature.SignerCertificate.Subject + signerThumbprint = $signature.SignerCertificate.Thumbprint + timestampSubject = $signature.TimeStamperCertificate.Subject + timestampThumbprint = $signature.TimeStamperCertificate.Thumbprint + signtoolExitCode = $verificationExit + } +} + +[ordered]@{ + sourceSha = $SourceSha + verifiedAtUtc = [DateTime]::UtcNow.ToString('o') + files = $records +} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $EvidenceDirectory 'signatures.json') +Write-Host "Verified app and NSIS installer; evidence: $EvidenceDirectory" diff --git a/desktop/signing/azure-federation.json b/desktop/signing/azure-federation.json new file mode 100644 index 000000000..2f547bedf --- /dev/null +++ b/desktop/signing/azure-federation.json @@ -0,0 +1,7 @@ +{ + "name": "openbot-windows-signing", + "issuer": "https://token.actions.githubusercontent.com", + "subject": "repo:CopilotKit@131273140/OpenBot@1336502227:environment:windows-signing", + "description": "OpenBot protected Windows signing workflow; existing DigiCert certificate", + "audiences": ["api://AzureADTokenExchange"] +} diff --git a/desktop/src-tauri/tauri.windows-signing.conf.json b/desktop/src-tauri/tauri.windows-signing.conf.json new file mode 100644 index 000000000..cc5e119ce --- /dev/null +++ b/desktop/src-tauri/tauri.windows-signing.conf.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["nsis"], + "windows": { + "signCommand": { + "cmd": "pwsh", + "args": [ + "-NoProfile", + "-NonInteractive", + "-File", + "../scripts/sign-windows.ps1", + "%1" + ] + } + } + } +} diff --git a/docs/README.md b/docs/README.md index 6b7c66687..efc5ee1d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,5 +13,6 @@ Start with the root [README](../README.md), then use these references: - [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes. - [Kubernetes](../charts/openbot/README.md): the Helm chart, what a cluster needs before it, and the values that differ per cloud. - [Releasing](releasing.md): how a release is proposed, reviewed and published. +- [Windows desktop signing](windows-signing.md): protected Azure Key Vault signing and verification of the app and NSIS installer. Do not include credential values, customer data, transcripts, or local-only notes in public docs. diff --git a/docs/windows-signing.md b/docs/windows-signing.md new file mode 100644 index 000000000..72b8a6aaf --- /dev/null +++ b/docs/windows-signing.md @@ -0,0 +1,94 @@ +# Windows desktop signing + +The [Desktop Windows signing workflow](../.github/workflows/desktop-signing.yml) +builds OpenBot and its NSIS installer with the existing DigiCert certificate in +Azure Key Vault. It retains verified binaries and signature evidence as Actions +artifacts for 14 days. It does not create or publish a release. Desktop version +`0.0.0` remains a validation build. + +Ordinary Desktop CI and fork PR builds remain unsigned. The +`tauri.windows-signing.conf.json` overlay is passed explicitly to Tauri only by +the protected signing job. Do not rename it `tauri.windows.conf.json`: Tauri +automatically merges that filename into every Windows build. + +## Request a signed validation build + +Before this workflow is merged to `main`, add the `windows-signing` label to a +same-repository PR and approve its `windows-signing` environment deployment. +The workflow checks out the exact PR head SHA from the labeling event. New +pushes run the regression job; remove and reapply the label to sign the new SHA. +Fork PRs cannot enter this signing job. There is no `pull_request_target` trigger. + +After merge, use **Actions → Desktop Windows signing → Run workflow**, select the +ref to validate, and set `signing-mode` to `keyvault`. The default `none` runs +only credential-free regressions. Environment reviewers should check the exact +source SHA and workflow changes before approving access to the publisher's key. + +A successful signing run verifies **both** `openbot-desktop.exe` and the single +`*-setup.exe` installer using Windows Authenticode and +`signtool verify /pa /all /v /tw`. Signatures must be valid, timestamped, and have +publisher `Tawkit, Inc.`. Any warning or nonzero SignTool exit fails the job. +`signatures.json` records the source SHA, artifact SHA-256 hashes, signer and +timestamp certificates; the companion text files retain verbose SignTool output. +The binaries upload only after both pass. These checks do not test SmartScreen +reputation or exercise the app UI. + +## One-time infrastructure setup + +Use the protected GitHub environment `windows-signing` with required reviewers. +Configure these environment **variables**; they contain public identifiers, not +passwords: + +| Variable | Value | +| --- | --- | +| `AZURE_CLIENT_ID` | `cb923310-e793-4557-929e-b33e49a42297` | +| `AZURE_TENANT_ID` | `c3050389-57ad-4c62-8dcd-fe5e2af4fbce` | +| `AZURE_SUBSCRIPTION_ID` | Subscription containing `cpk-signing-kv` | +| `AZURE_KEY_VAULT_URL` | `https://cpk-signing-kv.vault.azure.net` | +| `CODE_SIGNING_CERT_NAME` | `code-signing` | + +An owner of the existing Entra application, or an appropriately authorized +application administrator, must add the federated credential. From the repo root: + +```sh +az ad app federated-credential create \ + --id cb923310-e793-4557-929e-b33e49a42297 \ + --parameters desktop/signing/azure-federation.json +``` + +Check existing credentials first; do not duplicate or replace another repository's +credential. The subject in the checked-in JSON is OpenBot's verified immutable +subject, including owner and repository IDs, scoped to this environment. An +`Insufficient privileges` response requires an authorized app owner/administrator +to run the command; GitHub environment approval does not grant Entra permissions. + +The existing signing identity needs certificate read and key sign permissions. +With Key Vault RBAC, **Key Vault Certificate User** plus **Key Vault Crypto User** +cover these operations; Crypto User alone does not grant certificate read access. +Reuse the existing certificate and permissions where already provisioned. No +client secret, exported private key, PFX, or Tauri updater signing key is needed. + +The workflow pins Azure Login and AzureSignTool 7.0.1, checks the downloaded +tool's SHA-256, and obtains Key Vault access tokens through GitHub OIDC. The +wrapper refreshes the token for each signing invocation, registers it for log +masking, and clears its process environment afterward. Tokens are never written +to workflow outputs, `GITHUB_ENV`, or artifacts. Tauri invokes the wrapper for +the app and installer, as well as NSIS components it needs to sign. + +## Check the scripts + +```sh +pwsh -NoProfile -File desktop/scripts/test-windows-signing.ps1 +``` + +The regression suite uses synthetic command results to test missing config, +native failures, unsigned/altered signatures, publisher mismatch, absent +timestamps, absent/stale installers, and evidence for both files. It runs on +Windows PR CI without Azure access. Only a protected signing run proves the +certificate, OIDC federation, and real Windows signature chain together. + +References: [Tauri custom signing](https://v2.tauri.app/distribute/sign/windows/), +[AzureSignTool 7.0.1](https://github.com/vcsjones/AzureSignTool/tree/v7.0.1), +[Windows SignTool verification](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool), +[GitHub OIDC subjects](https://docs.github.com/en/actions/reference/security/oidc), +[Entra federated credentials](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust). From 52167c0fa38c1fc05374c3daa681da51c31136fb Mon Sep 17 00:00:00 2001 From: David McKay Date: Mon, 14 Sep 2026 10:14:28 -0700 Subject: [PATCH 2/2] fix(desktop): verify the signed app packaged inside NSIS --- .github/workflows/desktop-signing.yml | 10 +++++++++- desktop/scripts/test-windows-signing.ps1 | 20 +++++++++++++++++++ desktop/scripts/verify-windows-signatures.ps1 | 2 +- docs/windows-signing.md | 11 ++++++---- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-signing.yml b/.github/workflows/desktop-signing.yml index b77891e1d..6c08409f0 100644 --- a/.github/workflows/desktop-signing.yml +++ b/.github/workflows/desktop-signing.yml @@ -116,6 +116,14 @@ jobs: 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 @@ -123,7 +131,7 @@ jobs: with: name: openbot-windows-signed-${{ github.run_id }}-${{ github.run_attempt }} path: | - desktop/src-tauri/target/release/openbot-desktop.exe + desktop/signed-app/openbot-desktop.exe desktop/src-tauri/target/release/bundle/nsis/*-setup.exe if-no-files-found: error retention-days: 14 diff --git a/desktop/scripts/test-windows-signing.ps1 b/desktop/scripts/test-windows-signing.ps1 index d78914176..09d02bd0e 100644 --- a/desktop/scripts/test-windows-signing.ps1 +++ b/desktop/scripts/test-windows-signing.ps1 @@ -136,6 +136,26 @@ try { 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.' diff --git a/desktop/scripts/verify-windows-signatures.ps1 b/desktop/scripts/verify-windows-signatures.ps1 index 28773f991..0e142e14c 100644 --- a/desktop/scripts/verify-windows-signatures.ps1 +++ b/desktop/scripts/verify-windows-signatures.ps1 @@ -1,7 +1,7 @@ #Requires -Version 7.0 [CmdletBinding()] param( - [string]$AppPath = "$PSScriptRoot/../src-tauri/target/release/openbot-desktop.exe", + [string]$AppPath = "$PSScriptRoot/../signed-app/openbot-desktop.exe", [string]$InstallerDirectory = "$PSScriptRoot/../src-tauri/target/release/bundle/nsis", [string]$EvidenceDirectory = "$PSScriptRoot/../signing-evidence", [string]$SignToolPath, diff --git a/docs/windows-signing.md b/docs/windows-signing.md index 72b8a6aaf..717759e94 100644 --- a/docs/windows-signing.md +++ b/docs/windows-signing.md @@ -24,14 +24,17 @@ ref to validate, and set `signing-mode` to `keyvault`. The default `none` runs only credential-free regressions. Environment reviewers should check the exact source SHA and workflow changes before approving access to the publisher's key. -A successful signing run verifies **both** `openbot-desktop.exe` and the single -`*-setup.exe` installer using Windows Authenticode and +A successful signing run extracts `openbot-desktop.exe` from the NSIS installer +with 7-Zip, then verifies **both** that payload and the single `*-setup.exe` +installer using Windows Authenticode and `signtool verify /pa /all /v /tw`. Signatures must be valid, timestamped, and have publisher `Tawkit, Inc.`. Any warning or nonzero SignTool exit fails the job. `signatures.json` records the source SHA, artifact SHA-256 hashes, signer and timestamp certificates; the companion text files retain verbose SignTool output. -The binaries upload only after both pass. These checks do not test SmartScreen -reputation or exercise the app UI. +The binaries upload only after both pass. The extracted app is retained from +`desktop/signed-app/`: Tauri restores the unsigned build executable after bundling, +so verifying `target/release/openbot-desktop.exe` would inspect the wrong copy. +These checks do not test SmartScreen reputation or exercise the app UI. ## One-time infrastructure setup