Skip to content
Draft
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
83 changes: 72 additions & 11 deletions .github/workflows/desktop-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,24 +183,41 @@ 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
uses: actions/checkout@v5
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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/windows-signing-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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
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
shell: pwsh
run: ./scripts/ci/sign-windows.test.ps1
37 changes: 37 additions & 0 deletions scripts/ci/sign-windows.ps1
Original file line number Diff line number Diff line change
@@ -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"
79 changes: 79 additions & 0 deletions scripts/ci/sign-windows.test.ps1
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 23 additions & 2 deletions scripts/desktop-tauri-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions scripts/desktop-tauri-build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path';
import test from 'node:test';
import {
configureDesktopWebFontProfile,
configureWindowsSigning,
prepareMacOSFlashgrepForSigning,
prepareTauriConfig,
shouldRetryMacDmgBuild,
Expand Down Expand Up @@ -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, {});
});
Loading
Loading