Просмотр исходного кода

fix(desktop): preflight Windows signing before builds

winewill 1 неделя назад
Родитель
Сommit
bfe6beb517

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: f8825b652919147aef271accd5f4e521d7d5fc77
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2eb42f74dd8574afdf1348bc6abd5f5af1b979b9
+2026-08-25-electron-desktop-packaging-and-updates.md: 6e5568b8ef3e6316d3c31f0362fdba5af1feda6c
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: 844abb212de08b9f0e973205da7e7b31f301011c

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md

@@ -91,7 +91,7 @@ macOS signing requires a local p12 and its explicit export password. The packagi
 
 The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
 
-Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
+Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into signing preflight, runtime signing and electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
 
 Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification.
 
@@ -107,6 +107,8 @@ Windows updater verification pins the public release certificate's `CN`, `O`, an
 
 ## Implementation
 
+Windows signed packaging performs [one small-probe preflight](../../../../apps/desktop/scripts/windows-signing-preflight.ts) before expensive build stages. Static failures and retained interlocks prevent hardware access; the production signer, timestamp service and certificate verification must succeed once before construction continues. The probe stays in the private audit directory and is never executed. A bounded supervised stage prevents an unattended authentication prompt from leaving compilation waiting indefinitely. Preflight success does not independently validate a PIN because the driver can cache authentication; the pipeline never logs out the token or retries to establish that claim. Subsequent runtime, signer and builder failures still prevent release completion. Explicit unsigned and preparation-only modes bypass all hardware operations, not merely this preflight. Tests cover failure ordering and credential isolation without using the release token; real-token acceptance remains a release-host responsibility.
+
 The release [upload runner](../../../../apps/desktop/scripts/desktop-upload-run.ts) requires durable local evidence for both deployments. It saves the validated destination, artifact hashes and feed bytes before sending requests, flushes intent before each PUT and the available response metadata afterward, and stops on upload or evidence failure. SDK retries are disabled because an uncertain write must remain one inspectable attempt rather than silently repeat a mutable feed update. Terminal output alone cannot establish which operation survived a disconnected terminal; raw SDK errors are excluded because they can expose signed request data. Records survive independently of artifact cleanup, but local storage is not a remote audit service and an absent final result cannot establish remote failure. Upload receipts do not certify CDN propagation. Release operators retain the records and independently verify public bytes.
 
 Windows packaging retains per-run redacted output and timestamped stage/signing events through the [supervisor](../../../../apps/desktop/scripts/packaging-run.mjs). The [hardware interlock](../../../../apps/desktop/scripts/windows-signing-state.mjs) records intent before invoking the command interpreter and survives failures or interrupted runs across processes under one account. A fatal notification terminates the owned stage tree; a failed run never promotes a release record. Successful signatures alone release the interlock. Operator-approved recovery is explicit because a process-local rejected promise cannot protect the token after restarting a build. These records establish application-level operations, not the CSP's internal PIN-attempt count. Tests use fake signing and real isolated process trees, never the release token.

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -91,7 +91,7 @@ macOS 签名必须提供本地 p12 及显式导出密码。打包调用从导入
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 
-Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入签名预检、运行时签名与 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
 
@@ -107,6 +107,8 @@ Windows updater 在 `win.signtoolOptions.publisherName` 中固定公开发布证
 
 ## 实现
 
