فهرست منبع

fix(desktop): preserve verified runtime signatures during packaging

winewill 2 هفته پیش
والد
کامیت
b06ea72226

+ 2 - 2
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.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/feature/2026-09-14-desktop-primary-runtime.md
-2026-09-14-desktop-primary-runtime.md: 334b9a1aaa21021c8be4f3df42e2ada9e986d0f9
-2026-09-14-desktop-primary-runtime.zh.md: 1353aab6f1d8e91d6bc700dc16c0a17983ee7707
+2026-09-14-desktop-primary-runtime.md: 62e09c9f29cadbb8b416a304856e261138f462a9
+2026-09-14-desktop-primary-runtime.zh.md: d961c884a1c4b660201cdd555d921c35f5d4ca03

+ 1 - 1
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md

@@ -18,7 +18,7 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks these
 
 macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
 
-Windows signed packaging separates materialization from execution with a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. The existing per-user interlock, serialized signer and redacted journal own hardware calls; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access.
+Windows signed packaging separates materialization from execution with a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access.
 
 Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md

@@ -18,7 +18,7 @@ Node 下载并校验完整锁定 wheel 集的哈希,将这些仅含库的压
 
 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
 
-Windows 签名打包通过受监督的第一方运行时签名阶段,将文件准备与执行分开。PE 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。
+Windows 签名打包通过受监督的第一方运行时签名阶段,将文件准备与执行分开。PE 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。
 
 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
 

+ 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: 97ae244877b8d58b037b0a97c7620f6a19f5b9de
-README.zh.md: dcb53cab532025efc35b4eb48c438f89cfe498b9
+README.md: c0cbe2163567c1edea346ceb9a81c96144601293
+README.zh.md: 4b82269b4a77b40f6f8628ab3ddc28981dc95de5

+ 1 - 1
apps/desktop/README.md

@@ -12,7 +12,7 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r
 
 ### Bundled workspace dependencies
 
-Signed Windows packaging preserves valid vendor signatures and signs unsigned PE executables, DLLs, Python extensions and Node addons in the primary runtime before executing its smoke checks. Each new signature must match the configured certificate and carry a timestamp; invalid existing signatures, signing errors and verification errors stop the run without retries. Checks include decimal, XML, LZMA, UUID, numpy and pandas. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. A passing smoke does not establish compatibility for every extension or enterprise policy.
+Signed Windows packaging preserves valid vendor signatures and signs unsigned PE executables, DLLs, Python extensions and Node addons in the primary runtime before executing its smoke checks. Each new signature must match the configured certificate and carry a timestamp; invalid existing signatures, signing errors and verification errors stop the run without retries. Electron-builder preserves copied runtime executables only after checking their signature and exact equality with the prepared file, preventing repeat signing during resource copying. Checks include decimal, XML, LZMA, UUID, numpy and pandas. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. A passing smoke does not establish compatibility for every extension or enterprise policy.
 
 Desktop carries independent Python, Node.js and pnpm distributions, with numpy and pandas in Python's `site-packages`. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
 

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

@@ -12,7 +12,7 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含
 
 ### 内置工作区依赖
 
-Windows 签名打包保留有效的上游签名,并在执行冒烟检查前,为第一方运行时中未签名的 PE 可执行文件、DLL、Python 扩展和 Node 插件补签。每个新签名必须匹配配置的证书且带时间戳;已有签名无效、签名错误或验签错误都会停止本轮执行,不自动重试。检查覆盖 decimal、XML、LZMA、UUID、numpy 和 pandas。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表所有扩展或企业策略都兼容。
+Windows 签名打包保留有效的上游签名,并在执行冒烟检查前,为第一方运行时中未签名的 PE 可执行文件、DLL、Python 扩展和 Node 插件补签。每个新签名必须匹配配置的证书且带时间戳;已有签名无效、签名错误或验签错误都会停止本轮执行,不自动重试。electron-builder 只有在校验复制后运行时可执行文件的签名、且文件与已准备的源文件逐字节一致后,才保留其签名,避免复制资源时重复签名。检查覆盖 decimal、XML、LZMA、UUID、numpy 和 pandas。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表所有扩展或企业策略都兼容。
 
 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包,并在 Python 的 `site-packages` 中预装 numpy 和 pandas。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
 

