From 4d690341a7c725dd3849783236d7f3f0c90cdb2a Mon Sep 17 00:00:00 2001 From: zhayujie Date: Mon, 13 Jul 2026 16:40:32 +0800 Subject: [PATCH] feat(desktop): add Windows code signing --- .github/workflows/release.yml | 65 ++++++++++---- desktop/electron-builder.win.js | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 desktop/electron-builder.win.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2aeaf0ef..610a7c70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,6 +113,31 @@ jobs: shell: bash run: npm run build + # Download the Windows remote-signing CLI (vendor-neutral: the URL comes + # from a repo variable, so a public workflow never names the signing + # vendor). Only runs on the Windows leg and only when a URL is set; + # otherwise the build stays unsigned. SIGNTOOL_PATH is exported for the + # next step's electron-builder.win.js to invoke. + - name: Download Windows signing CLI + if: matrix.platform == 'win' && vars.SIGNTOOL_CLI_URL != '' + shell: bash + env: + SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }} + run: | + mkdir -p "$RUNNER_TEMP/signtool" + curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip" + # Unzip and locate the signtool executable regardless of nesting. + unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null + exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)" + if [ -z "$exe" ]; then + echo "signtool.exe not found in downloaded archive" >&2 + find "$RUNNER_TEMP/signtool" -type f >&2 + exit 1 + fi + # Normalize to a Windows-style path for execFileSync in Node. + echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV" + echo "resolved signtool: $exe" + - name: Build & publish (electron-builder) working-directory: desktop shell: bash @@ -124,8 +149,14 @@ jobs: # is the correct state for unsigned builds. MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} - WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} - WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + # Windows remote code signing (Racent-style CLI). Credentials are + # secrets; SIGNTOOL_PATH was exported by the download step above. + # COW_SIGN_DRY_RUN (repo variable) lets us validate the whole pipeline + # with a self-signed cert before buying a real one — no quota used. + SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }} + SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }} + SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }} + COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }} run: | # Pick the signing cert for THIS platform only. The mac and win secrets # are both present in the job env, but a mac cert must never leak into a @@ -137,6 +168,10 @@ jobs: # # NOTE: we only ever `export`, never `unset`, GitHub-injected env vars # (an `unset` can return non-zero and abort under errexit). + # macOS keeps the classic CSC_LINK (.p12) flow. Windows no longer uses + # a local .pfx (EV private keys can't be exported since 2023); it signs + # via the remote CLI wired into electron-builder.win.js instead, using + # the SIGNTOOL_* env already set above — nothing to export here. case "${{ matrix.platform }}" in mac) if [ -n "$MAC_CSC_LINK" ]; then @@ -144,12 +179,6 @@ jobs: export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD" fi ;; - win) - if [ -n "$WIN_CSC_LINK" ]; then - export CSC_LINK="$WIN_CSC_LINK" - export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD" - fi - ;; esac # Never let electron-builder publish: our publish target is a generic @@ -157,19 +186,25 @@ jobs: # installers to R2 and register them in D1 ourselves (publish-r2 job). # `--publish never` still emits the latest*.yml files. # - # CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to - # inject mac.binaries (the backend Mach-O files to hardened-sign for - # notarization) — it's a pure no-op on Windows. Passing --config on - # Windows was what silently broke the Windows build (it produced no - # installer while the job still reported success; Windows worked fine - # before --config was introduced). So Windows uses the plain - # package.json build config and only mac uses the dynamic one. + # CONFIG PER PLATFORM: each platform loads its OWN dynamic config. + # mac -> electron-builder.js (injects mac.binaries for signing) + # win -> electron-builder.win.js (wires the remote sign hook + + # afterPack to sign the backend exe) + # HISTORY: passing --config on Windows previously broke the build (no + # installer, job still green). That happened because the MAC config + # (electron-builder.js) was a no-op on Windows yet still disturbed the + # run. The fix is a DEDICATED win config that correctly extends + # config.win — not sharing the mac one. If a build ever runs WITHOUT + # signing configured, electron-builder.win.js still returns the base + # config unchanged (sign hook just skips), so the installer is still + # produced. # # Invoke via `node ` rather than `npx`: on Windows `npx` is # npx.cmd (a batch wrapper) and running it from this Git Bash step can # make bash return before the wrapped process finishes. node skips it. case "${{ matrix.platform }}" in mac) config_arg="--config electron-builder.js" ;; + win) config_arg="--config electron-builder.win.js" ;; *) config_arg="" ;; esac node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never diff --git a/desktop/electron-builder.win.js b/desktop/electron-builder.win.js new file mode 100644 index 00000000..ec46ae02 --- /dev/null +++ b/desktop/electron-builder.win.js @@ -0,0 +1,146 @@ +/** + * Dynamic electron-builder config for WINDOWS code signing. + * + * Mirrors electron-builder.js (which handles mac.binaries) but for Windows. + * It wires the Racent remote code-signing CLI into electron-builder so that + * every produced .exe (the app launcher + the NSIS installer) is signed in the + * cloud — the EV private key never leaves the CA's HSM, satisfying the post-2023 + * "private key must live in hardware" rule. + * + * Two signing passes are needed: + * 1. win.signtoolOptions.sign (customSign) — electron-builder calls this for + * each artifact it produces (app .exe, uninstaller, NSIS installer). + * 2. afterPack — signs the PyInstaller backend (cowagent-backend.exe) that is + * copied in via extraResources. electron-builder's sign hook only touches + * its OWN outputs, so the nested backend exe must be signed here, BEFORE + * the installer is assembled, so the signed backend ends up inside it. + * + * VENDOR PRIVACY: the CLI path and all credentials come from env vars only. + * Nothing in this file (or the public workflow) names the reseller, so a public + * repo never leaks which signing vendor we use. + * + * DRY-RUN / SKIP: when SIGNTOOL_CERT_CODE is absent we skip signing entirely + * (unsigned dev/dry builds keep working). When COW_SIGN_DRY_RUN=1 we pass + * --dry-run so the WHOLE pipeline can be validated in CI with a self-signed + * cert, WITHOUT a real certificate and WITHOUT consuming any signing quota. + */ +const { execFileSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +const config = require('./package.json').build + +// Absolute path to the Racent signtool CLI on the runner. Injected by CI so +// this file never hardcodes a vendor download URL. e.g. C:\signtool\signtool.exe +const SIGNTOOL = process.env.SIGNTOOL_PATH || '' +const ACCESS_KEY = process.env.SIGNTOOL_ACCESS_KEY || '' +const ACCESS_SECRET = process.env.SIGNTOOL_ACCESS_SECRET || '' +const CERT_CODE = process.env.SIGNTOOL_CERT_CODE || '' +// Dry-run validates the pipeline with a self-signed cert (no quota, no real +// cert needed). Any truthy value enables it. +const DRY_RUN = !!process.env.COW_SIGN_DRY_RUN + +// RFC3161 timestamp server for SHA256. Microsoft's is reliable from CI runners +// worldwide; overridable via env if needed. +const TIMESTAMP = process.env.SIGNTOOL_TIMESTAMP || 'http://timestamp.acs.microsoft.com' + +// Signing is possible when we have the CLI plus either a real cert code or +// explicit dry-run mode (dry-run accepts placeholder credentials). +function canSign() { + if (!SIGNTOOL || !fs.existsSync(SIGNTOOL)) return false + if (DRY_RUN) return true + return !!(ACCESS_KEY && ACCESS_SECRET && CERT_CODE) +} + +/** + * Sign a single file in place using the Racent CLI. The CLI writes to a + * separate --out path (it refuses to overwrite an existing file), so we sign to + * a temp file and atomically move it back over the original. + */ +function signFile(filePath) { + const tmpOut = `${filePath}.signed` + // Remove a stale temp from a previous failed run (CLI errors if --out exists). + try { + if (fs.existsSync(tmpOut)) fs.rmSync(tmpOut) + } catch { + /* ignore */ + } + + const args = [ + 'sign', + ...(DRY_RUN ? ['--dry-run'] : []), + `--access-key=${ACCESS_KEY}`, + `--access-secret=${ACCESS_SECRET}`, + `--cert-code=${CERT_CODE}`, + `--file=${filePath}`, + `--out=${tmpOut}`, + '--sha1=false', + '--sha2=true', + '--timestamp-rfc3161', + TIMESTAMP, + ] + + // Never print credentials: log only the file being signed. + console.log(`[win-sign] signing ${path.basename(filePath)}${DRY_RUN ? ' (dry-run)' : ''}`) + execFileSync(SIGNTOOL, args, { stdio: ['ignore', 'inherit', 'inherit'] }) + + if (!fs.existsSync(tmpOut)) { + throw new Error(`[win-sign] signed output not produced for ${filePath}`) + } + // Replace the original with the signed copy. + fs.rmSync(filePath) + fs.renameSync(tmpOut, filePath) +} + +// electron-builder calls this for each artifact it generates (app exe, NSIS +// installer, uninstaller). Signature: (configuration) => void, where +// configuration.path is the file to sign. +async function customSign(configuration) { + if (!canSign()) { + console.warn('[win-sign] signing skipped (no signtool/credentials)') + return + } + signFile(configuration.path) +} + +// Recursively collect .exe/.dll under the packed backend dir so nested native +// binaries get signed too (Defender flags PyInstaller bundles most often). +function collectSignables(dir) { + const out = [] + const walk = (d) => { + for (const name of fs.readdirSync(d)) { + const full = path.join(d, name) + const st = fs.lstatSync(full) + if (st.isSymbolicLink()) continue + if (st.isDirectory()) { + walk(full) + continue + } + if (/\.(exe|dll)$/i.test(name)) out.push(full) + } + } + walk(dir) + return out +} + +// Sign the PyInstaller backend BEFORE the installer is built, so the signed +// backend is what gets packaged. context.appOutDir is the unpacked app dir. +async function afterPack(context) { + if (process.platform !== 'win32') return + if (!canSign()) return + const backendDir = path.join(context.appOutDir, 'resources', 'backend', 'cowagent-backend') + if (!fs.existsSync(backendDir)) { + console.warn(`[win-sign] backend dir not found, skipping backend signing: ${backendDir}`) + return + } + const files = collectSignables(backendDir) + console.log(`[win-sign] signing ${files.length} backend binaries`) + for (const f of files) signFile(f) +} + +// Extend the base config: attach the sign hook + afterPack. Only meaningful on +// Windows builds (this config is only passed via --config on the win matrix leg). +config.win = { ...config.win, signtoolOptions: { sign: customSign, signingHashAlgorithms: ['sha256'] } } +config.afterPack = afterPack + +module.exports = config