+Windows 签名打包在耗时构建阶段前执行[一次小探针预检](../../../../apps/desktop/scripts/windows-signing-preflight.ts)。静态检查失败或遗留锁都会阻止访问硬件;只有正式签名器、时间戳服务和证书验证一次成功,才继续构建。探针保留在私有审计目录中,绝不执行。受监督阶段设有时限,避免无人处理的认证弹窗让构建无限等待。驱动可能缓存认证,因此预检成功不等于独立验证 PIN;流程不会为证明这一点而注销令牌或重试。后续运行时、签名器和构建器失败仍阻止发布完成。显式未签名及仅准备模式跳过所有硬件操作,而不只是此次预检。测试在不使用发布令牌的情况下覆盖失败顺序和凭据隔离;真实令牌验收仍由发布主机操作人员负责。
+
 发布[上传执行器](../../../../apps/desktop/scripts/desktop-upload-run.ts)要求两种部署环境都保留持久化本地证据。它在发送请求前保存已验证目标、产物哈希和 feed 字节,每次 PUT 前刷盘意图、之后刷盘可用响应元数据,上传或证据写入失败即停止。SDK 禁用重试,因为结果不确定的写入必须保留为一次可检查的尝试,不能静默重复更新可变 feed。终端断连后,仅有终端输出无法确定哪次操作已完成;原始 SDK 错误可能暴露签名请求数据,因此不予记录。记录独立于产物清理保留,但本地存储不是远端审计服务,缺少最终结果不能证明远端失败。上传回执不认证 CDN 传播。发布者保留记录并独立验证公网字节。
 
 Windows 打包通过[监督程序](../../../../apps/desktop/scripts/packaging-run.mjs)保留每次运行的脱敏输出和带时间戳的阶段/签名事件。[硬件锁定机制](../../../../apps/desktop/scripts/windows-signing-state.mjs)在调用命令解释器前记录意图,失败或运行中断后仍在同一账户的不同进程间保留。致命通知会终止所属阶段的进程树;失败运行绝不生成发布完成记录。只有签名成功才释放锁定状态。恢复必须由操作者明确批准,因为进程内的已拒绝 Promise 无法在重新启动构建后保护令牌。这些记录证明应用级操作,不代表 CSP 内部的 PIN 尝试次数。测试使用假签名和真实隔离进程树,绝不使用发布令牌。

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 7b3c787e4656c68a9a47d7398611bfa9b8e472e3
-README.zh.md: 9558de1b9b119c0ce3a714241fbec3873b8e377f
+README.md: ca0cd16ca50a5e399d2bdf5d892fd333ea02277f
+README.zh.md: 54e75b60254859092c03f336adb19a62b23a5a0d

+ 3 - 1
apps/desktop/README.md

@@ -180,6 +180,8 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an
 
 ### Windows EV signing
 
+Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. A 60-second preflight deadline, signing error or verification failure stops the run without retry. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately.
+
 Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field.
 
 Signed Windows configuration derives the updater's `publisherName` from the same public certificate's `CN`, `O`, and `C` attributes. Each must be present, nonempty, and single-valued. These identity attributes allow certificate renewal without pinning a leaf thumbprint. The installed application's `app-update.yml` carries the expected publisher; the downloaded feed does not choose it. Unsigned test builds omit updater configuration. See the [signature qualification record](tests/README.md) for real-file verification and its limits.
@@ -202,7 +204,7 @@ pnpm run package:desktop:win:x64
 
 Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
 
-The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives the dedicated primary-runtime signing stage and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
+The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated primary-runtime signing stage and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
 
 Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
 

+ 3 - 1
apps/desktop/README.zh.md

@@ -180,6 +180,8 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
 
 ### Windows EV 签名
 
+Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检超过 60 秒、签名报错或验签失败都会停止本轮流程,不重试。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
+
 Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。
 
 签名 Windows 配置从同一份公开证书的 `CN`、`O` 和 `C` 属性生成 updater 的 `publisherName`。每个属性都必须存在、非空且只有一个值。这些身份属性允许证书续期,无需固定叶证书指纹。已安装应用的 `app-update.yml` 保存预期发布者,下载的清单不能选择该身份。未签名测试构建省略 updater 配置。真实文件验证及其限制见[签名验收记录](tests/README.zh.md)。
@@ -202,7 +204,7 @@ pnpm run package:desktop:win:x64
 
 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
 
-PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
+PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
 
 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
 

+ 15 - 2
apps/desktop/scripts/package-target.ts

@@ -301,7 +301,14 @@ async function main(): Promise<void> {
   } finally { run?.finish(success) }
 }
 
