From 2c76caa91888573625fca134e880955074d5630e Mon Sep 17 00:00:00 2001 From: dijunkun Date: Wed, 9 Sep 2026 22:57:49 +0800 Subject: [PATCH] [feat] add SignPath release signing and manual Windows tests [skip ci] --- .github/actions/sign-windows/action.yml | 62 +++++++ .github/workflows/build.yml | 155 +++++++++++++----- .../scripts/windows/signpath-artifact.ps1 | 65 ++++++++ .../scripts/windows/signpath-binaries.xml | 21 +++ .../scripts/windows/signpath-common.ps1 | 57 +++++++ apps/desktop/tests/windows_signing_test.ps1 | 123 ++++++++++++++ docs/windows-signing.md | 71 ++++++++ 7 files changed, 513 insertions(+), 41 deletions(-) create mode 100644 .github/actions/sign-windows/action.yml create mode 100644 apps/desktop/scripts/windows/signpath-artifact.ps1 create mode 100644 apps/desktop/scripts/windows/signpath-binaries.xml create mode 100644 apps/desktop/scripts/windows/signpath-common.ps1 create mode 100644 apps/desktop/tests/windows_signing_test.ps1 create mode 100644 docs/windows-signing.md diff --git a/.github/actions/sign-windows/action.yml b/.github/actions/sign-windows/action.yml new file mode 100644 index 00000000..83fb0129 --- /dev/null +++ b/.github/actions/sign-windows/action.yml @@ -0,0 +1,62 @@ +name: Sign Windows artifact +description: Submit one raw EXE or ZIP to SignPath and download the signed result. +inputs: + api-token: + description: SignPath submitter token. + required: true + organization-id: + description: SignPath organization ID. + required: true + signing-policy: + description: test-signing or release-signing. + required: true + artifact-configuration: + description: Artifact configuration slug in the crossdesk project. + required: true + artifact: + description: Absolute path to one EXE or ZIP file. + required: true + output-directory: + description: Absolute directory for the downloaded signed file. + required: true + version: + description: CrossDesk ProductVersion and FileVersion. + required: true +runs: + using: composite + steps: + - name: Upload unsigned artifact + id: unsigned + uses: actions/upload-artifact@v7 + with: + path: ${{ inputs.artifact }} + archive: false + if-no-files-found: error + retention-days: 7 + + - name: Submit signing request and wait for approval + id: sign + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ inputs.api-token }} + organization-id: ${{ inputs.organization-id }} + project-slug: crossdesk + signing-policy-slug: ${{ inputs.signing-policy }} + artifact-configuration-slug: ${{ inputs.artifact-configuration }} + github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: ${{ inputs.output-directory }} + skip-decompress: true + parameters: | + version: ${{ toJSON(inputs.version) }} + + - name: Record signing request + if: always() && steps.sign.outputs.signing-request-web-url != '' + shell: pwsh + env: + SIGNING_REQUEST_URL: ${{ steps.sign.outputs.signing-request-web-url }} + SIGNING_CONFIGURATION: ${{ inputs.artifact-configuration }} + SIGNING_POLICY: ${{ inputs.signing-policy }} + run: | + "- $env:SIGNING_CONFIGURATION ($env:SIGNING_POLICY): $env:SIGNING_REQUEST_URL" >> $env:GITHUB_STEP_SUMMARY diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 849acc9a..0cf2d30b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,17 @@ on: required: false default: "0" sign_windows_test: - description: "Submit the Windows installer to SignPath using test-signing." + description: "Test-sign Windows binaries and installer (Actions artifacts only)." + required: false + type: boolean + default: false + sign_windows_release: + description: "Test release-signing with the production certificate (Actions artifacts only)." + required: false + type: boolean + default: false + windows_only: + description: "Build only Windows, for signing tests." required: false type: boolean default: false @@ -28,6 +38,7 @@ env: jobs: build-linux: + if: github.event_name != 'workflow_dispatch' || !inputs.windows_only name: Build Linux (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} strategy: @@ -176,6 +187,7 @@ jobs: # macOS build-macos: + if: github.event_name != 'workflow_dispatch' || !inputs.windows_only name: Build on macOS runs-on: ${{ matrix.runner }} env: @@ -308,6 +320,7 @@ jobs: # iOS build-ios: + if: github.event_name != 'workflow_dispatch' || !inputs.windows_only name: Build on iOS (arm64) runs-on: macos-26 timeout-minutes: 120 @@ -504,9 +517,37 @@ jobs: build-windows-x64: name: Build on Windows x64 runs-on: windows-2022 + timeout-minutes: 240 + outputs: + signing-policy: ${{ steps.signing.outputs.policy }} env: XMAKE_GLOBALDIR: D:\xmake_global steps: + - name: Select Windows signing policy + id: signing + shell: pwsh + env: + TEST_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_test }} + RELEASE_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_release }} + PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + run: | + if ($env:TEST_SIGNING -eq 'true' -and $env:RELEASE_SIGNING -eq 'true') { + throw 'Select only one of sign_windows_test and sign_windows_release.' + } + $policy = '' + if ($env:PUBLISH_RELEASE -eq 'true' -or $env:RELEASE_SIGNING -eq 'true') { + $policy = 'release-signing' + } elseif ($env:TEST_SIGNING -eq 'true') { + $policy = 'test-signing' + } + if ($policy -and (!$env:SIGNPATH_API_TOKEN -or !$env:SIGNPATH_ORGANIZATION_ID)) { + throw 'Signing requires SIGNPATH_API_TOKEN and SIGNPATH_ORGANIZATION_ID.' + } + "policy=$policy" >> $env:GITHUB_OUTPUT + "Windows signing policy: $(if ($policy) { $policy } else { 'unsigned development build' })" >> $env:GITHUB_STEP_SUMMARY + - name: Extract version number shell: pwsh run: | @@ -611,67 +652,99 @@ jobs: $nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode" copy "${{ github.workspace }}\apps\desktop\scripts\windows\nsProcess.dll" $nsisPluginDir + - name: Test Windows signing helpers + shell: pwsh + run: ./apps/desktop/tests/windows_signing_test.ps1 + - name: Build CrossDesk run: | xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true -y xmake b -vy crossdesk + - name: Prepare installer binaries for signing + if: steps.signing.outputs.policy != '' + shell: pwsh + run: | + ./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/installer-binaries.zip + + - name: Sign installer binaries + if: steps.signing.outputs.policy != '' + uses: ./.github/actions/sign-windows + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + signing-policy: ${{ steps.signing.outputs.policy }} + artifact-configuration: windows-binaries + artifact: ${{ github.workspace }}/signpath/unsigned/installer-binaries.zip + output-directory: ${{ github.workspace }}/signpath/signed/installer-binaries + version: ${{ env.VERSION_NUM }} + + - name: Verify and restore signed installer binaries + if: steps.signing.outputs.policy != '' + shell: pwsh + env: + SIGNING_POLICY: ${{ steps.signing.outputs.policy }} + run: | + ./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/installer-binaries/installer-binaries.zip -SigningPolicy $env:SIGNING_POLICY + - name: Package shell: pwsh run: | & "${{ github.workspace }}\apps\desktop\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM - - name: Upload unsigned Windows installer for test signing - if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test - id: upload-unsigned-windows-installer - uses: actions/upload-artifact@v7 - with: - path: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe - archive: false - if-no-files-found: error - retention-days: 1 - - - name: Sign Windows installer with SignPath test certificate - if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test - uses: signpath/github-action-submit-signing-request@v2 + - name: Sign Windows installer + if: steps.signing.outputs.policy != '' + uses: ./.github/actions/sign-windows with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} - project-slug: crossdesk - signing-policy-slug: test-signing - github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }} - wait-for-completion: true - output-artifact-directory: ${{ github.workspace }}\signpath\signed-installer - skip-decompress: true - parameters: | - version: ${{ toJSON(env.VERSION_NUM) }} + signing-policy: ${{ steps.signing.outputs.policy }} + artifact-configuration: initial + artifact: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe + output-directory: ${{ github.workspace }}/signpath/signed/installer + version: ${{ env.VERSION_NUM }} - - name: Verify and use test-signed Windows installer - if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test + - name: Verify and use signed Windows installer + if: steps.signing.outputs.policy != '' shell: pwsh + env: + SIGNING_POLICY: ${{ steps.signing.outputs.policy }} run: | - $installerName = "crossdesk-win-x64-${{ env.VERSION_NUM }}.exe" - $signedInstaller = Join-Path "${{ github.workspace }}\signpath\signed-installer" $installerName - $destination = Join-Path "${{ github.workspace }}\apps\desktop\scripts\windows" $installerName - - if (!(Test-Path $signedInstaller)) { - throw "SignPath did not return the signed installer: $signedInstaller" - } - - $signature = Get-AuthenticodeSignature $signedInstaller - if (!$signature.SignerCertificate -or $signature.Status -eq "NotSigned") { - throw "SignPath returned an installer without an Authenticode signature." - } - - Write-Host "Signer: $($signature.SignerCertificate.Subject)" - Write-Host "Signature status: $($signature.Status)" - Copy-Item $signedInstaller $destination -Force + $installerName = "crossdesk-win-x64-$env:VERSION_NUM.exe" + ./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation VerifyInstaller -Artifact "./signpath/signed/installer/$installerName" -SigningPolicy $env:SIGNING_POLICY + Copy-Item "./signpath/signed/installer/$installerName" ./apps/desktop/scripts/windows/ -Force - name: Build Portable CrossDesk run: | xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true --CROSSDESK_PORTABLE=true -y xmake b -vy crossdesk + - name: Prepare portable binaries for signing + if: steps.signing.outputs.policy != '' + shell: pwsh + run: | + ./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/portable-binaries.zip + + - name: Sign portable binaries + if: steps.signing.outputs.policy != '' + uses: ./.github/actions/sign-windows + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + signing-policy: ${{ steps.signing.outputs.policy }} + artifact-configuration: windows-binaries + artifact: ${{ github.workspace }}/signpath/unsigned/portable-binaries.zip + output-directory: ${{ github.workspace }}/signpath/signed/portable-binaries + version: ${{ env.VERSION_NUM }} + + - name: Verify and restore signed portable binaries + if: steps.signing.outputs.policy != '' + shell: pwsh + env: + SIGNING_POLICY: ${{ steps.signing.outputs.policy }} + run: | + ./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/portable-binaries/portable-binaries.zip -SigningPolicy $env:SIGNING_POLICY + - name: Package Portable shell: pwsh run: | @@ -719,7 +792,7 @@ jobs: release: name: Publish Release - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.build-windows-x64.outputs.signing-policy == 'release-signing' needs: [build-linux, build-macos, build-ios, build-windows-x64] runs-on: ubuntu-latest diff --git a/apps/desktop/scripts/windows/signpath-artifact.ps1 b/apps/desktop/scripts/windows/signpath-artifact.ps1 new file mode 100644 index 00000000..7a47df6d --- /dev/null +++ b/apps/desktop/scripts/windows/signpath-artifact.ps1 @@ -0,0 +1,65 @@ +param( + [Parameter(Mandatory)] + [ValidateSet('PrepareBinaries', 'RestoreBinaries', 'VerifyInstaller')] + [string]$Operation, + [Parameter(Mandatory)] [string]$Artifact, + [string]$Directory, + [ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy +) + +. "$PSScriptRoot/signpath-common.ps1" + +if ($Operation -ne 'PrepareBinaries' -and !$SigningPolicy) { + throw 'SigningPolicy is required when verifying signed artifacts.' +} +if ($Operation -eq 'VerifyInstaller') { + Assert-CrossDeskSignature -Path $Artifact -SigningPolicy $SigningPolicy + return +} +if (!$Directory -or !(Test-Path -LiteralPath $Directory -PathType Container)) { + throw "Binaries directory does not exist: $Directory" +} + +$files = @(Get-CrossDeskSigningFiles) +$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-$([guid]::NewGuid())" +New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null +try { + if ($Operation -eq 'PrepareBinaries') { + foreach ($name in $files) { + $source = Join-Path $Directory $name + if (!(Test-Path -LiteralPath $source -PathType Leaf)) { + throw "Missing CrossDesk binary: $source" + } + Copy-Item -LiteralPath $source -Destination (Join-Path $temporaryDirectory $name) + } + $artifactPath = [IO.Path]::GetFullPath($Artifact) + New-Item -ItemType Directory -Path (Split-Path -Parent $artifactPath) -Force | Out-Null + Compress-Archive -Path "$temporaryDirectory/*" -DestinationPath $artifactPath -Force + return + } + + # Inspect the ZIP before extraction: reject missing, duplicate or unexpected + # entries (including paths) so only the four intended files can be replaced. + $archive = [IO.Compression.ZipFile]::OpenRead([IO.Path]::GetFullPath($Artifact)) + try { + $entries = @($archive.Entries | ForEach-Object { $_.FullName }) + $uniqueEntries = @($entries | Sort-Object -Unique) + if ($entries.Count -ne $files.Count -or $uniqueEntries.Count -ne $files.Count -or + @(Compare-Object $files $entries).Count -ne 0) { + throw "Signed archive must contain exactly: $($files -join ', ')" + } + } finally { + $archive.Dispose() + } + Expand-Archive -LiteralPath $Artifact -DestinationPath $temporaryDirectory + + # Verify every file before replacing any build output. + foreach ($name in $files) { + Assert-CrossDeskSignature -Path (Join-Path $temporaryDirectory $name) -SigningPolicy $SigningPolicy + } + foreach ($name in $files) { + Copy-Item -LiteralPath (Join-Path $temporaryDirectory $name) -Destination (Join-Path $Directory $name) -Force + } +} finally { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force +} diff --git a/apps/desktop/scripts/windows/signpath-binaries.xml b/apps/desktop/scripts/windows/signpath-binaries.xml new file mode 100644 index 00000000..bdeca076 --- /dev/null +++ b/apps/desktop/scripts/windows/signpath-binaries.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/scripts/windows/signpath-common.ps1 b/apps/desktop/scripts/windows/signpath-common.ps1 new file mode 100644 index 00000000..e1d0a9df --- /dev/null +++ b/apps/desktop/scripts/windows/signpath-common.ps1 @@ -0,0 +1,57 @@ +$ErrorActionPreference = 'Stop' + +function Get-CrossDeskSigningFiles { + # Only binaries built from this project's sources. Never include third-party DLLs. + return @('crossdesk.exe', 'crossdesk_service.exe', 'crossdesk_session_helper.exe', 'wgc_plugin.dll') +} + +function Find-CrossDeskSignTool { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + $sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin' + $tool = Get-ChildItem "$sdk/*/x64/signtool.exe" | + Sort-Object { [version]$_.Directory.Parent.Name } | + Select-Object -Last 1 + if (!$tool) { + throw 'SignTool from the Windows SDK is required to verify release signatures.' + } + return $tool.FullName +} + +function Assert-CrossDeskSignature { + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy + ) + + if (!(Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Missing signed file: $Path" + } + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if (!$signature.SignerCertificate) { + throw "File has no Authenticode signing certificate: $Path" + } + Write-Host "Signature: $Path | $($signature.Status) | $($signature.SignerCertificate.Subject)" + if ($SigningPolicy -eq 'test-signing') { + # Self-signed test certificates are not trusted by stock GitHub runners. + # HashMismatch, NotSigned, UnknownError, etc. must still stop the build. + if ($signature.Status -notin @('Valid', 'NotTrusted')) { + throw "Invalid test signature on ${Path}: $($signature.Status)" + } + return + } + + if ($signature.Status -ne 'Valid') { + throw "Release signature is not trusted on ${Path}: $($signature.Status)" + } + if (!$signature.TimeStamperCertificate) { + throw "Release signature has no timestamp: $Path" + } + $signTool = Find-CrossDeskSignTool + & $signTool verify /pa /all /v $Path + if ($LASTEXITCODE -ne 0) { + throw "SignTool verification failed for $Path (exit $LASTEXITCODE)." + } +} diff --git a/apps/desktop/tests/windows_signing_test.ps1 b/apps/desktop/tests/windows_signing_test.ps1 new file mode 100644 index 00000000..82060c15 --- /dev/null +++ b/apps/desktop/tests/windows_signing_test.ps1 @@ -0,0 +1,123 @@ +# Portable tests for artifact handling and signature validation decisions. +# Get-AuthenticodeSignature and SignTool are simulated; CI validates real files. +$ErrorActionPreference = 'Stop' +$scripts = Join-Path $PSScriptRoot '../scripts/windows' +. "$scripts/signpath-common.ps1" +$artifactScript = Join-Path $scripts 'signpath-artifact.ps1' +$testDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-test-$([guid]::NewGuid())" +New-Item -ItemType Directory -Path $testDirectory | Out-Null +$checks = 0 + +function Assert-True([bool]$Condition, [string]$Message) { + if (!$Condition) { throw $Message } + $script:checks++ +} + +function Assert-Throws([scriptblock]$Action, [string]$Expected) { + try { & $Action } catch { + if ($_.Exception.Message -notlike "*$Expected*") { throw } + $script:checks++ + return + } + throw "Expected an error containing: $Expected" +} + +function New-TestZip([string]$Path, [string[]]$Names) { + $zip = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($name in $Names) { + $writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open()) + try { $writer.Write("signed $name") } finally { $writer.Dispose() } + } + } finally { $zip.Dispose() } +} + +$mockStatus = 'NotTrusted' +$mockBadFile = '' +$mockCertificate = [pscustomobject]@{ Subject = "CN=Test certificate for 'CrossDesk [OSS]'" } +$mockTimestamp = $null +function Get-AuthenticodeSignature { + param([string]$LiteralPath) + $status = if ([IO.Path]::GetFileName($LiteralPath) -eq $mockBadFile) { 'HashMismatch' } else { $mockStatus } + return [pscustomobject]@{ + Status = $status + SignerCertificate = $mockCertificate + TimeStamperCertificate = $mockTimestamp + } +} + +try { + $files = @(Get-CrossDeskSigningFiles) + $build = Join-Path $testDirectory 'build' + New-Item -ItemType Directory -Path $build | Out-Null + foreach ($name in $files) { Set-Content -LiteralPath (Join-Path $build $name) -Value "original $name" } + Set-Content -LiteralPath (Join-Path $build 'third-party.dll') -Value 'upstream' + $unsigned = Join-Path $testDirectory 'unsigned.zip' + & $artifactScript -Operation PrepareBinaries -Directory $build -Artifact $unsigned + $zip = [IO.Compression.ZipFile]::OpenRead($unsigned) + try { + $entries = @($zip.Entries | ForEach-Object { $_.FullName }) + Assert-True ($entries.Count -eq 4 -and @(Compare-Object $files $entries).Count -eq 0) 'Signing input must include only the four project binaries.' + } finally { $zip.Dispose() } + + [xml]$config = Get-Content -Raw (Join-Path $scripts 'signpath-binaries.xml') + $configured = @($config.SelectNodes('//*[local-name()="include"]') | ForEach-Object { $_.path }) + Assert-True (@(Compare-Object $files $configured).Count -eq 0) 'SignPath XML must cover exactly the files in the submitted ZIP.' + + Remove-Item -LiteralPath (Join-Path $build 'wgc_plugin.dll') + Assert-Throws { & $artifactScript -Operation PrepareBinaries -Directory $build -Artifact (Join-Path $testDirectory 'missing.zip') } 'Missing CrossDesk binary' + Set-Content -LiteralPath (Join-Path $build 'wgc_plugin.dll') -Value 'original wgc_plugin.dll' + + $signed = Join-Path $testDirectory 'signed.zip' + New-TestZip $signed $files + $mockBadFile = 'wgc_plugin.dll' + Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing } 'HashMismatch' + foreach ($name in $files) { + Assert-True ((Get-Content -Raw (Join-Path $build $name)).Trim() -eq "original $name") 'Failed verification must not replace any output.' + } + $mockBadFile = '' + & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing + foreach ($name in $files) { + Assert-True ((Get-Content -Raw (Join-Path $build $name)) -eq "signed $name") 'Verified signed files must replace the unsigned build output.' + } + Assert-True ((Get-Content -Raw (Join-Path $build 'third-party.dll')).Trim() -eq 'upstream') 'Third-party libraries must remain untouched.' + + $badArchives = @( + @($files[0], $files[1], $files[2]), + @($files[0], $files[1], $files[2], '../wgc_plugin.dll'), + @($files[0], $files[1], $files[2], $files[0]), + @($files[0], $files[1], $files[2], $files[3], 'third-party.dll') + ) + for ($i = 0; $i -lt $badArchives.Count; $i++) { + $badZip = Join-Path $testDirectory "bad-$i.zip" + New-TestZip $badZip $badArchives[$i] + Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $badZip -SigningPolicy test-signing } 'Signed archive must contain exactly' + } + + $file = Join-Path $build 'crossdesk.exe' + foreach ($mockStatus in @('NotSigned', 'HashMismatch', 'UnknownError')) { + Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'Invalid test signature' + } + $mockStatus = 'Valid' + $mockCertificate = $null + Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'no Authenticode signing certificate' + $mockCertificate = [pscustomobject]@{ Subject = 'CN=SignPath Foundation' } + $mockStatus = 'NotTrusted' + Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'Release signature is not trusted' + $mockStatus = 'Valid' + Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'no timestamp' + $mockTimestamp = [pscustomobject]@{ Subject = 'CN=Timestamp authority' } + + # Only the real SignTool process is replaced; exercise the actual exit-code check. + $fakeSignTool = Join-Path $testDirectory 'signtool.ps1' + function Find-CrossDeskSignTool { return $fakeSignTool } + Set-Content $fakeSignTool '$global:LASTEXITCODE = 1' + Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'SignTool verification failed' + Set-Content $fakeSignTool '$global:LASTEXITCODE = 0' + Assert-CrossDeskSignature $file release-signing + $checks++ + + Write-Host "Passed $checks Windows signing checks." +} finally { + Remove-Item -LiteralPath $testDirectory -Recurse -Force +} diff --git a/docs/windows-signing.md b/docs/windows-signing.md new file mode 100644 index 00000000..8b3893c9 --- /dev/null +++ b/docs/windows-signing.md @@ -0,0 +1,71 @@ +# Windows 签名与 Release 测试 + +GitHub Actions 的 `Build and Release` 工作流构建 CrossDesk,调用 SignPath 签名,再发布已验证的 Windows 文件。 + +## 触发方式 + +| 触发方式 | 签名策略 | 输出 | +| --- | --- | --- | +| 普通分支 push,或未勾选签名的手动运行 | 无 | 开发构建工件 | +| 手动运行,勾选 `sign_windows_test` | `test-signing` | 测试证书签名的 Actions 工件 | +| 手动运行,勾选 `sign_windows_release` | `release-signing` | 正式证书签名的 Actions 工件 | +| push `v*` tag | `release-signing` | 验证通过后发布 GitHub Release 和下载站 | + +两种手动签名选项不能同时启用。手动运行可勾选 `windows_only` 跳过其他平台;即使选择 tag 手动运行,也不会发布 Release、更新 `latest` 或同步下载站。 + +## SignPath 配置 + +GitHub 仓库需要 Secret `SIGNPATH_API_TOKEN` 和 Variable `SIGNPATH_ORGANIZATION_ID`。SignPath 项目 slug 为 `crossdesk`,已关联 GitHub.com Trusted Build System。 + +该工作流明确使用以下 Artifact Configuration: + +| Slug | XML 文件 | 用途 | +| --- | --- | --- | +| `initial` | `apps/desktop/scripts/windows/signpath-installer.xml` | 现有安装包配置,只签外层 NSIS EXE | +| `windows-binaries` | `apps/desktop/scripts/windows/signpath-binaries.xml` | 签 ZIP 内四个 CrossDesk 自有二进制 | + +`crossdesk` 后台已添加并验证 `windows-binaries`。新环境需要在项目的 Artifact Configurations 页面创建同名配置,使用仓库内对应的完整 XML。保留 `initial` 的默认配置和现有审批、来源验证要求。XML 文件仅提交到 GitHub 不会自动同步到 SignPath 后台。 + +两种策略都必须允许提交这些配置。正式策略需要有效的 Release 证书、提交者权限和审批人。若证书仍为 `CSR PENDING`,需要 SignPath 完成签发/导入;切换工作流参数不会使证书生效。后台的来源限制也必须允许当前测试分支,若不允许,应按项目原有发布审批流程处理。 + +## 签名顺序 + +1. 构建安装版,将 `crossdesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe`、`wgc_plugin.dll` 打成 `installer-binaries.zip`。 +2. 使用 `windows-binaries` 签名,验证四个文件后替换构建输出。 +3. NSIS 打包已签名的文件,使用 `initial` 签名安装器,验证后替换安装器。 +4. 构建便携版,将四个文件打成 `portable-binaries.zip`,同样签名并验证,然后生成包含已签名文件的便携 ZIP。 +5. 上传最终安装器与便携 ZIP。tag 发布等待 Windows 签名和其他平台构建全部成功。 + +第三方 DLL 原样打包,不提交签名。外层 NSIS 签名不为内部文件添加独立签名;本流程在打包前签名自有文件。NSIS 生成的 `uninstall.exe` 尚未配置独立签名。 + +每次运行会按上述顺序提交三个签名请求。正式签名需要在 SignPath 中逐个审批,每个请求最多等待 60 分钟,整个 Windows job 最多 240 分钟。请求链接由官方 Action 输出,并在可用时写入运行摘要。 + +## 启动正式证书测试 + +在 GitHub → Actions → Build and Release → Run workflow 中选择测试分支: + +- `sign_windows_release`: true +- `sign_windows_test`: false +- `windows_only`: true + +也可在分支推送后运行: + +```sh +gh workflow run build.yml --ref signpath-release-signing \ + -f sign_windows_release=true -f sign_windows_test=false -f windows_only=true +``` + +正式验证要求 Authenticode 状态为 `Valid`、存在时间戳,并且 `signtool verify /pa /all /v` 返回成功。测试证书允许 `NotTrusted`,但拒绝无签名、哈希不匹配等状态。任一签名或验证失败均停止后续发布;不会回退到未签名发布。 + +原始签名输入保留为中间 Actions 工件。手动测试成功后,下载名为 `crossdesk-win-x64-...` 和 `crossdesk-win-x64-portable-...` 的最终工件,不要使用 `installer-binaries.zip`、`portable-binaries.zip` 等原始签名输入。 + +## 本地校验 + +```sh +pwsh -NoProfile -File apps/desktop/tests/windows_signing_test.ps1 +actionlint .github/workflows/build.yml +``` + +PowerShell 测试使用模拟证书状态检查归档范围、还原顺序及失败行为,不能替代 Windows 上的真实 Authenticode 验证。 + +参考:[SignPath GitHub 集成](https://docs.signpath.io/trusted-build-systems/github)、[Artifact Configuration](https://docs.signpath.io/artifact-configuration/examples)、[SignTool 验证](https://learn.microsoft.com/en-us/windows/win32/seccrypto/using-signtool-to-verify-a-file-signature)。