From b21172852dea87efb5bc9d86698890e333d12a6e Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 11 Sep 2026 15:51:00 +0800 Subject: [PATCH 1/2] ci: integrate Certum Authenticode signing for Windows packages --- .github/workflows/desktop-package.yml | 83 +++++++++++++++++--- .github/workflows/windows-signing-checks.yml | 34 ++++++++ scripts/ci/sign-windows.ps1 | 37 +++++++++ scripts/ci/sign-windows.test.ps1 | 79 +++++++++++++++++++ scripts/desktop-tauri-build.mjs | 25 +++++- scripts/desktop-tauri-build.test.mjs | 26 ++++++ src/apps/desktop/README.md | 55 +++++++++++++ 7 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/windows-signing-checks.yml create mode 100644 scripts/ci/sign-windows.ps1 create mode 100644 scripts/ci/sign-windows.test.ps1 diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index b5fd63ba4c..90d90b9a41 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -183,17 +183,8 @@ jobs: - os: windows-latest name: windows-x64 target: x86_64-pc-windows-msvc - build_command: | - $ErrorActionPreference = 'Stop' - pnpm run desktop:build:nsis --target x86_64-pc-windows-msvc --verbose - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - $desktopExe = "target/x86_64-pc-windows-msvc/release/openbitfun-desktop.exe" - if (-not (Test-Path $desktopExe)) { - throw "Desktop executable was not found after NSIS build: $desktopExe" - } - $env:OPENBITFUN_INSTALLER_APP_EXE = $desktopExe - pnpm run installer:build:only - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Compile before opening the short-lived SimplySign session. + build_command: node scripts/desktop-tauri-build.mjs --no-bundle --target x86_64-pc-windows-msvc --verbose steps: - name: Checkout @@ -201,6 +192,32 @@ jobs: with: ref: ${{ needs.prepare.outputs.checkout_ref }} + - name: Check Windows signing configuration + if: runner.os == 'Windows' + id: windows-signing + shell: pwsh + env: + CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }} + CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }} + CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }} + REQUIRE_SIGNING: ${{ needs.prepare.outputs.upload_to_release }} + run: | + $ErrorActionPreference = 'Stop' + $names = @('CERTUM_USERNAME', 'CERTUM_OTP_URI', 'CERTUM_KEY_ID') + $missing = @($names | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }) + if ($missing.Count -eq 3 -and $env:REQUIRE_SIGNING -ne 'true') { + 'enabled=false' >> $env:GITHUB_OUTPUT + Write-Host 'Artifact-only build without Authenticode signing; Certum secrets are not configured.' + } elseif ($missing.Count -gt 0) { + throw "Missing Windows signing secrets: $($missing -join ', '). Release publication requires Authenticode signing." + } else { + $thumbprint = ($env:CERTUM_KEY_ID -replace '\s', '').ToUpperInvariant() + if ($thumbprint -notmatch '^[0-9A-F]{40}$') { throw 'CERTUM_KEY_ID must be a SHA-1 certificate fingerprint.' } + if (-not $env:CERTUM_OTP_URI.StartsWith('otpauth://totp/')) { throw 'CERTUM_OTP_URI must be a TOTP otpauth URI.' } + 'enabled=true' >> $env:GITHUB_OUTPUT + "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV + } + - name: Install NSIS (Windows) if: runner.os == 'Windows' shell: pwsh @@ -318,6 +335,50 @@ jobs: - name: Build desktop app run: ${{ matrix.platform.build_command }} + - name: Connect Certum SimplySign + if: runner.os == 'Windows' && steps.windows-signing.outputs.enabled == 'true' + timeout-minutes: 10 + # Pinned immutable revision; this community action automates the Desktop login. + uses: dismine/windows-app-signing-setup-action@89ae3b032d4bc7a5b98d1a42a34e61ecb6faad64 + with: + certum-username: ${{ secrets.CERTUM_USERNAME }} + certum-otp-uri: ${{ secrets.CERTUM_OTP_URI }} + certum-key-id: ${{ env.WINDOWS_CERTIFICATE_THUMBPRINT }} + capture-diagnostics: 'false' + + - name: Bundle Windows updater and verify Authenticode + if: runner.os == 'Windows' + timeout-minutes: 20 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + node scripts/desktop-tauri-build.mjs --bundle-only --target x86_64-pc-windows-msvc --bundles nsis --verbose + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) { + # Tauri restores the unsigned raw EXE after bundling; sign it again + # before the custom installer snapshots and hashes its payload. + & ./scripts/ci/sign-windows.ps1 -Path 'target/x86_64-pc-windows-msvc/release/openbitfun-desktop.exe' + $installers = @(Get-ChildItem 'target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe' -File) + if ($installers.Count -eq 0) { throw 'NSIS installer was not produced.' } + foreach ($installer in $installers) { + & ./scripts/ci/sign-windows.ps1 -Path $installer.FullName -VerifyOnly + } + } + + - name: Build and sign custom Windows installer + if: runner.os == 'Windows' + timeout-minutes: 60 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # The payload manifest must hash the already signed desktop executable. + $env:OPENBITFUN_INSTALLER_APP_EXE = 'target/x86_64-pc-windows-msvc/release/openbitfun-desktop.exe' + pnpm run installer:build:only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) { + & ./scripts/ci/sign-windows.ps1 -Path 'OpenBitFun-Installer/src-tauri/target/release/openbitfun-installer.exe' + } + - name: Verify Apple signature and notarization if: runner.os == 'macOS' shell: bash diff --git a/.github/workflows/windows-signing-checks.yml b/.github/workflows/windows-signing-checks.yml new file mode 100644 index 0000000000..5f9c120893 --- /dev/null +++ b/.github/workflows/windows-signing-checks.yml @@ -0,0 +1,34 @@ +name: Windows Signing Checks + +on: + pull_request: + paths: + - '.github/workflows/desktop-package.yml' + - '.github/workflows/windows-signing-checks.yml' + - 'scripts/ci/sign-windows*.ps1' + - 'scripts/desktop-tauri-build*.mjs' + push: + branches: [main] + paths: + - '.github/workflows/desktop-package.yml' + - '.github/workflows/windows-signing-checks.yml' + - 'scripts/ci/sign-windows*.ps1' + - 'scripts/desktop-tauri-build*.mjs' + +permissions: + contents: read + +jobs: + signing-contracts: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Test Tauri signing configuration + run: node --test scripts/desktop-tauri-build.test.mjs + - name: Test signing failure handling without credentials + shell: pwsh + run: ./scripts/ci/sign-windows.test.ps1 diff --git a/scripts/ci/sign-windows.ps1 b/scripts/ci/sign-windows.ps1 new file mode 100644 index 0000000000..8a3f5ad0e4 --- /dev/null +++ b/scripts/ci/sign-windows.ps1 @@ -0,0 +1,37 @@ +# Sign/verify before updater signatures and release checksums are generated. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Path, + [switch]$VerifyOnly +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$thumbprint = ($env:WINDOWS_CERTIFICATE_THUMBPRINT -replace '\s', '').ToUpperInvariant() +if ($thumbprint -notmatch '^[0-9A-F]{40}$') { + throw 'WINDOWS_CERTIFICATE_THUMBPRINT must be a SHA-1 certificate fingerprint.' +} +$file = (Get-Item -LiteralPath $Path -ErrorAction Stop).FullName +$tools = @(Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -File | + Sort-Object { [version]$_.Directory.Parent.Name } -Descending) +if ($tools.Count -eq 0) { throw 'Windows SDK x64 signtool.exe was not found.' } +$signtool = $tools[0].FullName + +if (-not $VerifyOnly) { + & $signtool sign /sha1 $thumbprint /fd SHA256 /tr http://time.certum.pl /td SHA256 /v $file + if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed: $file (exit $LASTEXITCODE)" } +} + +& $signtool verify /pa /all /tw /v $file +if ($LASTEXITCODE -ne 0) { throw "Authenticode verification failed: $file (exit $LASTEXITCODE)" } +$signature = Get-AuthenticodeSignature -LiteralPath $file +if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { + throw "Invalid Authenticode signature: $file ($($signature.Status))" +} +if ($signature.SignerCertificate.Thumbprint -ne $thumbprint) { + throw "Unexpected signing certificate: $file" +} +if ($null -eq $signature.TimeStamperCertificate) { + throw "Missing Authenticode timestamp: $file" +} +Write-Host "Verified Authenticode signature and timestamp: $file" diff --git a/scripts/ci/sign-windows.test.ps1 b/scripts/ci/sign-windows.test.ps1 new file mode 100644 index 0000000000..4d66120f7a --- /dev/null +++ b/scripts/ci/sign-windows.test.ps1 @@ -0,0 +1,79 @@ +# Portable contract tests. These mocks do not exercise Certum or Windows trust. +$ErrorActionPreference = 'Stop' +$scriptUnderTest = Join-Path $PSScriptRoot 'sign-windows.ps1' +$tokens = $null +$parseErrors = $null +$null = [System.Management.Automation.Language.Parser]::ParseFile($scriptUnderTest, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count -gt 0) { throw ($parseErrors | Out-String) } +$oldThumbprint = $env:WINDOWS_CERTIFICATE_THUMBPRINT +$oldProgramFiles = ${env:ProgramFiles(x86)} +$env:WINDOWS_CERTIFICATE_THUMBPRINT = 'AB' * 20 +${env:ProgramFiles(x86)} = 'mock-sdk' +$global:signingTestcalls = @() +$global:signingTestfailCommand = '' +$global:signingTestsignature = $null + +function Get-Item { param($LiteralPath, $ErrorAction) [pscustomobject]@{ FullName = $LiteralPath } } +function Get-ChildItem { + param($Path, [switch]$File) + [pscustomobject]@{ FullName = 'Invoke-MockSignTool'; Directory = @{ Parent = @{ Name = '10.0.26100.0' } } } +} +function Invoke-MockSignTool { + $global:signingTestcalls += ,@($args) + $global:LASTEXITCODE = if ($args[0] -eq $global:signingTestfailCommand) { 1 } else { 0 } +} +function Get-AuthenticodeSignature { param($LiteralPath) $global:signingTestsignature } +function Reset-Fixture { + $global:signingTestcalls = @() + $global:signingTestfailCommand = '' + $global:signingTestsignature = [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Thumbprint = 'AB' * 20 } + TimeStamperCertificate = [pscustomobject]@{ Subject = 'Mock TSA' } + } +} +function Assert-Fails($Action, $Expected) { + $message = $null + try { & $Action } catch { $message = $_.Exception.Message } + if (-not $message -or $message -notlike "*$Expected*") { + throw "Expected failure containing '$Expected'; got '$message'." + } +} +try { + Reset-Fixture + & $scriptUnderTest -Path 'installer with spaces.exe' + if ($global:signingTestcalls.Count -ne 2 -or $global:signingTestcalls[0][0] -ne 'sign' -or $global:signingTestcalls[1][0] -ne 'verify') { + throw 'Signing must be followed by verification.' + } + if ($global:signingTestcalls[0][-1] -ne 'installer with spaces.exe' -or $global:signingTestcalls[0] -notcontains '/tr') { + throw 'Signing must preserve file arguments and request an RFC3161 timestamp.' + } + Reset-Fixture + & $scriptUnderTest -Path 'nsis.exe' -VerifyOnly + if ($global:signingTestcalls.Count -ne 1 -or $global:signingTestcalls[0][0] -ne 'verify') { + throw 'Verification must not mutate the already updater-signed NSIS installer.' + } + Reset-Fixture + $global:signingTestfailCommand = 'sign' + Assert-Fails { & $scriptUnderTest -Path 'installer.exe' } 'signing failed' + if ($global:signingTestcalls.Count -ne 1) { throw 'Failed signing must stop immediately.' } + Reset-Fixture + $global:signingTestfailCommand = 'verify' + Assert-Fails { & $scriptUnderTest -Path 'installer.exe' -VerifyOnly } 'verification failed' + Reset-Fixture + $global:signingTestsignature.Status = 'HashMismatch' + Assert-Fails { & $scriptUnderTest -Path 'installer.exe' -VerifyOnly } 'Invalid Authenticode' + Reset-Fixture + $global:signingTestsignature.SignerCertificate.Thumbprint = 'CD' * 20 + Assert-Fails { & $scriptUnderTest -Path 'installer.exe' -VerifyOnly } 'Unexpected signing certificate' + Reset-Fixture + $global:signingTestsignature.TimeStamperCertificate = $null + Assert-Fails { & $scriptUnderTest -Path 'installer.exe' -VerifyOnly } 'Missing Authenticode timestamp' + $env:WINDOWS_CERTIFICATE_THUMBPRINT = 'bad' + Assert-Fails { & $scriptUnderTest -Path 'installer.exe' } 'fingerprint' + Write-Host 'Passed 8 Windows signing contract cases (mocked).' +} finally { + $env:WINDOWS_CERTIFICATE_THUMBPRINT = $oldThumbprint + ${env:ProgramFiles(x86)} = $oldProgramFiles + Remove-Variable signingTestcalls, signingTestfailCommand, signingTestsignature -Scope Global -ErrorAction SilentlyContinue +} diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index e961ccac0e..39cc8a9eeb 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -38,6 +38,8 @@ function tauriBuildArgsFromArgv() { async function main() { const { productConfig, forwardArgs: forward } = extractProductConfigArg(tauriBuildArgsFromArgv()); + const bundleOnly = forward.includes('--bundle-only'); + if (bundleOnly) forward.splice(forward.indexOf('--bundle-only'), 1); const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig, member: 'desktop' }); Object.assign(process.env, productBuildEnvironment(resolution)); console.log(`[product] ${resolution.assembly.member} ${resolution.assembly.assemblyDigest}`); @@ -47,7 +49,7 @@ async function main() { console.log(`[release] channel=${releaseChannel.channel}`); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); - preparePluginHost(); + if (!bundleOnly) preparePluginHost(); const flashgrepBinary = prepareMacOSFlashgrepForSigning( ensureFlashgrepBinary({ target: optionValue(forward, '--target') || rustHostTargetTriple() }), desktopDir, @@ -68,7 +70,7 @@ async function main() { releaseChannel, }); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); - const tauriArgs = ['build', '--config', tauriConfig, ...forward]; + const tauriArgs = [bundleOnly ? 'bundle' : 'build', '--config', tauriConfig, ...forward]; let attemptStartedAtMs = Date.now(); let r = runTauriBuild(tauriBin, tauriArgs, desktopDir); @@ -289,6 +291,24 @@ export function prepareMacOSFlashgrepForSigning( return signedBinary; } +// The cloud private key remains in SimplySign; only its certificate selector is +// passed to Tauri. Authenticode runs before Tauri creates updater signatures. +export function configureWindowsSigning(config, env = process.env, platform = process.platform) { + if (platform !== 'win32' || !env.WINDOWS_CERTIFICATE_THUMBPRINT) return; + const thumbprint = env.WINDOWS_CERTIFICATE_THUMBPRINT.replace(/\s/g, '').toUpperCase(); + if (!/^[0-9A-F]{40}$/.test(thumbprint)) { + throw new Error('WINDOWS_CERTIFICATE_THUMBPRINT must be a SHA-1 certificate fingerprint.'); + } + config.bundle ??= {}; + config.bundle.windows = { + ...config.bundle.windows, + certificateThumbprint: thumbprint, + digestAlgorithm: 'sha256', + timestampUrl: 'http://time.certum.pl', + tsp: true, + }; +} + export function prepareTauriConfig( baseConfigPath, { desktopDir, flashgrepBinary, resolution, releaseChannel } @@ -302,6 +322,7 @@ export function prepareTauriConfig( config.mainBinaryName = resolution.assembly.binaryName; config.identifier = resolution.assembly.bundleId; } + configureWindowsSigning(config); injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); // The DeepSeek bridge is not a compile-time resource: cargo check and // desktop:dev must not require packages/dsh-acp/dist-profile. Official diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index df9b81d680..e62ba26050 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -5,6 +5,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { configureDesktopWebFontProfile, + configureWindowsSigning, prepareMacOSFlashgrepForSigning, prepareTauriConfig, shouldRetryMacDmgBuild, @@ -515,3 +516,28 @@ test('Desktop release config bundles models.dev notices and provenance', () => { 'third-party/models.dev/provenance.json' ); }); + + +test('Windows cloud signing uses SHA256 and RFC3161 without changing installer settings', () => { + const config = { bundle: { windows: { nsis: { installMode: 'currentUser' } } } }; + configureWindowsSigning(config, { WINDOWS_CERTIFICATE_THUMBPRINT: 'ab '.repeat(20) }, 'win32'); + assert.deepEqual(config.bundle.windows, { + nsis: { installMode: 'currentUser' }, + certificateThumbprint: 'AB'.repeat(20), + digestAlgorithm: 'sha256', + timestampUrl: 'http://time.certum.pl', + tsp: true, + }); +}); + +test('Windows signing rejects malformed fingerprints and leaves other platforms unchanged', () => { + assert.throws(() => configureWindowsSigning({}, { WINDOWS_CERTIFICATE_THUMBPRINT: 'bad' }, 'win32'), /fingerprint/); + for (const platform of ['darwin', 'linux']) { + const config = { bundle: { active: true } }; + configureWindowsSigning(config, { WINDOWS_CERTIFICATE_THUMBPRINT: 'AB'.repeat(20) }, platform); + assert.deepEqual(config, { bundle: { active: true } }); + } + const unsigned = {}; + configureWindowsSigning(unsigned, {}, 'win32'); + assert.deepEqual(unsigned, {}); +}); diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index 664bbb33e1..d56ad517e3 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -84,3 +84,58 @@ OPENBITFUN_DEV_PORT=1432 pnpm run desktop:dev HMR uses port 1431 in this example; `OPENBITFUN_DEV_HMR_PORT` can override it. The launcher supplies the same HTTP URL to Tauri that Vite listens on, and both the main window and companion window read that configured URL. + + +## Windows release signing (maintainers) + +`Desktop Package` uses Certum SimplySign on the hosted Windows runner. Configure +these repository Actions secrets before publishing a release: + +| Secret | Value | +| --- | --- | +| `CERTUM_USERNAME` | SimplySign login account | +| `CERTUM_OTP_URI` | Full `otpauth://totp/...` provisioning URI, including its original algorithm, digits and period | +| `CERTUM_KEY_ID` | SHA-1 fingerprint of the activated Code Signing certificate | + +The OTP URI is provisioning data from the activation QR code, not a current +mobile token, an email activation code or the certificate PIN. Do not paste it +into an issue, PR, log or online QR decoder. Certum's +[activation instructions](https://support.certum.eu/en/how-to-activate-access-to-simply-sign-application/) +describe the activation-link email and separate activation-code email used to +show the QR code. If the original provisioning data is unavailable, contact +Certum/the reseller about regaining access; do not assume the Desktop login can +export it. Replacing the provisioning seed also requires updating the CI secret +and potentially reactivating the mobile app. + +The workflow compiles with `--no-bundle`, then opens the SimplySign session. +`--bundle-only` in the Desktop build wrapper runs `tauri bundle` using the same +product and updater configuration, without recompiling. Tauri signs the NSIS +payload and installer before generating updater `.sig` files. Since Tauri +restores the unsigned raw Desktop EXE after bundling, the workflow separately +signs that EXE before the custom installer hashes and embeds it. Finally, it +signs the custom installer. Subsequent release staging copies/renames those +bytes and generates the existing updater/manual-download signatures. + +Verification requires Windows Authenticode trust, the configured signer and a +timestamp. Any failure blocks artifact upload. A publication run requires all +three secrets; an artifact-only run with none configured explicitly builds +unsigned packages. Partial configuration always fails. No PFX/private-key export +is required, and the existing Tauri updater key remains unchanged. + +Login uses a pinned community action, not an official Certum CI API. Its GUI +login compatibility and any additional certificate PIN prompt must be validated +with the actual account before the first signed release. Diagnostic screenshots +are disabled. A timed-out signing step must be investigated rather than bypassed. +Only trusted release code should receive the secrets. Code signing identifies +the publisher; it does not guarantee that SmartScreen reputation warnings vanish. + +Focused checks: + +```sh +pnpm run check:github-config +node --test scripts/desktop-tauri-build.test.mjs OpenBitFun-Installer/scripts/build-installer.test.cjs +pwsh -NoProfile -File scripts/ci/sign-windows.test.ps1 +``` + +The PowerShell test uses mocked signing results; a Windows build with the real +certificate is still required to prove cloud signing and timestamp/trust validation. From 16da5c64d813ef340d56fee4c3eb27ffaf14cf28 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 11 Sep 2026 15:52:56 +0800 Subject: [PATCH 2/2] ci: disable dependency cache for signing contract checks --- .github/workflows/windows-signing-checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/windows-signing-checks.yml b/.github/workflows/windows-signing-checks.yml index 5f9c120893..fd6d7e4029 100644 --- a/.github/workflows/windows-signing-checks.yml +++ b/.github/workflows/windows-signing-checks.yml @@ -27,6 +27,7 @@ jobs: - uses: actions/setup-node@v5 with: node-version: 22 + package-manager-cache: false - name: Test Tauri signing configuration run: node --test scripts/desktop-tauri-build.test.mjs - name: Test signing failure handling without credentials