-async function packageTarget(
+/**
+ * Prepare one release only after its signing preflight, without publishing from the builder.
+ * @param invocation Validated host, target and packaging mode.
+ * @param environment File-owned release configuration.
+ * @param run Windows stage supervisor; required for signed Windows packaging.
+ * @returns Resolves after preparation or complete packaging; any failed stage prevents a release record.
+ */
+export async function packageTarget(
   invocation: DesktopPackageInvocation,
   environment: NodeJS.ProcessEnv,
   run: ReturnType<typeof createPackagingRun> | undefined,
@@ -324,6 +331,13 @@ async function packageTarget(
   for (const name of WINDOWS_SIGNING_ENV_NAMES) {
     if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
   }
+  const signPrimaryRuntime = target.platform === 'win32' && !invocation.unsigned && !invocation.prepareOnly
+  if (signPrimaryRuntime) {
+    if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run')
+    await run.run('preflight:windows-signing', process.execPath,
+      ['--import', 'tsx/esm', join(APP_ROOT, 'scripts/windows-signing-preflight.ts')],
+      { cwd: APP_ROOT, env: electronBuilderEnv, timeoutMs: 60_000 })
+  }
   await execute(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
   await execute(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
   await execute([
@@ -344,7 +358,6 @@ async function packageTarget(
     '--pack-destination',
     buildPaths.packedLandlock,
   ], buildEnv, REPOSITORY_ROOT)
-  const signPrimaryRuntime = target.platform === 'win32' && !invocation.unsigned && !invocation.prepareOnly
   await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv)
   if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
   await execute(['run', 'prepare:packages'], targetEnv)

+ 98 - 0
apps/desktop/scripts/windows-signing-preflight.ts

@@ -0,0 +1,98 @@
+/** Check the production signing path with one private probe before preparing release artifacts. */
+import { execFile } from 'node:child_process'
+import { X509Certificate, createHash } from 'node:crypto'
+import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { promisify } from 'node:util'
+import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
+import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
+import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
+
+const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n'
+
+interface SigningPreflightOptions {
+  runDirectory: string
+  environment: NodeJS.ProcessEnv
+  stateDirectory?: string
+  compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise<void>
+  sign?: ReturnType<typeof createWindowsTokenSigner>
+  inspect?: (path: string) => Promise<WindowsRuntimeSignature>
+}
+
+async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise<void> {
+  const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment)
+  await promisify(execFile)(compiler, ['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, source], {
+    env: safeEnvironment, windowsHide: true, timeout: 30_000,
+  })
+}
+
+/**
+ * Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate.
+ * @param options Supervised evidence directory, file-owned settings and isolated test adapters.
+ * @returns Resolves after one signature and verification; failures retain evidence and never retry or unlock a token.
+ */
+export async function preflightWindowsSigning(options: SigningPreflightOptions): Promise<void> {
+  const { environment, runDirectory } = options
+  const record = (event: object): void => { recordPackagingEvent(runDirectory, event) }
+  const signer = createWindowsTokenSigner({
+    certificateFile: environment.DSH_DESKTOP_WINDOWS_CER_FILE,
+    signTool: environment.DSH_DESKTOP_WINDOWS_SIGNTOOL,
+    keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+    tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
+    runDirectory,
+    stateDirectory: options.stateDirectory,
+  })
+  const state = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing')
+  const lock = join(state, 'attempt.json')
+  const compiler = join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe')
+  await access(join(runDirectory, 'run.json'))
+  for (const path of [join(runDirectory, 'fatal.json'), lock]) {
+    try { await lstat(path) }
+    catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
+      throw error
+    }
+    throw new Error(`Windows signing preflight refused: inspect retained failure or interlock at ${path}; no automatic recovery`)
+  }
+  if (!(await lstat(compiler)).isFile()) throw new Error('Windows signing preflight requires the local .NET Framework C# compiler')
+  const certificate = await readFile(environment.DSH_DESKTOP_WINDOWS_CER_FILE!)
+  const leaf = new X509Certificate(certificate)
+  if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) {
+    throw new Error('Windows signing preflight requires a currently valid public certificate')
+  }
+  const thumbprint = leaf.fingerprint.replaceAll(':', '')
+  const directory = join(runDirectory, 'signing-preflight')
+  await mkdir(directory)
+  const source = join(directory, 'probe.cs')
+  const output = join(directory, 'probe.exe')
+  await writeFile(source, PROBE_SOURCE, { flag: 'wx', flush: true })
+  record({ type: 'signing-preflight-static', compiler,
+    compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'),
+    signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'),
+    certificateSha256: createHash('sha256').update(certificate).digest('hex') })
+  await (options.compile ?? compileProbe)(compiler, source, output, environment)
+  const inspect = options.inspect ?? inspectWindowsRuntimeSignature
+  if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe')
+  record({ type: 'signing-preflight-probe', path: output, sha256: createHash('sha256').update(await readFile(output)).digest('hex') })
+  await (options.sign ?? signer)({ path: output, hash: 'sha256', isNest: false })
+  const signature = await inspect(output)
+  if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
+    throw new Error('Windows signing preflight verification failed: expected the configured certificate and a valid timestamp')
+  }
+  record({ type: 'signing-preflight-success', ...signature,
+    sha256: createHash('sha256').update(await readFile(output)).digest('hex'), probeExecuted: false })
+}
+
+async function main(): Promise<void> {
+  if (process.platform !== 'win32') throw new Error('Windows signing preflight requires Windows')
+  const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
+  if (!runDirectory) throw new Error('Windows signing preflight requires a supervised packaging run')
+  try { await preflightWindowsSigning({ runDirectory, environment: process.env }) }
+  catch (error) {
+    failPackagingRun(runDirectory, 'windows-signing-preflight-failed')
+    throw error
+  }
+}
+
+if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await main()

+ 55 - 0
apps/desktop/tests/package-target-stages.spec.ts

@@ -0,0 +1,55 @@
+import { writeFileSync } from 'node:fs'
+import { afterEach, expect, it, vi } from 'vitest'
+import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
+
+// Keep the real orchestration and manifest reads; this suite owns no release directories or subprocesses.
+vi.mock('node:fs', async importOriginal => ({
+  ...await importOriginal<typeof import('node:fs')>(),
+  rmSync: vi.fn(), mkdirSync: vi.fn(), writeFileSync: vi.fn(), renameSync: vi.fn(),
+}))
+
+afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() })
+
+const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
+  DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }
+
+function supervisor(failure?: string) {
+  vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs')
+  const stages: string[] = []
+  const run = { directory: 'fixture-record', finish: vi.fn(),
+    run: vi.fn(async (stage: string, _executable: string, _args: readonly string[], _options: { env: NodeJS.ProcessEnv }) => {
+      stages.push(stage)
+      if (stage === failure) throw new Error('stage refused')
+    }) }
+  return { run, stages }
+}
+
+it('requires one signing preflight before building, then records only the complete release', async () => {
+  const { run, stages } = supervisor()
+  await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)
+  expect(stages.slice(0, 2)).toEqual(['preflight:windows-signing', 'run build:official'])
+  expect(stages.filter(stage => stage === 'preflight:windows-signing')).toHaveLength(1)
+  expect(run.run.mock.calls[0]![3]).toMatchObject({ env: { DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }, timeoutMs: 60_000 })
+  expect(run.run.mock.calls[1]![3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
+  expect(writeFileSync).toHaveBeenCalledOnce()
+})
+
+it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh',
+  'exec electron-builder --config electron-builder.config.mjs --win --x64 --publish never'])
+('never continues or records a release after %s fails', async (failure) => {
+  const { run, stages } = supervisor(failure)
+  await expect(packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)).rejects.toThrow('stage refused')
+  expect(stages.at(-1)).toBe(failure)
+  expect(stages.filter(stage => stage === failure)).toHaveLength(1)
+  expect(writeFileSync).not.toHaveBeenCalled()
+})
+
+it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no release record', async (mode) => {
+  const { run, stages } = supervisor()
+  await packageTarget(parseDesktopPackageInvocation(['win-x64', mode], 'win32', 'x64'), environment, run)
+  expect(stages[0]).toBe('run build:official')
+  expect(stages).not.toContain('preflight:windows-signing')
+  expect(stages).not.toContain('run sign:primary-runtime')
+  for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
+  expect(writeFileSync).not.toHaveBeenCalled()
+})

