feat(desktop): share Windows runtime signature cache across worktrees
This commit is contained in:
1 parent
3e216c3d64
commit
d4ed1d93d8
25 files changed
+789
-64
No files matched your search
+2
-2
@@ -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/process/2026-09-17-windows-runtime-signature-cache.md
|
||||
2026-09-17-windows-runtime-signature-cache.md: 909e39b5c06ed9d529df7553de324295aadf43fe
|
||||
2026-09-17-windows-runtime-signature-cache.zh.md: c7ee5e1ca5105c21e3a7f4b43342dee20331e88b
|
||||
2026-09-17-windows-runtime-signature-cache.md: 3182f46680fd862e123a54a7703b0b0dcad8fd79
|
||||
2026-09-17-windows-runtime-signature-cache.zh.md: 72b3599b28244d390a00ac5aa1eea0f5f8834c86
|
||||
@@ -12,6 +12,12 @@ Windows packaging reconstructs unsigned native dependencies on each build. Signi
|
||||
|
||||
Cache complete signed runtime files by original content, public certificate, SignTool bytes, signing-script bytes and cache format. Keep valid vendor signatures. Restore a cache hit through a private staged copy only after checking the input record, signed-file digest, Windows trust, timestamp and configured certificate; reject invalid entries without automatic repair or hardware fallback. Publish complete entries atomically, and retain an existing valid entry when concurrent publishers collide.
|
||||
|
||||
Share the cache only within the current Windows account. Require a fixed local drive, current-account ownership and a private destination DACL; reject linked or redirected paths. Programs running as that account remain trusted. Explicit migration copies complete integrity-checked entries without modifying its source or replacing an existing valid entry. Different timestamp bytes at the same input/policy key do not require replacement; every actual restoration still verifies Windows trust.
|
||||
|
||||
The default cache lives beside the account's existing signing state under the user profile. AppData is unsuitable for this default because an MSIX launcher can redirect new files into its private package storage, separating otherwise identical worktrees launched from different programs. A validated explicit directory remains available for isolated build storage.
|
||||
|
||||
Serialize each complete signing stage and cache-maintenance operation with an exclusive Windows file handle. The lock covers preflight, primary-runtime signing, application-runtime signing and artifact creation separately, leaving compilation and preparation concurrent. A waiter rechecks the cache after acquisition. Stage ownership and the retained hardware-attempt interlock have different lifetimes: closing the stage handle releases contention, while hardware failures and interruptions retain the existing attempt evidence and never authorize an automatic retry.
|
||||
|
||||
Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request.
|
||||
|
||||
The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation.
|
||||
@@ -24,6 +30,10 @@ The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md)
|
||||
|
||||
**Whole signed-runtime artifacts only.** They can also avoid extraction and copying, but require assembly and dependency-graph identities. Per-file caching can reuse unchanged native dependencies when application code or one dependency changes.
|
||||
|
||||
**Per-entry locks only.** They avoid duplicate signatures for one key but do not coordinate different files, preflight or installer signing against the same hardware token. Stage-wide ownership covers those calls without holding a lock during unrelated compilation.
|
||||
|
||||
**Cache-age expiry.** Age alone does not establish that a timestamped signature is invalid. Rechecking current Windows trust preserves the validation requirement without periodically repeating unchanged hardware signatures.
|
||||
|
||||
## Consequences
|
||||
|
||||
Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files.
|
||||
@@ -33,3 +43,5 @@ Six complete signed Windows x64 builds cover an empty signature cache, repeated
|
||||
## Risks
|
||||
|
||||
The cache consumes local disk and depends on the build account controlling its files and records. It is not a distribution format for untrusted remote caches. Signature validation still depends on Windows trust services; cache hits do not authorize clearing a signing interlock or retrying a failed token operation. A timestamp-service failure can interrupt a cold build even after a successful preflight.
|
||||
|
||||
No automatic capacity eviction runs. Explicit maintenance waits for stage ownership and retires complete entries before deleting their files; interrupted staging directories remain distinguishable from published entries. Runtime summaries count actual publications separately from retained collisions, and report cache verification time separately from signing time. They do not measure the entire package's verification cost.
|
||||
@@ -12,6 +12,12 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
|
||||
|
||||
按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。
|
||||
|
||||
缓存仅在当前 Windows 账户内共享。要求固定本地磁盘、当前账户所有权及私有目标目录 DACL;拒绝带链接或被重定向的路径。同账户运行的程序仍受信任。显式迁移复制通过完整性检查的完整缓存项,不修改源目录,也不替换已有有效项。同一输入和策略键下的时间戳字节不同,不构成替换理由;每次实际恢复仍验证 Windows 信任。
|
||||
|
||||
默认缓存位于用户目录下,与账户已有签名状态放在一起。AppData 不适合作为此默认位置,因为 MSIX 启动器可能将新文件重定向到私有包存储,使不同程序启动的相同 worktree 使用不同缓存。独立构建存储仍可指定经过验证的显式目录。
|
||||
|
||||
通过 Windows 独占文件句柄串行执行每个完整签名阶段及缓存维护操作。锁分别覆盖预检、主要运行时签名、应用运行时签名及产物生成,编译和准备仍可并行。等待者在获取锁后重新检查缓存。阶段所有权与持久保留的硬件尝试互锁具有不同生命周期:关闭阶段句柄释放竞争锁,而硬件失败或中断保留现有尝试证据,绝不构成自动重试授权。
|
||||
|
||||
公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。
|
||||
|
||||
[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。
|
||||
@@ -24,6 +30,10 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
|
||||
|
||||
**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。
|
||||
|
||||
**仅使用逐项锁。** 它能避免同一键的重复签名,却无法协调不同文件、预检或安装器签名对同一硬件令牌的访问。阶段级所有权覆盖这些调用,不在无关编译期间持锁。
|
||||
|
||||
**按缓存年龄过期。** 年龄本身不能说明带时间戳的签名无效。重新检查当前 Windows 信任可以保留验证要求,同时避免周期性重复未变化的硬件签名。
|
||||
|
||||
## Consequences
|
||||
|
||||
原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。
|
||||
@@ -33,3 +43,5 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的
|
||||
## Risks
|
||||
|
||||
缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。
|
||||
|
||||
不执行自动容量淘汰。显式维护等待阶段所有权,在删除文件前先将完整缓存项移出已发布名称;中断的暂存目录仍可与已发布项区分。运行时摘要区分实际发布和冲突保留,并将缓存验证耗时与签名耗时分别记录;它不测量整个安装包的验证成本。
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
|
||||
# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
# Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
@@ -16,6 +16,9 @@ DSH_DESKTOP_WINDOWS_SIGNTOOL=
|
||||
DSH_DESKTOP_WINDOWS_KEY_CONTAINER=
|
||||
DSH_DESKTOP_WINDOWS_TOKEN_PIN=
|
||||
|
||||
# Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1.
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1
|
||||
|
||||
# Optional upload configuration; packaging does not require these credentials.
|
||||
# DOWNLOAD_TEST_COS_BUCKET=
|
||||
# DOWNLOAD_TEST_COS_SECRET_ID=
|
||||
|
||||
@@ -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: 531c168c0d3fc3091a330f5350d0a85ac3009f08
|
||||
README.zh.md: 6a20f83623a54e8f87d05104280b97ddfb2a796a
|
||||
README.md: 9a20ec4b919c31f9d3427181ae984c30206aed19
|
||||
README.zh.md: a5b876ab97c108db51ebbcea45a78322234ffaa0
|
||||
@@ -210,7 +210,13 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an
|
||||
|
||||
### Windows EV signing
|
||||
|
||||
Runtime signing caches complete signed files under `.desktop-build/targets/win-x64/signature-cache`. Entries identify the original bytes, public certificate and signing toolchain. Restoring an entry checks its digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. The cache requires trusted local build storage and retains entries until removed. Preflight, hardware interlocks and final artifact signing still apply. Cache qualification and design limits are tracked in the [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md).
|
||||
Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits.
|
||||
|
||||
Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery.
|
||||
|
||||
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, cache trust verification and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately.
|
||||
|
||||
Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect complete-entry bytes and counts, or `--from <absolute-old-cache>` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory <absolute-cache>` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly.
|
||||
|
||||
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. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. 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.
|
||||
|
||||
|
||||
@@ -211,7 +211,13 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
|
||||
|
||||
### Windows EV 签名
|
||||
|
||||
运行时签名将完整的已签名文件缓存在 `.desktop-build/targets/win-x64/signature-cache`。缓存项标识原始字节、公钥证书和签名工具链。恢复缓存项时,先检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。缓存要求受信任的本地构建存储,并保留缓存项直到被移除。预检、硬件互锁和最终产物签名仍然执行。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)记录缓存验收要求和设计限制。
|
||||
运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。
|
||||
|
||||
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
|
||||
|
||||
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、缓存信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。
|
||||
|
||||
使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看完整缓存项的字节数和数量,或用 `--from <absolute-old-cache>` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory <absolute-cache>` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。
|
||||
|
||||
Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"prepare:runtime": "tsx scripts/prepare-runtime.ts",
|
||||
"prepare:primary-runtime": "tsx scripts/prepare-primary-runtime.ts",
|
||||
"sign:primary-runtime": "tsx scripts/sign-primary-runtime.ts",
|
||||
"cache:windows-signatures": "node scripts/manage-windows-signature-cache.mjs",
|
||||
"prepare:packages": "tsx scripts/prepare-package-set.ts",
|
||||
"prepare:dsh": "tsx scripts/prepare-dsh.ts",
|
||||
"prepare:package": "tsx scripts/package-target.ts --prepare-only",
|
||||
@@ -52,6 +53,7 @@
|
||||
"extract-zip": "^2.0.1",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.2.0",
|
||||
"koffi": "^3.1.0",
|
||||
"pnpm": "11.7.0",
|
||||
"tar": "^7.5.0",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -8,10 +8,11 @@ import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSS
|
||||
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { createWindowsTokenSigner } from './windows-sign.mjs'
|
||||
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
|
||||
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
@@ -85,6 +86,7 @@ export function validateDesktopPackageEnvironment(environment, target, options =
|
||||
tokenPin: environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
|
||||
keyContainer: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
|
||||
})
|
||||
if (!options.prepareOnly) resolveWindowsSignatureCacheDirectory(environment)
|
||||
} else {
|
||||
resolveMacOSSigningEnvironment(environment)
|
||||
const strategies = [
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Explicit current-account migration and storage inspection under the signing-stage lock. */
|
||||
import { parseArgs } from 'node:util'
|
||||
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { maintainSignatureCache, migrateSignatureCache } from './windows-signature-cache.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
|
||||
const { values } = parseArgs({ options: {
|
||||
from: { type: 'string' }, usage: { type: 'boolean' }, clear: { type: 'boolean' }, directory: { type: 'string' },
|
||||
} })
|
||||
if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) {
|
||||
throw new Error('Specify exactly one of --from <old-cache>, --usage or --clear')
|
||||
}
|
||||
const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32')
|
||||
: { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory }
|
||||
const root = resolveWindowsSignatureCacheDirectory(environment)
|
||||
const record = event => process.stdout.write(`${JSON.stringify(event)}\n`)
|
||||
await withWindowsSigningStage({ stage: values.from !== undefined ? 'cache-migration' : 'cache-maintenance', record }, async () => {
|
||||
await prepareWindowsSignatureCacheDirectory(root)
|
||||
if (values.from !== undefined) {
|
||||
const source = resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.from })
|
||||
await prepareWindowsSignatureCacheDirectory(source, true)
|
||||
record({ type: 'signature-cache-migration-summary', ...await migrateSignatureCache({ source, root, record }) })
|
||||
return
|
||||
}
|
||||
record({ type: values.clear ? 'signature-cache-cleared' : 'signature-cache-usage', root,
|
||||
...await maintainSignatureCache(root, values.clear) })
|
||||
})
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
|
||||
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { createPackagingRun } from './packaging-run.mjs'
|
||||
import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
@@ -22,6 +24,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [
|
||||
'DSH_DESKTOP_WINDOWS_KEY_CONTAINER',
|
||||
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
|
||||
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
|
||||
'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR',
|
||||
] as const
|
||||
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
|
||||
'DOWNLOAD_TEST_COS_SECRET_ID',
|
||||
@@ -332,11 +335,31 @@ export async function packageTarget(
|
||||
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
|
||||
}
|
||||
const signPrimaryRuntime = target.platform === 'win32' && !invocation.unsigned && !invocation.prepareOnly
|
||||
const signedStage = async (stage: string, operation: () => Promise<void>): Promise<void> => {
|
||||
if (!signPrimaryRuntime) return operation()
|
||||
if (run === undefined) throw new Error('desktop package: signed Windows packaging requires a supervised run')
|
||||
const controller = new AbortController()
|
||||
const interrupted = (): void => controller.abort()
|
||||
const detach = (): void => {
|
||||
process.removeListener('SIGINT', interrupted)
|
||||
process.removeListener('SIGTERM', interrupted)
|
||||
}
|
||||
process.once('SIGINT', interrupted)
|
||||
process.once('SIGTERM', interrupted)
|
||||
try {
|
||||
const options = { stage, signal: controller.signal, record: (event: object): void => recordPackagingEvent(run.directory, event) }
|
||||
await withWindowsSigningStage(options, async () => {
|
||||
detach()
|
||||
await operation()
|
||||
})
|
||||
} finally { detach() }
|
||||
}
|
||||
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,
|
||||
await prepareWindowsSignatureCacheDirectory(resolveWindowsSignatureCacheDirectory(environment))
|
||||
await signedStage('preflight', () => 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 })
|
||||
{ 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)
|
||||
@@ -376,7 +399,7 @@ export async function packageTarget(
|
||||
environment: electronBuilderEnv,
|
||||
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv))
|
||||
} else {
|
||||
await execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
}
|
||||
if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
|
||||
if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join, resolve } from 'node:path'
|
||||
import { createWindowsTokenSigner } from './windows-sign.mjs'
|
||||
import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs'
|
||||
import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
@@ -50,30 +52,38 @@ async function main(): Promise<void> {
|
||||
const certificateFile = process.env.DSH_DESKTOP_WINDOWS_CER_FILE
|
||||
if (!certificateFile) throw new Error('primary runtime signing requires the configured certificate')
|
||||
const thumbprint = new X509Certificate(await readFile(certificateFile)).fingerprint.replaceAll(':', '')
|
||||
const record = (event: object): void => { recordPackagingEvent(runDirectory, event) }
|
||||
try {
|
||||
const paths = resolveDesktopTargetBuildPaths()
|
||||
const record = (event: object): void => { recordPackagingEvent(runDirectory, event) }
|
||||
const sign = createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
|
||||
keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN })
|
||||
const identity = await signatureCacheIdentity([
|
||||
await realpath(certificateFile), await realpath(process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL!),
|
||||
join(import.meta.dirname, 'windows-sign.cmd'), join(import.meta.dirname, 'windows-sign.mjs'),
|
||||
join(import.meta.dirname, 'windows-timestamp.mjs'),
|
||||
])
|
||||
const options = {
|
||||
thumbprint,
|
||||
sign: createCachedSigner({ root: join(paths.root, 'signature-cache'), identity, thumbprint,
|
||||
sign, inspect: inspectWindowsRuntimeSignature, record }),
|
||||
record,
|
||||
}
|
||||
if (process.argv.includes('--dsh')) {
|
||||
const version = JSON.parse(await readFile(join(paths.dsh, 'package.json'), 'utf8')).version as string
|
||||
await signWindowsDesktopRuntime(paths.dsh, version, { ...options,
|
||||
smoke: descriptor => smokePreparedRuntime(paths.dsh, join(paths.electron, 'electron.exe'), paths.runtime, descriptor),
|
||||
})
|
||||
} else {
|
||||
await signWindowsPrimaryRuntime(join(paths.runtime, 'primary-runtime'), { ...options, smoke: smokePrimaryRuntime })
|
||||
}
|
||||
await withWindowsSigningStage({ stage: process.argv.includes('--dsh') ? 'dsh-runtime' : 'primary-runtime', record }, async () => {
|
||||
const paths = resolveDesktopTargetBuildPaths()
|
||||
const sign = createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
|
||||
keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN })
|
||||
const identity = await signatureCacheIdentity([
|
||||
await realpath(certificateFile), await realpath(process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL!),
|
||||
join(import.meta.dirname, 'windows-sign.cmd'), join(import.meta.dirname, 'windows-sign.mjs'),
|
||||
join(import.meta.dirname, 'windows-timestamp.mjs'),
|
||||
])
|
||||
const cacheRoot = resolveWindowsSignatureCacheDirectory(process.env)
|
||||
await prepareWindowsSignatureCacheDirectory(cacheRoot)
|
||||
const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint,
|
||||
sign, inspect: inspectWindowsRuntimeSignature, record })
|
||||
const options = { thumbprint, sign: cachedSign, record }
|
||||
record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity })
|
||||
try {
|
||||
if (process.argv.includes('--dsh')) {
|
||||
const version = JSON.parse(await readFile(join(paths.dsh, 'package.json'), 'utf8')).version as string
|
||||
await signWindowsDesktopRuntime(paths.dsh, version, { ...options,
|
||||
smoke: descriptor => smokePreparedRuntime(paths.dsh, join(paths.electron, 'electron.exe'), paths.runtime, descriptor),
|
||||
})
|
||||
} else {
|
||||
await signWindowsPrimaryRuntime(join(paths.runtime, 'primary-runtime'), { ...options, smoke: smokePrimaryRuntime })
|
||||
}
|
||||
} finally {
|
||||
const summary = cachedSign.summary()
|
||||
record({ type: 'signature-cache-summary', ...summary })
|
||||
process.stdout.write(`SIGNATURE_CACHE_SUMMARY ${JSON.stringify(summary)}\n`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
failPackagingRun(runDirectory, 'primary-runtime-signing-or-smoke-failed')
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Resolve the current account's cache or a file-owned absolute local override.
|
||||
* @param environment Packaging configuration; an absent override uses the current account's signing-state directory.
|
||||
* @returns Normalized drive path; rejects relative, network, device and alternate-stream paths.
|
||||
*/
|
||||
export function resolveWindowsSignatureCacheDirectory(environment: NodeJS.ProcessEnv): string
|
||||
|
||||
/**
|
||||
* Validate directory ownership, links, drive type and destination access permissions.
|
||||
* @param path Absolute directory to create or inspect.
|
||||
* @param existingSource Validate an existing migration source without changing its permissions.
|
||||
* @returns Resolves after validation; never reads cache entries from another account's directory.
|
||||
*/
|
||||
export function prepareWindowsSignatureCacheDirectory(path: string, existingSource?: boolean): Promise<void>
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Resolve and validate the current account's shared runtime-signature cache. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { join, resolve, win32 } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
|
||||
/**
|
||||
* Resolve the file-owned override or the current account's signing-state cache without reading its contents.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned packaging configuration.
|
||||
* @returns {string} Absolute local cache path; relative, network, device and alternate-stream paths are rejected.
|
||||
*/
|
||||
export function resolveWindowsSignatureCacheDirectory(environment) {
|
||||
const path = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR
|
||||
?? win32.join(homedir(), '.dsh-desktop-signing', 'signature-cache', 'v1')
|
||||
if (!/^[a-z]:[\\/]/iu.test(path) || path.slice(2).includes(':') || /[\0\r\n]/u.test(path)) {
|
||||
throw new Error('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR must be an absolute local drive directory')
|
||||
}
|
||||
return win32.resolve(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create private current-account storage or validate an explicitly selected migration source.
|
||||
* @param {string} path Absolute local directory.
|
||||
* @param {boolean} existingSource Require an existing, current-account-owned migration source without changing its permissions.
|
||||
* @returns {Promise<void>} Resolves after checking drive type, ancestor links, ownership and destination permissions.
|
||||
*/
|
||||
export async function prepareWindowsSignatureCacheDirectory(path, existingSource = false) {
|
||||
if (process.platform !== 'win32') throw new Error('Windows signature cache directory validation requires Windows')
|
||||
await promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-File',
|
||||
join(import.meta.dirname, 'windows-signature-cache-directory.ps1'), '-CachePath', resolve(path),
|
||||
...(existingSource ? ['-ExistingSource'] : []),
|
||||
], { env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000 })
|
||||
if ((await realpath(path)).toLowerCase() !== resolve(path).toLowerCase()) {
|
||||
throw new Error('Signature cache directory is redirected; configure DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR outside redirected storage')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Validate account ownership before accessing cache contents; new caches receive a private DACL.
|
||||
param([Parameter(Mandatory=$true)][string]$CachePath, [switch]$ExistingSource)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
|
||||
Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop
|
||||
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$fullPath = [System.IO.Path]::GetFullPath($CachePath)
|
||||
if ($fullPath -notmatch '^[A-Za-z]:\\' -or $CachePath -notmatch '^[A-Za-z]:[\\/]') { throw 'Signature cache requires an absolute local drive path' }
|
||||
$drive = [System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($fullPath))
|
||||
if ($drive.DriveType -ne [System.IO.DriveType]::Fixed) { throw 'Signature cache requires a fixed local drive' }
|
||||
for ($ancestor = [System.IO.DirectoryInfo]::new($fullPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) {
|
||||
if ($ancestor.Exists -and ($ancestor.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
throw 'Signature cache cannot use reparse points in its path'
|
||||
}
|
||||
}
|
||||
if ([System.IO.File]::Exists($fullPath)) { throw 'Signature cache path must be a directory' }
|
||||
if (-not [System.IO.Directory]::Exists($fullPath)) {
|
||||
if ($ExistingSource) { throw 'Signature cache migration source does not exist' }
|
||||
$null = [System.IO.Directory]::CreateDirectory($fullPath)
|
||||
$acl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$acl.SetOwner($currentSid)
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($sid in @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
|
||||
$identity = [System.Security.Principal.SecurityIdentifier]::new($sid)
|
||||
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$acl.AddAccessRule($rule)
|
||||
}
|
||||
Set-Acl -LiteralPath $fullPath -AclObject $acl
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $fullPath
|
||||
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $currentSid.Value) {
|
||||
throw 'Signature cache directory must belong to the current Windows account'
|
||||
}
|
||||
if (-not $ExistingSource) {
|
||||
foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
|
||||
if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
|
||||
throw 'Signature cache permissions must exclude other ordinary Windows accounts'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,53 @@ export interface WindowsSignatureCacheOptions {
|
||||
*/
|
||||
export function signatureCacheIdentity(files: readonly string[]): Promise<string>
|
||||
|
||||
/** Counters include failed attempts; restore time includes trust verification and staging cleanup. */
|
||||
export interface WindowsSignatureCacheSummary {
|
||||
root: string
|
||||
identity: string
|
||||
hits: number
|
||||
misses: number
|
||||
published: number
|
||||
retained: number
|
||||
signingCalls: number
|
||||
avoidedSigningCalls: number
|
||||
validationFailures: number
|
||||
verificationMs: number
|
||||
restoreMs: number
|
||||
signingMs: number
|
||||
}
|
||||
|
||||
/** Serial cache callback with a snapshot of this instance's completed and failed operations. */
|
||||
export type WindowsCachedSigner = ReturnType<typeof createWindowsTokenSigner> & {
|
||||
/** @returns Current counters and elapsed times; does not wait for queued operations. */
|
||||
summary(): WindowsSignatureCacheSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse complete signed files only after input, integrity, identity and timestamp checks.
|
||||
* @param options Cache policy, supervised signer, public-key verifier and audit sink.
|
||||
* @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries.
|
||||
*/
|
||||
export function createCachedSigner(options: WindowsSignatureCacheOptions): ReturnType<typeof createWindowsTokenSigner>
|
||||
export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner
|
||||
|
||||
/** The caller validates ownership and holds the user-wide signing-stage lock throughout migration. */
|
||||
export interface SignatureCacheMigrationOptions {
|
||||
source: string
|
||||
root: string
|
||||
record: (event: object) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy complete entries without changing the source or replacing valid destination entries.
|
||||
* @param options Validated directories and credential-free audit sink.
|
||||
* @returns Counts of published, retained and skipped entries; corruption rejects without repair. Actual use rechecks trust.
|
||||
*/
|
||||
export function migrateSignatureCache(options: SignatureCacheMigrationOptions): Promise<{ published: number, retained: number, skipped: number }>
|
||||
|
||||
/**
|
||||
* Inspect or explicitly clear complete entries under the caller's signing-stage lock.
|
||||
* @param root Validated current-account cache directory.
|
||||
* @param clear Remove complete entries after atomic retirement; incomplete staging directories remain untouched.
|
||||
* @returns Count and bytes of complete entries, plus the number of skipped incomplete entries.
|
||||
*/
|
||||
export function maintainSignatureCache(root: string, clear?: boolean): Promise<{ entries: number, bytes: number, incomplete: number }>
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Cache complete signed files; corrupt entries stop the caller before hardware access. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, toNamespacedPath } from 'node:path'
|
||||
|
||||
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
@@ -22,7 +22,8 @@ async function regularFile(path) {
|
||||
|
||||
async function directory(path) {
|
||||
const stat = await lstat(path)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== resolve(path)) {
|
||||
const normalize = value => process.platform === 'win32' ? toNamespacedPath(resolve(value)).toLowerCase() : resolve(value)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || normalize(await realpath(path)) !== normalize(path)) {
|
||||
throw new Error(`signature cache: expected unlinked directory: ${path}`)
|
||||
}
|
||||
}
|
||||
@@ -32,21 +33,113 @@ function validSignature(signature, thumbprint) {
|
||||
&& signature.thumbprint?.toUpperCase() === thumbprint.toUpperCase()
|
||||
}
|
||||
|
||||
async function readCacheEntry(entry, key) {
|
||||
await directory(entry)
|
||||
const record = JSON.parse((await regularFile(join(entry, 'record.json'))).toString('utf8'))
|
||||
if (record === null || typeof record !== 'object' || record.version !== 1 || record.key !== key
|
||||
|| typeof record.inputDigest !== 'string' || !/^[a-f\d]{64}$/u.test(record.inputDigest)
|
||||
|| typeof record.identity !== 'string' || typeof record.signedDigest !== 'string'
|
||||
|| !/^[a-f\d]{64}$/u.test(record.signedDigest)
|
||||
|| digest(JSON.stringify({ version: 1, inputDigest: record.inputDigest, identity: record.identity })) !== key) {
|
||||
throw new Error('signature cache: invalid input record')
|
||||
}
|
||||
if (digest(await regularFile(join(entry, 'payload'))) !== record.signedDigest) throw new Error('signature cache: corrupt signed payload')
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Import complete integrity-checked entries while the caller holds the signing-stage lock.
|
||||
* @param {import('./windows-signature-cache.mjs').SignatureCacheMigrationOptions} options Validated source, private destination and audit sink.
|
||||
* @returns {Promise<{published: number, retained: number, skipped: number}>} Counts; existing valid entries win even when timestamp bytes differ. Trust is rechecked at actual use.
|
||||
*/
|
||||
export async function migrateSignatureCache(options) {
|
||||
if (resolve(options.source).toLowerCase() === resolve(options.root).toLowerCase()) throw new Error('signature cache: migration source equals destination')
|
||||
await directory(options.source)
|
||||
await mkdir(options.root, { recursive: true })
|
||||
await directory(options.root)
|
||||
const counts = { published: 0, retained: 0, skipped: 0 }
|
||||
for (const key of await readdir(options.source)) {
|
||||
if (!/^[a-f\d]{64}$/u.test(key)) {
|
||||
counts.skipped++
|
||||
options.record({ type: 'signature-cache-migration-skipped', reason: 'not-complete-entry-name' })
|
||||
continue
|
||||
}
|
||||
const source = join(options.source, key)
|
||||
await readCacheEntry(source, key)
|
||||
const target = join(options.root, key)
|
||||
let exists = false
|
||||
try { await lstat(target); exists = true }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (exists) {
|
||||
await readCacheEntry(target, key)
|
||||
counts.retained++
|
||||
continue
|
||||
}
|
||||
const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-')))
|
||||
try {
|
||||
await copyFile(join(source, 'record.json'), join(staging, 'record.json'))
|
||||
await copyFile(join(source, 'payload'), join(staging, 'payload'))
|
||||
await readCacheEntry(staging, key)
|
||||
await rename(staging, target)
|
||||
counts.published++
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Count or explicitly clear complete entries while the caller holds the signing-stage lock.
|
||||
* @param {string} root Validated, current-account cache directory.
|
||||
* @param {boolean} clear Retire each complete entry atomically before removing its two regular files.
|
||||
* @returns {Promise<{entries: number, bytes: number, incomplete: number}>} Complete-entry size and count; incomplete entries are left untouched.
|
||||
*/
|
||||
export async function maintainSignatureCache(root, clear = false) {
|
||||
await directory(root)
|
||||
let entries = 0
|
||||
let bytes = 0
|
||||
let incomplete = 0
|
||||
for (const name of await readdir(root)) {
|
||||
if (!/^[a-f\d]{64}$/u.test(name)) { incomplete++; continue }
|
||||
const entry = join(root, name)
|
||||
await directory(entry)
|
||||
const files = await readdir(entry)
|
||||
if (files.length !== 2 || !files.includes('record.json') || !files.includes('payload')) {
|
||||
throw new Error('signature cache: unexpected files prevent cache maintenance')
|
||||
}
|
||||
for (const file of files) {
|
||||
const stat = await lstat(join(entry, file))
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('signature cache: expected a regular entry file')
|
||||
bytes += stat.size
|
||||
}
|
||||
if (clear) {
|
||||
const retired = join(root, `.clear-${randomUUID()}`)
|
||||
await rename(entry, retired)
|
||||
for (const file of files) await unlink(join(retired, file))
|
||||
await rmdir(retired)
|
||||
}
|
||||
entries++
|
||||
}
|
||||
return { entries, bytes, incomplete }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy.
|
||||
* @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations.
|
||||
* @returns {ReturnType<typeof import('./windows-sign.mjs').createWindowsTokenSigner>} Serial, fail-stop signing callback; cache hits still verify trust and timestamp.
|
||||
* @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp.
|
||||
*/
|
||||
export function createCachedSigner(options) {
|
||||
let pending = Promise.resolve()
|
||||
const statistics = { hits: 0, misses: 0, published: 0, retained: 0, signingCalls: 0, validationFailures: 0,
|
||||
verificationMs: 0, restoreMs: 0, signingMs: 0 }
|
||||
async function verify(path) {
|
||||
const start = performance.now()
|
||||
try {
|
||||
if (!validSignature(await options.inspect(path), options.thumbprint)) throw new Error('signature cache: invalid signature')
|
||||
} finally { statistics.verificationMs += performance.now() - start }
|
||||
}
|
||||
async function readEntry(entry, key, inputDigest) {
|
||||
await directory(entry)
|
||||
const record = JSON.parse((await regularFile(join(entry, 'record.json'))).toString('utf8'))
|
||||
if (record === null || typeof record !== 'object' || record.version !== 1 || record.key !== key || record.inputDigest !== inputDigest
|
||||
|| record.identity !== options.identity || typeof record.signedDigest !== 'string'
|
||||
|| !/^[a-f\d]{64}$/u.test(record.signedDigest)) throw new Error('signature cache: invalid input record')
|
||||
const payload = await regularFile(join(entry, 'payload'))
|
||||
if (digest(payload) !== record.signedDigest) throw new Error('signature cache: corrupt signed payload')
|
||||
const record = await readCacheEntry(entry, key)
|
||||
if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record')
|
||||
return record
|
||||
}
|
||||
async function sign(configuration) {
|
||||
@@ -60,38 +153,66 @@ export function createCachedSigner(options) {
|
||||
try { await lstat(entry); hit = true }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
if (hit) {
|
||||
const record = await readEntry(entry, key, inputDigest)
|
||||
const staging = await mkdtemp(toNamespacedPath(join(dirname(configuration.path), '.signature-restore-')))
|
||||
const start = performance.now()
|
||||
try {
|
||||
const candidate = join(staging, basename(configuration.path))
|
||||
await copyFile(join(entry, 'payload'), candidate)
|
||||
if (digest(await regularFile(candidate)) !== record.signedDigest) throw new Error('signature cache: restore changed signed bytes')
|
||||
if (!validSignature(await options.inspect(candidate), options.thumbprint)) throw new Error('signature cache: invalid cached signature')
|
||||
if (digest(await regularFile(configuration.path)) !== inputDigest) throw new Error('signature cache: target changed before restore')
|
||||
await rename(candidate, configuration.path)
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
const record = await readEntry(entry, key, inputDigest)
|
||||
const staging = await mkdtemp(toNamespacedPath(join(dirname(configuration.path), '.signature-restore-')))
|
||||
try {
|
||||
const candidate = join(staging, basename(configuration.path))
|
||||
await copyFile(join(entry, 'payload'), candidate)
|
||||
if (digest(await regularFile(candidate)) !== record.signedDigest) throw new Error('signature cache: restore changed signed bytes')
|
||||
try { await verify(candidate) }
|
||||
catch (error) { throw new Error('signature cache: invalid cached signature', { cause: error }) }
|
||||
if (digest(await regularFile(configuration.path)) !== inputDigest) throw new Error('signature cache: target changed before restore')
|
||||
await rename(candidate, configuration.path)
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
} catch (error) {
|
||||
statistics.validationFailures++
|
||||
options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path })
|
||||
throw error
|
||||
} finally { statistics.restoreMs += performance.now() - start }
|
||||
statistics.hits++
|
||||
options.record({ type: 'signature-cache-hit', key, path: configuration.path })
|
||||
return
|
||||
}
|
||||
statistics.misses++
|
||||
options.record({ type: 'signature-cache-miss', key, path: configuration.path })
|
||||
await options.sign(configuration)
|
||||
if (!validSignature(await options.inspect(configuration.path), options.thumbprint)) throw new Error('signature cache: new signature verification failed')
|
||||
const start = performance.now()
|
||||
statistics.signingCalls++
|
||||
try { await options.sign(configuration) }
|
||||
finally { statistics.signingMs += performance.now() - start }
|
||||
try { await verify(configuration.path) }
|
||||
catch (error) {
|
||||
statistics.validationFailures++
|
||||
options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path })
|
||||
throw new Error('signature cache: new signature verification failed', { cause: error })
|
||||
}
|
||||
const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-')))
|
||||
try {
|
||||
await copyFile(configuration.path, join(staging, 'payload'))
|
||||
const signedDigest = digest(await regularFile(join(staging, 'payload')))
|
||||
if (signedDigest !== digest(await regularFile(configuration.path))) throw new Error('signature cache: signed file changed during publication')
|
||||
await writeFile(join(staging, 'record.json'), JSON.stringify({ version: 1, key, inputDigest, identity: options.identity, signedDigest }), { flag: 'wx', flush: true })
|
||||
try { await rename(staging, entry) }
|
||||
let published = false
|
||||
try { await rename(staging, entry); published = true }
|
||||
catch (error) {
|
||||
if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error
|
||||
await readEntry(entry, key, inputDigest)
|
||||
try { await readEntry(entry, key, inputDigest) }
|
||||
catch (validationError) {
|
||||
statistics.validationFailures++
|
||||
options.record({ type: 'signature-cache-validation-failed', key, path: configuration.path })
|
||||
throw validationError
|
||||
}
|
||||
}
|
||||
options.record({ type: 'signature-cache-published', key, path: configuration.path })
|
||||
if (published) statistics.published++
|
||||
else statistics.retained++
|
||||
options.record({ type: published ? 'signature-cache-published' : 'signature-cache-retained', key, path: configuration.path })
|
||||
} finally { await rm(staging, { recursive: true, force: true }) }
|
||||
}
|
||||
return configuration => {
|
||||
const signer = configuration => {
|
||||
pending = pending.then(() => sign(configuration))
|
||||
return pending
|
||||
}
|
||||
signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits })
|
||||
return signer
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Stage-wide ownership is independent of the retained hardware-attempt interlock. */
|
||||
export interface WindowsSigningStageOptions {
|
||||
stage: string
|
||||
record: (event: object) => void
|
||||
signal?: AbortSignal
|
||||
/** Test-only storage isolates independent processes from the real account's signing state. */
|
||||
stateDirectory?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one complete signing or cache-maintenance stage under the account's exclusive file handle.
|
||||
* @param options Audit sink, optional wait cancellation and isolated test storage.
|
||||
* @param operation Stage that awaits all owned children before settling.
|
||||
* @returns The stage result, after releasing the handle; acquisition errors never run the stage.
|
||||
*/
|
||||
export function withWindowsSigningStage<T>(options: WindowsSigningStageOptions, operation: () => Promise<T>): Promise<T>
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Serialize complete signing and cache-maintenance stages without clearing hardware failure evidence. */
|
||||
import { lstat, mkdir, realpath } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve, toNamespacedPath } from 'node:path'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
let bindings
|
||||
async function windows() {
|
||||
if (process.platform !== 'win32') throw new Error('Windows signing stages require Windows')
|
||||
if (bindings !== undefined) return bindings
|
||||
const koffi = (await import('koffi')).default
|
||||
const kernel = koffi.load('kernel32.dll')
|
||||
bindings = {
|
||||
open: kernel.func('__stdcall', 'CreateFileW', 'intptr', ['str16', 'uint', 'uint', 'void*', 'uint', 'uint', 'intptr']),
|
||||
close: kernel.func('__stdcall', 'CloseHandle', 'int', ['intptr']),
|
||||
error: kernel.func('__stdcall', 'GetLastError', 'uint', []),
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold one account-wide exclusive file handle until a stage and its children have settled.
|
||||
* @template T
|
||||
* @param {import('./windows-signing-stage.mjs').WindowsSigningStageOptions} options Audit sink, cancellation and isolated test storage.
|
||||
* @param {() => Promise<T>} operation Entire stage, including verification and cache publication.
|
||||
* @returns {Promise<T>} Stage result; contention waits, all other acquisition errors stop before the operation.
|
||||
*/
|
||||
export async function withWindowsSigningStage(options, operation) {
|
||||
const api = await windows()
|
||||
const root = options.stateDirectory ?? join(homedir(), '.dsh-desktop-signing')
|
||||
await mkdir(root, { recursive: true })
|
||||
const stat = await lstat(root)
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(root) !== resolve(root)) {
|
||||
throw new Error('Windows signing stage requires an unlinked local state directory')
|
||||
}
|
||||
const path = join(root, 'stage.lock')
|
||||
try {
|
||||
const file = await lstat(path)
|
||||
if (!file.isFile() || file.isSymbolicLink()) throw new Error('Windows signing stage requires a regular lock file')
|
||||
} catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
const start = performance.now()
|
||||
let handle
|
||||
let waiting = false
|
||||
for (;;) {
|
||||
options.signal?.throwIfAborted()
|
||||
// OPEN_ALWAYS retains the same file; denying sharing prevents concurrent holders and deletion.
|
||||
handle = api.open(toNamespacedPath(path), 0xc0000000, 0, null, 4, 0x80, 0)
|
||||
if (handle !== -1) break
|
||||
const code = api.error()
|
||||
if (code !== 32) throw new Error(`Windows signing stage cannot open its lock (Win32 ${code})`)
|
||||
if (!waiting) { options.record({ type: 'signing-stage-wait', stage: options.stage }); waiting = true }
|
||||
await setTimeout(100, undefined, { signal: options.signal })
|
||||
}
|
||||
try {
|
||||
options.record({ type: 'signing-stage-acquired', stage: options.stage, waitMs: performance.now() - start })
|
||||
return await operation()
|
||||
} finally {
|
||||
if (api.close(handle) === 0) throw new Error(`Windows signing stage cannot release its lock (Win32 ${api.error()})`)
|
||||
options.record({ type: 'signing-stage-released', stage: options.stage })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Independent process exercising stage ownership and cache reuse with a hardware-free signer. */
|
||||
import { appendFile, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { withWindowsSigningStage } from '../../scripts/windows-signing-stage.mjs'
|
||||
import { createCachedSigner, maintainSignatureCache } from '../../scripts/windows-signature-cache.mjs'
|
||||
|
||||
const [root, input, mode] = process.argv.slice(2)
|
||||
const release = new Promise(resolve => process.once('message', resolve))
|
||||
await withWindowsSigningStage({ stateDirectory: join(root, 'state'), stage: 'fixture', record: event => process.send(event) }, async () => {
|
||||
process.send({ type: 'entered' })
|
||||
await release
|
||||
if (mode === 'clear') {
|
||||
await maintainSignatureCache(join(root, 'cache'), true)
|
||||
return
|
||||
}
|
||||
const thumbprint = 'A'.repeat(40)
|
||||
const signer = createCachedSigner({ root: join(root, 'cache'), identity: 'fixture', thumbprint,
|
||||
record: event => process.send(event),
|
||||
inspect: async path => ({ status: (await readFile(path, 'utf8')).startsWith('signed:') ? 'Valid' : 'NotSigned', timestamped: true, thumbprint }),
|
||||
sign: async ({ path }) => {
|
||||
await appendFile(join(root, 'hardware-calls'), 'call\n')
|
||||
await writeFile(path, `signed:${await readFile(path, 'utf8')}`)
|
||||
},
|
||||
})
|
||||
await signer({ path: input, hash: 'sha256', isNest: false })
|
||||
})
|
||||
process.disconnect()
|
||||
@@ -1,6 +1,15 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
|
||||
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
|
||||
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
|
||||
}))
|
||||
vi.mock('../scripts/windows-signature-cache-directory.mjs', () => ({
|
||||
prepareWindowsSignatureCacheDirectory: vi.fn(async () => {}),
|
||||
resolveWindowsSignatureCacheDirectory: vi.fn(() => 'C:\\fixture-cache'),
|
||||
}))
|
||||
|
||||
// Keep the real orchestration and manifest reads; this suite owns no release directories or subprocesses.
|
||||
vi.mock('node:fs', async importOriginal => ({
|
||||
@@ -29,6 +38,8 @@ it('requires one signing preflight before building, then records only the comple
|
||||
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(vi.mocked(withWindowsSigningStage).mock.calls.map(([options]) => options.stage))
|
||||
.toEqual(['preflight', 'artifacts'])
|
||||
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(stages.indexOf('run sign:primary-runtime --dsh')).toBeGreaterThan(stages.indexOf('run prepare:dsh --defer-runtime-smoke'))
|
||||
@@ -59,6 +70,7 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no
|
||||
expect(stages).not.toContain('preflight:windows-signing')
|
||||
expect(stages).not.toContain('run sign:primary-runtime')
|
||||
expect(stages).not.toContain('run sign:primary-runtime --dsh')
|
||||
expect(withWindowsSigningStage).not.toHaveBeenCalled()
|
||||
for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rm, symlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect, it } from 'vitest'
|
||||
import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from '../scripts/windows-signature-cache-directory.mjs'
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('resolves an account cache independently of checkout and LocalAppData virtualization', () => {
|
||||
expect(resolveWindowsSignatureCacheDirectory({ LOCALAPPDATA: 'C:\\Users\\build\\AppData\\Local' }))
|
||||
.toBe(join(homedir(), '.dsh-desktop-signing', 'signature-cache', 'v1'))
|
||||
})
|
||||
|
||||
it('accepts an explicit local cache directory', () => {
|
||||
expect(resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: 'D:/private-cache' }))
|
||||
.toBe('D:\\private-cache')
|
||||
})
|
||||
|
||||
it.each(['', 'cache', 'C:cache', '\\cache', '\\\\server\\share', '\\\\?\\C:\\cache', 'C:\\cache:stream'])
|
||||
('rejects unsupported cache path %j', (path) => {
|
||||
expect(() => resolveWindowsSignatureCacheDirectory({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: path })).toThrow(/absolute local/u)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('creates private storage and rejects linked or publicly readable cache roots', async (t) => {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'directory-test-'))
|
||||
t.onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const cache = join(root, 'private')
|
||||
await prepareWindowsSignatureCacheDirectory(cache)
|
||||
await prepareWindowsSignatureCacheDirectory(cache)
|
||||
const link = join(root, 'linked')
|
||||
await symlink(cache, link, 'junction')
|
||||
await expect(prepareWindowsSignatureCacheDirectory(join(link, 'child'))).rejects.toThrow(/reparse/u)
|
||||
await promisify(execFile)('icacls.exe', [cache, '/grant', '*S-1-1-0:(OI)(CI)R'], { windowsHide: true })
|
||||
await expect(prepareWindowsSignatureCacheDirectory(cache)).rejects.toThrow(/permissions/u)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { it as test, type TestContext } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createCachedSigner, signatureCacheIdentity } from '../scripts/windows-signature-cache.mjs'
|
||||
import { createCachedSigner, maintainSignatureCache, migrateSignatureCache, signatureCacheIdentity } from '../scripts/windows-signature-cache.mjs'
|
||||
|
||||
async function fixture(t: TestContext) {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'cache-test-'))
|
||||
@@ -34,6 +34,20 @@ test('unchanged bytes restored in another file avoid another hardware call', asy
|
||||
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-hit').length, 1)
|
||||
})
|
||||
|
||||
test('cache summaries distinguish avoided signatures from actual signing attempts', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const signer = createCachedSigner(f.options)
|
||||
await signer(f.request)
|
||||
await writeFile(f.path, 'original')
|
||||
await signer(f.request)
|
||||
const { verificationMs, restoreMs, signingMs, ...summary } = signer.summary()
|
||||
assert.deepEqual(summary, { root: f.cache, identity: f.options.identity, hits: 1, misses: 1,
|
||||
published: 1, retained: 0, signingCalls: 1, avoidedSigningCalls: 1, validationFailures: 0 })
|
||||
assert(verificationMs >= 0 && restoreMs >= 0 && signingMs >= 0)
|
||||
assert.equal(f.state.hardwareCalls, summary.signingCalls)
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
|
||||
})
|
||||
|
||||
test('restores cached signatures into deeply nested dependency paths', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
@@ -64,7 +78,10 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops
|
||||
const entry = (await readdir(f.cache)).find(name => !name.startsWith('.'))
|
||||
assert(entry)
|
||||
await writeFile(join(f.cache, entry, corrupt), 'corrupt')
|
||||
await assert.rejects(createCachedSigner(f.options)(f.request))
|
||||
const signer = createCachedSigner(f.options)
|
||||
await assert.rejects(signer(f.request))
|
||||
assert.equal(signer.summary().validationFailures, 1)
|
||||
assert.equal(signer.summary().avoidedSigningCalls, 0)
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'original')
|
||||
})
|
||||
@@ -89,6 +106,8 @@ test('hardware failure rejects queued work without publishing an entry or retryi
|
||||
assert(results.every(result => result.status === 'rejected'))
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert.deepEqual(await readdir(f.cache), [])
|
||||
assert.equal(signer.summary().signingCalls, 1)
|
||||
assert.equal(signer.summary().validationFailures, 0)
|
||||
})
|
||||
|
||||
test('a failed new signature verification never publishes a cache entry', async (t) => {
|
||||
@@ -140,6 +159,8 @@ test('two writers publish one complete entry without overwriting another writer'
|
||||
createCachedSigner(options)({ ...f.request, path: second }),
|
||||
])
|
||||
for (const result of results) if (result.status === 'rejected') throw result.reason
|
||||
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-published').length, 1)
|
||||
assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-retained').length, 1)
|
||||
assert.equal((await readdir(f.cache)).length, 1)
|
||||
await writeFile(f.path, 'original')
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
@@ -166,3 +187,67 @@ test('a target changed during verification is never replaced with stale cache by
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert(!(await readdir(f.root)).some(name => name.startsWith('.signature-restore-')))
|
||||
})
|
||||
|
||||
test('migration preserves different valid timestamp bytes at the same destination key', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
const destination = join(f.root, 'shared')
|
||||
await writeFile(f.path, 'original')
|
||||
await createCachedSigner({ ...f.options, root: destination,
|
||||
sign: async ({ path }) => writeFile(path, 'signed:original:another-timestamp'),
|
||||
})(f.request)
|
||||
const key = (await readdir(f.cache))[0]!
|
||||
const sourceRecord = await readFile(join(f.cache, key, 'record.json'), 'utf8')
|
||||
const counts = await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} })
|
||||
assert.deepEqual(counts, { published: 0, retained: 1, skipped: 0 })
|
||||
assert.equal(await readFile(join(destination, key, 'payload'), 'utf8'), 'signed:original:another-timestamp')
|
||||
assert.equal(await readFile(join(f.cache, key, 'record.json'), 'utf8'), sourceRecord)
|
||||
})
|
||||
|
||||
test('migration copies complete entries and skips staging directories without reading them', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
await mkdir(join(f.cache, '.publish-incomplete'))
|
||||
const destination = join(f.root, 'shared')
|
||||
assert.deepEqual(await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }),
|
||||
{ published: 1, retained: 0, skipped: 1 })
|
||||
await writeFile(f.path, 'original')
|
||||
await createCachedSigner({ ...f.options, root: destination })(f.request)
|
||||
assert.equal(f.state.hardwareCalls, 1)
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
|
||||
assert.equal((await readdir(destination)).length, 1)
|
||||
})
|
||||
|
||||
test('migration refuses a corrupt existing destination instead of replacing it', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
const destination = join(f.root, 'shared')
|
||||
await migrateSignatureCache({ source: f.cache, root: destination, record: () => {} })
|
||||
const key = (await readdir(destination))[0]!
|
||||
await writeFile(join(destination, key, 'payload'), 'corrupt')
|
||||
await assert.rejects(migrateSignatureCache({ source: f.cache, root: destination, record: () => {} }), /corrupt/u)
|
||||
assert.equal(await readFile(join(destination, key, 'payload'), 'utf8'), 'corrupt')
|
||||
})
|
||||
|
||||
test('maintenance counts and clears complete entries while preserving incomplete staging', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
await mkdir(join(f.cache, '.publish-incomplete'))
|
||||
const usage = await maintainSignatureCache(f.cache)
|
||||
assert.equal(usage.entries, 1)
|
||||
assert(usage.bytes > 0)
|
||||
assert.equal(usage.incomplete, 1)
|
||||
assert.deepEqual(await maintainSignatureCache(f.cache, true), usage)
|
||||
assert.deepEqual(await readdir(f.cache), ['.publish-incomplete'])
|
||||
assert.equal(await readFile(f.path, 'utf8'), 'signed:original')
|
||||
})
|
||||
|
||||
test('maintenance refuses unexpected entry contents without deleting them', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await createCachedSigner(f.options)(f.request)
|
||||
const key = (await readdir(f.cache))[0]!
|
||||
const unexpected = join(f.cache, key, 'unrecognized')
|
||||
await writeFile(unexpected, 'keep')
|
||||
await assert.rejects(maintainSignatureCache(f.cache, true), /unexpected files/u)
|
||||
assert.equal(await readFile(unexpected, 'utf8'), 'keep')
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, type TestContext } from 'vitest'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
|
||||
const test = it.skipIf(process.platform !== 'win32')
|
||||
|
||||
interface StageChild {
|
||||
process: ChildProcess
|
||||
events: string[]
|
||||
closed: Promise<{ code: number | null; signal: NodeJS.Signals | null }>
|
||||
wait: (event: string) => Promise<void>
|
||||
}
|
||||
|
||||
async function fixture(t: TestContext) {
|
||||
const root = await mkdtemp(join(import.meta.dirname, 'stage-test-'))
|
||||
const children: StageChild[] = []
|
||||
t.onTestFinished(async () => {
|
||||
for (const item of children) {
|
||||
if (item.process.exitCode === null && item.process.signalCode === null) item.process.kill()
|
||||
await item.closed
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
function child(input: string, mode = 'sign'): StageChild {
|
||||
const process = fork(join(import.meta.dirname, 'fixtures/windows-signing-stage.mjs'), [root, input, mode], {
|
||||
execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
})
|
||||
const events: string[] = []
|
||||
let stderr = ''
|
||||
process.stderr!.on('data', (chunk) => { stderr += String(chunk) })
|
||||
const waiters = new Map<string, () => void>()
|
||||
process.on('message', (message: { type: string }) => { events.push(message.type); waiters.get(message.type)?.() })
|
||||
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
process.once('close', (code, signal) => { resolve({ code, signal }) })
|
||||
})
|
||||
const failed = new Promise<never>((_resolve, reject) => { process.once('error', reject) })
|
||||
const item = { process, events, closed,
|
||||
wait: (event: string) => Promise.race([
|
||||
events.includes(event) ? Promise.resolve() : new Promise<void>(resolve => waiters.set(event, resolve)),
|
||||
closed.then((result) => { throw new Error(`fixture exited before ${event}: ${JSON.stringify(result)} ${stderr}`) }),
|
||||
failed,
|
||||
]),
|
||||
}
|
||||
children.push(item)
|
||||
return item
|
||||
}
|
||||
const first = join(root, 'first.node')
|
||||
const second = join(root, 'second.node')
|
||||
await writeFile(first, 'original')
|
||||
await writeFile(second, 'original')
|
||||
return { root, first, second, child }
|
||||
}
|
||||
|
||||
test('independent signing stages wait and recheck the cache before accessing hardware', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = f.child(f.first)
|
||||
await first.wait('entered')
|
||||
const second = f.child(f.second)
|
||||
await second.wait('signing-stage-wait')
|
||||
expect(second.events).not.toContain('entered')
|
||||
first.process.send('release')
|
||||
expect(await first.closed).toEqual({ code: 0, signal: null })
|
||||
await second.wait('entered')
|
||||
second.process.send('release')
|
||||
expect(await second.closed).toEqual({ code: 0, signal: null })
|
||||
expect(await readFile(join(f.root, 'hardware-calls'), 'utf8')).toBe('call\n')
|
||||
expect(await readFile(f.second, 'utf8')).toBe('signed:original')
|
||||
expect(second.events).toContain('signature-cache-hit')
|
||||
})
|
||||
|
||||
test('an interrupted holder releases only the stage lock and retains hardware failure evidence', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = f.child(f.first)
|
||||
await first.wait('entered')
|
||||
const evidence = join(f.root, 'state/attempt.json')
|
||||
await writeFile(evidence, 'retained hardware attempt')
|
||||
first.process.kill()
|
||||
await first.closed
|
||||
await withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'inspect', record: () => {} }, async () => {
|
||||
expect(await readFile(evidence, 'utf8')).toBe('retained hardware attempt')
|
||||
})
|
||||
})
|
||||
|
||||
test('a failed stage releases its handle before another process enters', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await expect(withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'failed', record: () => {} }, async () => {
|
||||
throw new Error('stage failed')
|
||||
})).rejects.toThrow('stage failed')
|
||||
const next = f.child(f.first)
|
||||
await next.wait('entered')
|
||||
next.process.send('release')
|
||||
expect(await next.closed).toEqual({ code: 0, signal: null })
|
||||
})
|
||||
|
||||
test('a cancelled waiter never enters and does not release another process lock', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const holder = f.child(f.first)
|
||||
await holder.wait('entered')
|
||||
const controller = new AbortController()
|
||||
let entered = false
|
||||
await expect(withWindowsSigningStage({ stateDirectory: join(f.root, 'state'), stage: 'cancelled', signal: controller.signal,
|
||||
record: (event) => { if ('type' in event && event.type === 'signing-stage-wait') controller.abort() },
|
||||
}, async () => { entered = true })).rejects.toThrow()
|
||||
expect(entered).toBe(false)
|
||||
holder.process.send('release')
|
||||
expect(await holder.closed).toEqual({ code: 0, signal: null })
|
||||
})
|
||||
|
||||
test('cache clearing waits until another process finishes signing and publishing', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const signer = f.child(f.first)
|
||||
await signer.wait('entered')
|
||||
const cleaner = f.child(f.second, 'clear')
|
||||
await cleaner.wait('signing-stage-wait')
|
||||
expect(cleaner.events).not.toContain('entered')
|
||||
signer.process.send('release')
|
||||
expect(await signer.closed).toEqual({ code: 0, signal: null })
|
||||
await cleaner.wait('entered')
|
||||
cleaner.process.send('release')
|
||||
expect(await cleaner.closed).toEqual({ code: 0, signal: null })
|
||||
expect(await readdir(join(f.root, 'cache'))).toEqual([])
|
||||
expect(await readFile(f.first, 'utf8')).toBe('signed:original')
|
||||
})
|
||||
Generated
+3
@@ -575,6 +575,9 @@ importers:
|
||||
js-yaml:
|
||||
specifier: ^4.2.0
|
||||
version: 4.3.1
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.1
|
||||
pnpm:
|
||||
specifier: 11.7.0
|
||||
version: 11.7.0
|
||||
|
||||
Reference in new issue
Block a user