+ 1 - 0
apps/desktop/electron-builder.config.d.mts

@@ -42,6 +42,7 @@ export interface DesktopElectronBuilderConfig {
     readonly installerLanguages: readonly ['en_US', 'zh_CN']
   }
   readonly beforeBuild: () => Promise<boolean>
+  readonly beforePack: (context: { readonly appOutDir: string }) => Promise<void>
   readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
   readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
 }

+ 10 - 2
apps/desktop/scripts/electron-builder-config.mjs

@@ -19,6 +19,7 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen
 import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
 import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
 import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs'
+import { preserveWindowsRuntimeSignature } from './windows-runtime-signature.mjs'
 import {
   resolveMacOSAppUpdateFeed,
   verifyMacOSAppUpdateConfig,
@@ -54,19 +55,25 @@ export function createElectronBuilderConfig(
   if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
   const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
   if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
+  const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
+  let primaryRuntimeDestination
   const windowsSigner = packagesWindows && !unsigned
     ? createWindowsTokenSigner({
         certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
         signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
         tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
         keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
+        preserveSignature: async path => primaryRuntimeDestination === undefined ? false : preserveWindowsRuntimeSignature(path, {
+          sourceRoot: join(buildPaths.runtime, 'primary-runtime'),
+          destinationRoot: primaryRuntimeDestination,
+          runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR,
+        }),
       })
     : undefined
   if (windowsSigner !== undefined) {
     installWindowsNsisBootstrapSigner({ sign: windowsSigner })
   }
   const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
-  const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
   if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime
   return {
     appId,
@@ -123,7 +130,8 @@ export function createElectronBuilderConfig(
       sign: true,
       writeUpdateInfo: false,
     },
-    beforePack: async () => {
+    beforePack: async context => {
+      if (windowsSigner !== undefined) primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
       if (policy === undefined) return
       const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js')
       resolveDesktopPolicyConfig(policy)

+ 4 - 26
apps/desktop/scripts/sign-primary-runtime.ts

@@ -1,20 +1,13 @@
 /** Sign Windows runtime code before executing it, retaining vendor signatures and fail-stop hardware protection. */
-import { execFile } from 'node:child_process'
 import { X509Certificate } from 'node:crypto'
 import { lstat, open, readdir, readFile } from 'node:fs/promises'
 import { extname, join, resolve } from 'node:path'
-import { promisify } from 'node:util'
-import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
+import { createWindowsTokenSigner } from './windows-sign.mjs'
+import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
 import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
 import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
 import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
 
-interface RuntimeSignature {
-  status: string
-  timestamped: boolean
-  thumbprint: string | null
-}
-
 /**
  * Enumerate Windows code without following links or treating foreign .node files as PE binaries.
  * @param root - Owned, materialized runtime directory.
@@ -49,25 +42,10 @@ export async function windowsRuntimeCode(root: string): Promise<string[]> {
   return files.sort()
 }
 
-async function inspectSignature(path: string): Promise<RuntimeSignature> {
-  const { stdout, stderr } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
-    '$ErrorActionPreference="Stop"; $s=Get-AuthenticodeSignature -LiteralPath $env:DSH_RUNTIME_VERIFY_FILE; [pscustomobject]@{status=[string]$s.Status;timestamped=($null -ne $s.TimeStamperCertificate);thumbprint=$s.SignerCertificate.Thumbprint}|ConvertTo-Json -Compress'], {
-    env: { ...scrubWindowsSigningEnvironment(process.env), DSH_RUNTIME_VERIFY_FILE: path },
-    encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024,
-  })
-  const value: unknown = JSON.parse(stdout)
-  if (stderr || typeof value !== 'object' || value === null || !('status' in value) || typeof value.status !== 'string'
-    || !('timestamped' in value) || typeof value.timestamped !== 'boolean' || !('thumbprint' in value)
-    || !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) {
-    throw new Error(`primary runtime: invalid signature inspection: ${path}`)
-  }
-  return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint }
-}
-
 interface RuntimeSigningOptions {
   thumbprint: string
   sign: ReturnType<typeof createWindowsTokenSigner>
-  inspect?: (path: string) => Promise<RuntimeSignature>
+  inspect?: (path: string) => Promise<WindowsRuntimeSignature>
   record: (event: object) => void
   smoke: (root: string) => void
 }
@@ -79,7 +57,7 @@ interface RuntimeSigningOptions {
  * @returns Resolves only after sequential signatures, verification and execution; no retries.
  */
 export async function signWindowsPrimaryRuntime(root: string, options: RuntimeSigningOptions): Promise<void> {
-  const inspect = options.inspect ?? inspectSignature
+  const inspect = options.inspect ?? inspectWindowsRuntimeSignature
   const files = await windowsRuntimeCode(root)
   if (files.length === 0) throw new Error('primary runtime: no Windows code found')
   const unsigned: string[] = []

+ 26 - 0
apps/desktop/scripts/windows-runtime-signature.d.mts

@@ -0,0 +1,26 @@
+/** Public-key verification result; no hardware authentication is performed. */
+export interface WindowsRuntimeSignature {
+  status: string
+  timestamped: boolean
+  thumbprint: string | null
+}
+
+/**
+ * Read Windows trust, timestamp and signer identity without accessing the private key.
+ * @param path File to inspect.
+ * @returns Authenticode verification result.
+ */
+export function inspectWindowsRuntimeSignature(path: string): Promise<WindowsRuntimeSignature>
+
+/**
+ * Preserve a copied primary-runtime executable only after signature and exact-byte verification.
+ * @param path Signing-hook target.
+ * @param options Prepared and copied runtime roots with retained audit directory.
+ * @returns True for a verified runtime copy; false for targets outside that directory.
+ */
+export function preserveWindowsRuntimeSignature(path: string, options: {
+  sourceRoot: string
+  destinationRoot: string
+  runDirectory: string
+  inspect?: typeof inspectWindowsRuntimeSignature
+}): Promise<boolean>

+ 48 - 0
apps/desktop/scripts/windows-runtime-signature.mjs

@@ -0,0 +1,48 @@
+/** Inspect runtime signatures and preserve byte-identical copies made by electron-builder. */
+import { execFile } from 'node:child_process'
+import { readFile, realpath } from 'node:fs/promises'
+import { isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { promisify } from 'node:util'
+import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
+import { recordPackagingEvent } from './packaging-run.mjs'
+
+/**
+ * Read Windows trust, timestamp and signer identity without accessing the private key.
+ * @param {string} path File to inspect.
+ * @returns {Promise<{status: string, timestamped: boolean, thumbprint: string | null}>} Authenticode verification result.
+ */
+export async function inspectWindowsRuntimeSignature(path) {
+  const { stdout, stderr } = await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
+    '$ErrorActionPreference="Stop"; $s=Get-AuthenticodeSignature -LiteralPath $env:DSH_RUNTIME_VERIFY_FILE; [pscustomobject]@{status=[string]$s.Status;timestamped=($null -ne $s.TimeStamperCertificate);thumbprint=$s.SignerCertificate.Thumbprint}|ConvertTo-Json -Compress'], {
+    env: { ...scrubWindowsSigningEnvironment(process.env), DSH_RUNTIME_VERIFY_FILE: path },
+    encoding: 'utf8', windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024,
+  })
+  const value = JSON.parse(stdout)
+  if (stderr || typeof value !== 'object' || value === null || typeof value.status !== 'string'
+    || typeof value.timestamped !== 'boolean'
+    || !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) {
+    throw new Error(`primary runtime: invalid signature inspection: ${path}`)
+  }
+  return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint }
+}
+
+/**
+ * Preserve a copied primary-runtime executable only after signature and exact-byte verification.
+ * @param {string} path Signing-hook target.
+ * @param {{sourceRoot: string, destinationRoot: string, runDirectory: string, inspect?: typeof inspectWindowsRuntimeSignature}} options Prepared and copied runtime roots with retained audit directory.
+ * @returns {Promise<boolean>} True for a verified runtime copy; false for targets outside that directory.
+ */
+export async function preserveWindowsRuntimeSignature(path, options) {
+  const suffix = relative(options.destinationRoot, path)
+  if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return false
+  const source = join(options.sourceRoot, suffix)
+  for (const file of [source, path]) {
+    if (await realpath(file) !== resolve(file)) throw new Error(`primary runtime: linked copy is not signable: ${file}`)
+  }
+  const [prepared, copied] = await Promise.all([readFile(source), readFile(path)])
+  if (!prepared.equals(copied)) throw new Error(`primary runtime: copied executable changed: ${path}`)
+  const signature = await (options.inspect ?? inspectWindowsRuntimeSignature)(path)
+  if (signature.status !== 'Valid') throw new Error(`primary runtime: copied signature is ${signature.status}: ${path}`)
+  recordPackagingEvent(options.runDirectory, { type: 'primary-runtime-copy-verified', path, ...signature })
+  return true
+}

+ 1 - 0
apps/desktop/scripts/windows-sign.d.mts

@@ -35,6 +35,7 @@ export function createWindowsTokenSigner(options: {
   commandInterpreter?: string | undefined
   runDirectory?: string | undefined
   stateDirectory?: string | undefined
+  preserveSignature?: (path: string) => Promise<boolean>
 }): (
   configuration: {
     path: string

+ 2 - 1
apps/desktop/scripts/windows-sign.mjs

@@ -158,7 +158,7 @@ export function buildWindowsSigningEnvironment(environment, input) {
 /**
  * Serialize SafeNet signing and stop all queued tasks after the first failure.
  *
- * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string, runDirectory?: string, stateDirectory?: string }} options Release identity, supervised run, and test-only isolated interlock directory.
+ * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string, runDirectory?: string, stateDirectory?: string, preserveSignature?: (path: string) => Promise<boolean> }} options Release identity, supervised run, verified-copy preservation, and test-only isolated interlock directory.
  * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise<void>} The signing hook.
  */
 export function createWindowsTokenSigner(options) {
@@ -174,6 +174,7 @@ export function createWindowsTokenSigner(options) {
       if (configuration.hash !== 'sha256') {
         throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`)
       }
+      if (await options.preserveSignature?.(configuration.path)) return
       await repairDanglingAuthenticodeDirectory(configuration.path)
       const secrets = [tokenPin]
       const attempt = beginWindowsSigningAttempt({ runDirectory: options.runDirectory,

+ 25 - 1
apps/desktop/tests/primary-runtime-signing.spec.ts

@@ -1,8 +1,10 @@
-import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, expect, it, vi } from 'vitest'
 import { signWindowsPrimaryRuntime, windowsRuntimeCode } from '../scripts/sign-primary-runtime.ts'
+import { preserveWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
+import { createPackagingRun } from '../scripts/packaging-run.mjs'
 
 const roots: string[] = []
 const thumbprint = 'A'.repeat(40)
@@ -120,3 +122,25 @@ it('refuses an empty runtime without declaring successful validation', async ()
   await expect(signWindowsPrimaryRuntime(root, { thumbprint, sign: vi.fn(), smoke, record: () => {} })).rejects.toThrow('no Windows code')
   expect(smoke).not.toHaveBeenCalled()
 })
+
+it('preserves only identical, valid runtime copies and records verification without signing', async () => {
+  const sourceRoot = await realpath(await fixture(['python.exe']))
+  const destinationRoot = await realpath(await fixture(['python.exe']))
+  const run = createPackagingRun(join(sourceRoot, 'records'), {})
+  const inspect = vi.fn(async () => valid)
+  const options = { sourceRoot, destinationRoot, runDirectory: run.directory, inspect }
+  const path = join(destinationRoot, 'python.exe')
+  expect(await preserveWindowsRuntimeSignature(join(`${destinationRoot}-other`, 'python.exe'), options)).toBe(false)
+  expect(inspect).not.toHaveBeenCalled()
+  expect(await preserveWindowsRuntimeSignature(path, options)).toBe(true)
+  expect(await readFile(join(run.directory, 'events.jsonl'), 'utf8')).toContain('primary-runtime-copy-verified')
+  inspect.mockResolvedValueOnce({ ...valid, status: 'NotSigned' })
+  await expect(preserveWindowsRuntimeSignature(path, options)).rejects.toThrow('copied signature is NotSigned')
+  await writeFile(path, 'changed executable')
+  await expect(preserveWindowsRuntimeSignature(path, options)).rejects.toThrow('copied executable changed')
+  await rm(path)
+  await symlink(sourceRoot, join(destinationRoot, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
+  await mkdir(join(sourceRoot, 'linked'))
+  await writeFile(join(sourceRoot, 'linked', 'python.exe'), await readFile(join(sourceRoot, 'python.exe')))
+  await expect(preserveWindowsRuntimeSignature(join(destinationRoot, 'linked', 'python.exe'), options)).rejects.toThrow('linked copy')
+})

+ 23 - 0
apps/desktop/tests/windows-sign.spec.ts

@@ -37,6 +37,29 @@ const CERTIFICATE_FILE = 'C:\\release\\server.cer'
 const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd')
 
 describe('Windows token signing', () => {
+  it('preserves verified copies without hardware and rejects the entire queue after preservation failure', async () => {
+    const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-copy-signature-'))
+    try {
+      const certificateFile = join(directory, 'server.cer')
+      const signTool = join(directory, 'signtool.exe')
+      await writeFile(certificateFile, 'code-signing-certificate-fixture')
+      await writeFile(signTool, 'fixture')
+      const preserveSignature = vi.fn(async () => true)
+      const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin',
+        keyContainer: 'fixture-container', preserveSignature })
+      const task = { path: join(directory, 'copy.exe'), hash: 'sha256', isNest: false }
+      await sign(task)
+      expect(execFile).not.toHaveBeenCalled()
+      preserveSignature.mockRejectedValueOnce(new Error('copy changed'))
+      const results = await Promise.allSettled([sign(task), sign(task), sign(task)])
+      expect(results.map(result => result.status)).toEqual(['rejected', 'rejected', 'rejected'])
+      expect(preserveSignature).toHaveBeenCalledTimes(2)
+      expect(execFile).not.toHaveBeenCalled()
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
   it('stops concurrent and subsequent signing tasks after a PIN failure', async () => {
     const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-pin-failure-'))
     try {

+ 13 - 0
apps/desktop/tests/windows-update-publisher.spec.ts

@@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import { resolveWindowsUpdatePublisher } from '../scripts/windows-sign.mjs'
+import * as runtimeSignatures from '../scripts/windows-runtime-signature.mjs'
+
+vi.mock('../lib/types/mandatory-update-policy.js', () => import('../src/mandatory-update-policy.ts'))
 
 vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({
   ...await importOriginal<typeof import('../scripts/windows-sign.mjs')>(),
@@ -92,6 +95,16 @@ describe('Windows update publisher', () => {
       expect(typeof config.win.signtoolOptions.sign).toBe('function')
       const manager = new WindowsSignToolManager({ platformSpecificBuildOptions: config.win, getCscLink: () => undefined })
       expect(await manager.computedPublisherName.value).toEqual(['CN=Publisher,O=Company,C=CN'])
+      const preservation = vi.spyOn(runtimeSignatures, 'preserveWindowsRuntimeSignature').mockResolvedValue(true)
+      try {
+        const appOutDir = join(file, '..', 'win-unpacked')
+        await config.beforePack({ appOutDir })
+        const path = join(appOutDir, 'resources', 'runtime', 'primary-runtime', 'python.exe')
+        await config.win.signtoolOptions.sign!({ path, hash: 'sha256', isNest: false })
+        expect(preservation).toHaveBeenCalledWith(path, expect.objectContaining({
+          destinationRoot: join(appOutDir, 'resources', 'runtime', 'primary-runtime'),
+        }))
+      } finally { preservation.mockRestore() }
     })
   })
 })