+ 119 - 0
apps/desktop/tests/windows-signing-preflight.spec.ts

@@ -0,0 +1,119 @@
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, expect, it, vi } from 'vitest'
+import { createPackagingRun } from '../scripts/packaging-run.mjs'
+import { preflightWindowsSigning } from '../scripts/windows-signing-preflight.ts'
+import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
+
+vi.mock('node:crypto', async importOriginal => ({
+  ...await importOriginal<typeof import('node:crypto')>(),
+  X509Certificate: class {
+    ca = false
+    keyUsage = ['1.3.6.1.5.5.7.3.3']
+    fingerprint = 'A'.repeat(40)
+    validFrom = 'Jan 1 2000 GMT'
+    validTo = 'Jan 1 2100 GMT'
+  },
+}))
+
+const roots: string[] = []
+afterEach(async () => {
+  vi.useRealTimers()
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
+it.each(['1999-01-01', '2101-01-01'])('rejects a certificate outside its validity period at %s', async (date) => {
+  const options = await fixture()
+  vi.useFakeTimers({ toFake: ['Date'] })
+  vi.setSystemTime(new Date(date))
+  await expect(preflightWindowsSigning(options)).rejects.toThrow('currently valid')
+  expect(options.compile).not.toHaveBeenCalled()
+  expect(options.sign).not.toHaveBeenCalled()
+})
+
+it.skipIf(process.platform !== 'win32')('compiles a real unsigned probe without executing it or accessing a token', async () => {
+  const { compile: _compile, ...options } = await fixture()
+  options.environment.SystemRoot = process.env.SystemRoot!
+  options.inspect.mockImplementation(async (path: string) => options.sign.mock.calls.length === 0
+    ? inspectWindowsRuntimeSignature(path)
+    : { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) })
+  await preflightWindowsSigning(options)
+  const bytes = await readFile(join(options.runDirectory, 'signing-preflight/probe.exe'))
+  expect(bytes.subarray(0, 2).toString()).toBe('MZ')
+  expect(bytes.length).toBeLessThan(16 * 1024)
+  expect(options.sign).toHaveBeenCalledOnce()
+})
+
+async function fixture() {
+  const root = await mkdtemp(join(tmpdir(), 'signing-preflight-'))
+  roots.push(root)
+  const run = createPackagingRun(join(root, 'records'), {})
+  const compiler = join(root, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe')
+  await mkdir(dirname(compiler), { recursive: true })
+  await writeFile(compiler, 'inert compiler')
+  const environment = { SystemRoot: root,
+    DSH_DESKTOP_WINDOWS_CER_FILE: join(root, 'public.cer'),
+    DSH_DESKTOP_WINDOWS_SIGNTOOL: join(root, 'signtool.exe'),
+    DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'fixture-container', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }
+  await writeFile(environment.DSH_DESKTOP_WINDOWS_CER_FILE, 'inert certificate')
+  await writeFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL, 'inert signer')
+  const sequence: string[] = []
+  const compile = vi.fn(async (_compiler: string, _source: string, output: string) => {
+    sequence.push('compile'); await writeFile(output, 'inert unsigned probe')
+  })
+  const sign = vi.fn(async () => { sequence.push('sign') })
+  const inspect = vi.fn(async (_path: string) => {
+    sequence.push('inspect')
+    return sign.mock.calls.length === 0
+      ? { status: 'NotSigned', timestamped: false, thumbprint: null }
+      : { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) }
+  })
+  return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect }
+}
+
+it('compiles one new probe and verifies one production-format signature before success', async () => {
+  const options = await fixture()
+  await preflightWindowsSigning(options)
+  expect(options.sequence).toEqual(['compile', 'inspect', 'sign', 'inspect'])
+  expect(options.sign).toHaveBeenCalledExactlyOnceWith({ path: join(options.runDirectory, 'signing-preflight/probe.exe'), hash: 'sha256', isNest: false })
+  const events = await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8')
+  expect(events).toContain('signing-preflight-success')
+  expect(events).toContain('"probeExecuted":false')
+  expect(events).not.toContain('fixture-pin')
+})
+
+it.each(['interlock', 'fatal', 'compiler', 'certificate', 'signTool', 'pin', 'audit'] as const)
+('rejects static %s failure without compiling or signing', async (failure) => {
+  const options = await fixture()
+  if (failure === 'interlock') {
+    await mkdir(options.stateDirectory)
+    await writeFile(join(options.stateDirectory, 'attempt.json'), 'retained attempt')
+  } else if (failure === 'fatal') await writeFile(join(options.runDirectory, 'fatal.json'), 'failed')
+  else if (failure === 'compiler') await rm(options.compiler)
+  else if (failure === 'certificate') await rm(options.environment.DSH_DESKTOP_WINDOWS_CER_FILE)
+  else if (failure === 'signTool') await rm(options.environment.DSH_DESKTOP_WINDOWS_SIGNTOOL)
+  else if (failure === 'pin') options.environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN = ''
+  else {
+    await rm(join(options.runDirectory, 'events.jsonl'))
+    await mkdir(join(options.runDirectory, 'events.jsonl'))
+  }
+  await expect(preflightWindowsSigning(options)).rejects.toThrow()
+  expect(options.compile).not.toHaveBeenCalled()
+  expect(options.sign).not.toHaveBeenCalled()
+  if (failure === 'interlock') expect(await readFile(join(options.stateDirectory, 'attempt.json'), 'utf8')).toBe('retained attempt')
+})
+
+it.each(['compile', 'unexpected-signature', 'sign', 'timestamp', 'publisher', 'verification'] as const)
+('stops on %s failure without retry or successful evidence', async (failure) => {
+  const options = await fixture()
+  if (failure === 'compile') options.compile.mockRejectedValueOnce(new Error('compiler refused'))
+  else if (failure === 'unexpected-signature') options.inspect.mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) })
+  else if (failure === 'sign') options.sign.mockRejectedValueOnce(new Error('token refused'))
+  else options.inspect.mockResolvedValueOnce({ status: 'NotSigned', timestamped: false, thumbprint: null })
+    .mockResolvedValueOnce({ status: failure === 'verification' ? 'HashMismatch' : 'Valid',
+      timestamped: failure !== 'timestamp', thumbprint: (failure === 'publisher' ? 'B' : 'A').repeat(40) })
+  await expect(preflightWindowsSigning(options)).rejects.toThrow()
+  expect(options.sign).toHaveBeenCalledTimes(failure === 'compile' || failure === 'unexpected-signature' ? 0 : 1)
+  expect(await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8')).not.toContain('signing-preflight-success')
+})