diff --git a/.github/workflows/windows-msi.yml b/.github/workflows/windows-msi.yml index 2f41f9c..cd4522f 100644 --- a/.github/workflows/windows-msi.yml +++ b/.github/workflows/windows-msi.yml @@ -7,15 +7,21 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + sign: + description: "Submit unsigned MSI to SignPath (requires SignPath approval)" + type: boolean + default: false permissions: contents: write + actions: read jobs: msi: name: Build MSI (windows-msvc) runs-on: windows-latest - timeout-minutes: 90 + timeout-minutes: 120 steps: - name: Checkout @@ -41,7 +47,7 @@ jobs: foreach ($bin in $wixBins) { Write-Host "Adding WiX bin: $bin" echo "$bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - $env:Path = "$bin;$env:Path" + $env:Path = "$bin;$env:PATH" } } @@ -70,7 +76,63 @@ jobs: shell: pwsh run: cargo install cargo-wix --locked - - name: Build MSI + - name: Read package version + id: meta + shell: pwsh + run: | + $m = Select-String -Path Cargo.toml -Pattern '^\s*version\s*=\s*"([^"]+)"' | Select-Object -First 1 + if (-not $m) { throw "Could not read version from Cargo.toml" } + $version = $m.Matches.Groups[1].Value + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "Package version: $version" + + - name: Resolve code signing backend + id: signing + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + WINDOWS_CODESIGN_PFX_BASE64: ${{ secrets.WINDOWS_CODESIGN_PFX_BASE64 }} + WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }} + DISPATCH_SIGN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sign || 'false' }} + run: | + $isTag = $env:GITHUB_REF -like 'refs/tags/v*' + $wantSignPath = $isTag -or ($env:DISPATCH_SIGN -eq 'true') + $hasSignPath = -not [string]::IsNullOrWhiteSpace($env:SIGNPATH_API_TOKEN) -and + -not [string]::IsNullOrWhiteSpace($env:SIGNPATH_ORGANIZATION_ID) + $hasPfx = -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_BASE64) -and + -not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PASSWORD) + + if ($wantSignPath -and $hasSignPath) { + "backend=signpath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "Signing backend: SignPath (OSS / Foundation certificate)" + return + } + + if ($hasPfx) { + $pfx = Join-Path $env:RUNNER_TEMP "wslc-codesign.pfx" + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CODESIGN_PFX_BASE64)) + "backend=pfx" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "CODESIGN_PFX=$pfx" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Host "Signing backend: local PFX (signtool)" + return + } + + if ($isTag) { + throw @" + Tag releases must be Authenticode-signed. + Preferred (free for OSS): SignPath Foundation — set + secrets.SIGNPATH_API_TOKEN + vars.SIGNPATH_ORGANIZATION_ID + and create project/signing policy + artifact config (see README). + Alternative: WINDOWS_CODESIGN_PFX_BASE64 + WINDOWS_CODESIGN_PASSWORD. + "@ + } + + "backend=none" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "No signing backend configured; producing unsigned build." + + - name: Build release binary shell: pwsh run: | if (-not (Test-Path "wix\main.wxs")) { @@ -79,7 +141,77 @@ jobs: if (-not (Test-Path "wix\Product.ico")) { throw "Missing wix\Product.ico" } - cargo wix --nocapture + cargo build --release + + - name: Sign application executable (PFX) + if: steps.signing.outputs.backend == 'pfx' + shell: pwsh + env: + CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }} + WINDOWS_CODESIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_CODESIGN_TIMESTAMP_URL }} + run: | + ./scripts/sign-windows.ps1 -Path "target\release\wslc-desktop.exe" + + - name: Build MSI (from current release binary) + shell: pwsh + run: cargo wix --nocapture --no-build + + - name: Sign MSI (PFX) + if: steps.signing.outputs.backend == 'pfx' + shell: pwsh + env: + CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }} + WINDOWS_CODESIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_CODESIGN_TIMESTAMP_URL }} + run: | + $msi = Get-ChildItem "target\wix\*.msi" | Select-Object -First 1 + if (-not $msi) { throw "No MSI under target\wix" } + ./scripts/sign-windows.ps1 -Path $msi.FullName + + - name: Upload unsigned MSI for SignPath + if: steps.signing.outputs.backend == 'signpath' + id: upload-unsigned + uses: actions/upload-artifact@v4 + with: + name: wslc-desktop-msi-unsigned + path: target/wix/*.msi + if-no-files-found: error + retention-days: 5 + + - name: Submit SignPath signing request + if: steps.signing.outputs.backend == 'signpath' + id: signpath + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'wslc-desktop' }} + signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG || 'release-signing' }} + artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG || 'release' }} + github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} + wait-for-completion: true + # OSS policies require a human approver in SignPath before signing finishes. + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: ${{ github.workspace }}/signed + parameters: | + version: ${{ toJSON(steps.meta.outputs.version) }} + + - name: Install signed MSI from SignPath + if: steps.signing.outputs.backend == 'signpath' + shell: pwsh + run: | + $signed = Get-ChildItem -Path "signed" -Recurse -Filter "*.msi" -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $signed) { + throw "SignPath completed but no .msi found under signed/" + } + New-Item -ItemType Directory -Force -Path "target\wix" | Out-Null + Get-ChildItem "target\wix\*.msi" -ErrorAction SilentlyContinue | Remove-Item -Force + $dest = Join-Path "target\wix" $signed.Name + Copy-Item -LiteralPath $signed.FullName -Destination $dest -Force + Write-Host "Installed signed MSI: $dest" + if ('${{ steps.signpath.outputs.signing-request-web-url }}') { + Write-Host "SignPath request: ${{ steps.signpath.outputs.signing-request-web-url }}" + } - name: Upload MSI artifact uses: actions/upload-artifact@v4 @@ -98,3 +230,11 @@ jobs: overwrite_files: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Remove PFX from runner + if: always() && steps.signing.outputs.backend == 'pfx' + shell: pwsh + run: | + if ($env:CODESIGN_PFX -and (Test-Path -LiteralPath $env:CODESIGN_PFX)) { + Remove-Item -LiteralPath $env:CODESIGN_PFX -Force + } diff --git a/.signpath/artifact-configurations/release.xml b/.signpath/artifact-configurations/release.xml new file mode 100644 index 0000000..fb6679c --- /dev/null +++ b/.signpath/artifact-configurations/release.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e2f09..f5d484c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +### 变更 + +- Release 流水线支持 Authenticode 签名以降低 SmartScreen 拦截。优先走 **SignPath Foundation**(深签 MSI 内 `bin/wslc-desktop.exe` 再签 MSI);也可备选自有 PFX。`v*` tag 未配置任一签名后端时 CI 失败。 + - CI Authenticode-signs via SignPath (preferred, free for OSS) or a local PFX; tag releases require one of them. + ## [0.1.2] - 2026-10-11 ### 修复 diff --git a/Cargo.toml b/Cargo.toml index 2d55283..7cdb69f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ repository = "https://github.com/timeroute/wslc-desktop" homepage = "https://timeroute.github.io/wslc-desktop-website/" authors = ["WSLc Desktop Contributors"] +[target.'cfg(windows)'.build-dependencies] +winres = "0.1" + [package.metadata.wix] # Used by `cargo wix` on Windows to produce target/wix/*.msi name = "WSLc Desktop" diff --git a/README.md b/README.md index 0f34998..c8d1a00 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,62 @@ cargo wix # 当前 0.1.2:WSLc.Desktop-0.1.2-x86_64.msi ``` -可选:`cargo wix --no-build`(已有 release 产物时)、`cargo wix --sign`(需 `signtool`)。 +可选:`cargo wix --no-build`(已有 release 产物时)。 + +### 代码签名(降低 SmartScreen) + +未签名的 MSI/EXE 容易触发 Windows SmartScreen。发布流水线对 **EXE(MSI 内嵌)+ MSI** 做 Authenticode 签名。 + +#### 推荐:SignPath Foundation(开源免费) + +1. 在 [signpath.org/apply](https://signpath.org/apply) 为本仓库申请 SignPath Foundation 免费代码签名(需 OSI 许可、公开仓库、已有 Release、团队 MFA 等,见 [条件](https://signpath.org/terms.html))。 +2. 批准后在 SignPath: + - 启用 Trusted Build System:**GitHub.com**,并安装 SignPath GitHub App + - 创建 Project(建议 slug:`wslc-desktop`) + - 创建 Signing Policy(建议 slug:`release-signing`,发布需人工审批) + - 添加 Artifact Configuration,slug:`release`,XML 使用仓库内 [`.signpath/artifact-configurations/release.xml`](.signpath/artifact-configurations/release.xml) +3. 仓库 Settings → Secrets and variables → Actions: + +| 类型 | 名称 | 说明 | +|------|------|------| +| Secret | `SIGNPATH_API_TOKEN` | SignPath → Profile → API Tokens(Submitter) | +| Variable | `SIGNPATH_ORGANIZATION_ID` | 组织 ID(仪表盘 URL / 设置) | +| Variable(可选) | `SIGNPATH_PROJECT_SLUG` | 默认 `wslc-desktop` | +| Variable(可选) | `SIGNPATH_SIGNING_POLICY_SLUG` | 默认 `release-signing` | +| Variable(可选) | `SIGNPATH_ARTIFACT_CONFIGURATION_SLUG` | 默认 `release` | + +`v*` tag 会上传未签名 MSI → 提交 SignPath → **在 SignPath 控制台批准** → CI 下载已签名 MSI 并创建 Release(等待审批最长约 1 小时)。也可 Actions → **Windows MSI** → **Run workflow** 并勾选 `sign` 做试签。 + +#### 备选:自有 PFX + +若已有 OV/EV 代码签名证书,可改用本地 `signtool`(无 SignPath 配置时生效): + +- Secrets:`WINDOWS_CODESIGN_PFX_BASE64`、`WINDOWS_CODESIGN_PASSWORD` +- 可选 Variable:`WINDOWS_CODESIGN_TIMESTAMP_URL`(默认 DigiCert) + +```powershell +cargo build --release +.\scripts\sign-windows.ps1 -Path target\release\wslc-desktop.exe -PfxPath cert.pfx -PfxPassword '***' +cargo wix --no-build +.\scripts\sign-windows.ps1 -Path (Get-ChildItem target\wix\*.msi).FullName -PfxPath cert.pfx -PfxPassword '***' +``` + +`v*` tag **必须**能走 SignPath 或 PFX 之一,否则 CI 失败。PR / `main` 无证书时仍可打未签名 artifact。 + +### Code signing policy + +Free code signing provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org). + +| Role | Who | +|------|-----| +| Committers and reviewers | [Repository collaborators](https://github.com/timeroute/wslc-desktop/settings/access) with write access | +| Approvers | [Repository owners / maintainers](https://github.com/timeroute/wslc-desktop/settings/access) who approve SignPath signing requests | + +Privacy policy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. ### 发布到 GitHub Release -打 `v*` tag 会触发 [Windows MSI](.github/workflows/windows-msi.yml) 工作流:构建 MSI 并创建 Release 挂载安装包。 +打 `v*` tag 会触发 [Windows MSI](.github/workflows/windows-msi.yml):编译 → 打 MSI → SignPath(或 PFX)签名 → 创建 Release。 ```powershell # 版本与 Cargo.toml 对齐,例如 0.1.2 → v0.1.2 diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..ef87e8d --- /dev/null +++ b/build.rs @@ -0,0 +1,27 @@ +fn main() { + #[cfg(windows)] + embed_windows_version_info(); +} + +#[cfg(windows)] +fn embed_windows_version_info() { + // VERSIONINFO strings must match SignPath artifact metadata restrictions + // (.signpath/artifact-configurations/release.xml) and the `version` parameter. + let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION"); + let mut res = winres::WindowsResource::new(); + res.set("ProductName", "WSLc Desktop"); + res.set( + "FileDescription", + "Lightweight desktop manager for Windows WSL Containers (WSLc)", + ); + res.set("CompanyName", "WSLc Desktop"); + res.set("LegalCopyright", "Copyright WSLc Desktop contributors"); + res.set("OriginalFilename", "wslc-desktop.exe"); + res.set("InternalName", "wslc-desktop"); + res.set("FileVersion", &version); + res.set("ProductVersion", &version); + if let Err(e) = res.compile() { + eprintln!("winres failed: {e}"); + std::process::exit(1); + } +} diff --git a/scripts/sign-windows.ps1 b/scripts/sign-windows.ps1 new file mode 100644 index 0000000..ca4069d --- /dev/null +++ b/scripts/sign-windows.ps1 @@ -0,0 +1,116 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Authenticode-sign Windows binaries / MSI with signtool (SHA-256 + RFC3161 timestamp). + +.DESCRIPTION + Expects a PFX on disk (from CI secrets) and Windows SDK signtool.exe. + Retries timestamping a few times — TSA endpoints are occasionally flaky. + +.PARAMETER Path + One or more .exe / .msi files to sign. + +.PARAMETER PfxPath + Path to the code-signing PFX. Defaults to $env:CODESIGN_PFX. + +.PARAMETER PfxPassword + PFX password. Defaults to $env:CODESIGN_PASSWORD / WINDOWS_CODESIGN_PASSWORD. + +.PARAMETER TimestampUrl + RFC3161 timestamp URL. Defaults to DigiCert. +#> +param( + [Parameter(Mandatory = $true)] + [string[]] $Path, + + [string] $PfxPath = $env:CODESIGN_PFX, + + [string] $PfxPassword = $( + if ($env:CODESIGN_PASSWORD) { $env:CODESIGN_PASSWORD } + else { $env:WINDOWS_CODESIGN_PASSWORD } + ), + + [string] $TimestampUrl = $( + if ($env:WINDOWS_CODESIGN_TIMESTAMP_URL) { $env:WINDOWS_CODESIGN_TIMESTAMP_URL } + else { 'http://timestamp.digicert.com' } + ) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Find-SignTool { + $cmd = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + + $kitRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + if (-not (Test-Path $kitRoot)) { + throw "signtool.exe not found (Windows SDK missing under '$kitRoot')" + } + $candidate = Get-ChildItem -Path $kitRoot -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $candidate) { + throw 'signtool.exe not found under Windows Kits\10\bin' + } + return $candidate.FullName +} + +if ([string]::IsNullOrWhiteSpace($PfxPath) -or -not (Test-Path -LiteralPath $PfxPath)) { + throw "PFX not found. Set -PfxPath or CODESIGN_PFX (got '$PfxPath')." +} +if ($null -eq $PfxPassword) { + throw 'PFX password missing. Set -PfxPassword or CODESIGN_PASSWORD / WINDOWS_CODESIGN_PASSWORD.' +} + +$files = @() +foreach ($p in $Path) { + if (-not (Test-Path -LiteralPath $p)) { + throw "File to sign not found: $p" + } + $files += (Resolve-Path -LiteralPath $p).Path +} + +$signtool = Find-SignTool +Write-Host "Using signtool: $signtool" +Write-Host "Timestamp URL: $TimestampUrl" + +foreach ($file in $files) { + Write-Host "Signing $file" + $ok = $false + foreach ($attempt in 1..4) { + & $signtool sign ` + /fd SHA256 ` + /td SHA256 ` + /tr $TimestampUrl ` + /f $PfxPath ` + /p $PfxPassword ` + /d 'WSLc Desktop' ` + $file + if ($LASTEXITCODE -eq 0) { + $ok = $true + break + } + Write-Warning "signtool failed (exit $LASTEXITCODE), attempt $attempt/4" + Start-Sleep -Seconds (10 * $attempt) + } + if (-not $ok) { + throw "Failed to sign: $file" + } + + & $signtool verify /pa /v $file + if ($LASTEXITCODE -ne 0) { + throw "signtool verify failed: $file" + } + $sig = Get-AuthenticodeSignature -FilePath $file + if ($sig.Status -ne 'Valid') { + throw "Authenticode status for $file is $($sig.Status)" + } + if ($null -eq $sig.TimeStamperCertificate) { + throw "No timestamp countersignature on $file" + } + Write-Host "Verified: $file (signer=$($sig.SignerCertificate.Subject))" +} + +Write-Host 'All files signed and verified.'