From 4c252360701ee5fe74737d6d4dae18ef8c10466a Mon Sep 17 00:00:00 2001 From: wuyouMaster Date: Sun, 20 Sep 2026 15:57:02 +0800 Subject: [PATCH] fix: ad-hoc sign packaged macOS key helpers and app bundle electron-builder 26 skips macOS signing entirely when no Developer ID identity is configured, so the packaged key helpers can ship unsigned or with a broken signature and macOS kills them even with SIP disabled. afterPack now verifies and ad-hoc re-signs the packaged xkey helpers and the outer app bundle, failing the build when a signature cannot be repaired. --- resources/xkey_helper | Bin 343488 -> 307424 bytes resources/xkey_helper_4_1_13 | Bin 101720 -> 117984 bytes scripts/after-pack.cjs | 78 ++++++++++++++++- tests/unit/runtime-packaging.test.ts | 126 ++++++++++++++++++++++++++- 4 files changed, 200 insertions(+), 4 deletions(-) diff --git a/resources/xkey_helper b/resources/xkey_helper index 1c9b951830de80ef672d14a35bf28bb3a0eda49a..ce2178adb1fa8374ba87591df74ee74fc37501f5 100755 GIT binary patch delta 793 zcmX@`Tjaq*p$TGA!VL@z41A0X3_L)Zfq|(3hz%Y9S$rD}&)aYQZ{Nfs2owW@13+a! z&=9Z@SU^gEY=*Ny906r3GBEK4C@?YbOf*!Te$kJ~VfsdYCI^MuSj~xs>~A>FHBWuj zmhNJ!3S+_%(ONzVxks9cK8~M=TF4xg9IuH_dgU)$=m0b61@&?)_dTEWO%iRvO=zgLfv@ zY(5uiwB^gbgFgSYS+~w*7X5Ei^4?*dqbbnwAa8)f=+`}N5S!yixq0uO(kI@E(UKbt z8+5iEU}|I$bO6eL0my$03=a&pPddT`3M;0KhFm=D8!j;cG4u8fmsr?j+?cQg7!x$W zn4kg1h%3N28JP4o00WE<6kvw$SsbPdeqeE|mpUo6;ce`!zIDcY>T@O~E=XIp>uTPN zqs+U{>6ZR_Aao$RaN6NhFM~P?t!BQmnZT27ysD+UFx=`$*yRR+_v@JyuCp62yzSqs zt#Tr&!hPa|aIqO#dLr@>PsN^EE(loj;_J`5DJ{v4kJpM^j@G`KVOreO*ff=gw|UMF z;UL=+hxXV1`kA|tAx!nw>>HI^bY|aXap*e`b}DB`j2p=%B1 z3q0@X&apMxC;jkhzL=m#TKQ_1=yf-3&q}{J%inpnpmB1*#8V=#etfP9@-$rVA?jGn zo4n}8w_)eiHs)E+k9x@IpQ(I`pHXx3mn?hH59WUlupW8Kw#PxQE5xkI_R5s21=C*t o6pk~AR63%#^zUSkBE6FR?H8)NCu57s$Wz^c+p6YS)rxTf0Dv delta 3158 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 delta 578 zcmaDbll{glHiJ|DcI{(eU|?coU|DA`M>;EP7i3{fW@M6J znrNuBeFiJzx}#=H54c&!^0u$u64`S zS39QE)=#>!s$k|$*C@vGuNJS+XL=U7PEA8;Hfvd|+>c(HdJ~6fJJozV_zqgW_E@U? z&-{+?r!u8T$0=Von5C+=mk4Su@O~_qmm!rmi7T8b>C>#BfH=V~pIjV8!(5Ynx)e9w zD_&c_W>ZA|v6pEFSgv2Zn9bxL!TP^uOHEblt-akIojS|$%GU!GsO)P|pOKu-7U26} q1&hL0>$~R_bxeQTZ7w=`+qSDgXev;o~0w diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index c06c773..343d328 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -16,6 +16,15 @@ const REQUIRED_RUNTIME_PACKAGES = [ 'koffi' ] +// electron-builder 26 skips macOS signing entirely when no Developer ID +// identity is configured, so an unpacked bundle can ship without a usable +// signature. macOS kills a helper whose code or signature is missing or +// modified even when SIP is disabled, which is what customers hit on newer +// macOS releases. Ad-hoc re-sign the runtime helpers and the outer bundle so +// every Mach-O verifies strictly; spctl still rejects ad-hoc code, which is +// acceptable for the SIP-disabled customer workflow. +const MACOS_HELPER_NAMES = ['xkey_helper', 'xkey_helper_4_1_13'] + function getRuntimeResources(context) { const productName = context.packager.appInfo.productFilename return context.electronPlatformName === 'darwin' @@ -121,6 +130,63 @@ function normalizeBuilderArch(arch) { return { 0: 'ia32', 1: 'x64', 2: 'armv7l', 3: 'arm64', 4: 'universal' }[arch] || String(arch) } +function runCodesign(args) { + execFileSync('/usr/bin/codesign', args, { stdio: 'ignore' }) +} + +function isMacosCodeValid(targetPath, run = runCodesign) { + try { + run(['--verify', '--strict', targetPath]) + return true + } catch { + return false + } +} + +function findMacosHelperPaths(runtimeResources) { + return MACOS_HELPER_NAMES.map((name) => path.join(runtimeResources, 'resources', name)).filter( + (helperPath) => existsSync(helperPath) + ) +} + +function signMacosHelpers(runtimeResources, run = runCodesign) { + const helperPaths = findMacosHelperPaths(runtimeResources) + for (const helperPath of helperPaths) { + chmodSync(helperPath, 0o755) + if (!isMacosCodeValid(helperPath, run)) { + run(['--force', '--sign', '-', helperPath]) + } + for (const arch of ['arm64', 'x86_64']) { + try { + run(['--verify', '--strict', '--arch', arch, helperPath]) + } catch (error) { + throw new Error( + 'macOS helper signature verification failed: ' + + path.basename(helperPath) + + ' (' + + arch + + ')', + { cause: error } + ) + } + } + } + return helperPaths +} + +function signMacosAppBundle(appBundlePath, run = runCodesign) { + if (isMacosCodeValid(appBundlePath, run)) return appBundlePath + run(['--force', '--sign', '-', appBundlePath]) + try { + run(['--verify', '--strict', appBundlePath]) + } catch (error) { + throw new Error('macOS app bundle signature verification failed: ' + appBundlePath, { + cause: error + }) + } + return appBundlePath +} + /** * A foreign-architecture binary only fails once the user touches the feature * that needs it, so verify the ones whose filename is shared across @@ -226,7 +292,9 @@ function pruneForeignArchNativeRuntimes(runtimeResources, platform, arch) { for (const entry of readdirSync(modulesRoot, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === expected || !foreign.test(entry.name)) continue rmSync(path.join(modulesRoot, entry.name), { recursive: true, force: true }) - removed.push(runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name) + removed.push( + runtime.modules.length ? `${runtime.modules.join('/')}/${entry.name}` : entry.name + ) } } return removed @@ -280,6 +348,9 @@ exports.default = async function afterPack(context) { execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', ffmpegPath], { stdio: 'ignore' }) + signMacosHelpers(runtimeResources) + const productName = context.packager.appInfo.productFilename + signMacosAppBundle(path.join(context.appOutDir, productName + '.app')) } if (context.electronPlatformName === 'win32') { @@ -298,7 +369,6 @@ exports.default = async function afterPack(context) { } return } - } exports.getRuntimeResources = getRuntimeResources @@ -312,3 +382,7 @@ exports.pruneIntelMacKeyTool = pruneIntelMacKeyTool exports.pruneForeignArchConnectors = pruneForeignArchConnectors exports.pruneForeignArchNativeRuntimes = pruneForeignArchNativeRuntimes exports.validateRuntimeBinaryArchitecture = validateRuntimeBinaryArchitecture +exports.findMacosHelperPaths = findMacosHelperPaths +exports.isMacosCodeValid = isMacosCodeValid +exports.signMacosHelpers = signMacosHelpers +exports.signMacosAppBundle = signMacosAppBundle diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts index e4d0cee..6811928 100644 --- a/tests/unit/runtime-packaging.test.ts +++ b/tests/unit/runtime-packaging.test.ts @@ -1,5 +1,13 @@ import { createRequire } from 'module' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'fs' import { tmpdir } from 'os' import { dirname, join, resolve } from 'path' import { afterAll, describe, expect, it } from 'vitest' @@ -16,13 +24,37 @@ const { validateFfmpegRuntime, validateReaderSkillRuntime, validateSherpaRuntime, - validateSilkWasmRuntime + validateSilkWasmRuntime, + findMacosHelperPaths, + isMacosCodeValid, + signMacosHelpers, + signMacosAppBundle } = nodeRequire('../../scripts/after-pack.cjs') as { validateAsarRuntimeDependencies: (runtimeResources: string) => void validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void validateReaderSkillRuntime: (runtimeResources: string) => string validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void validateSilkWasmRuntime: (runtimeResources: string) => void + findMacosHelperPaths: (runtimeResources: string) => string[] + isMacosCodeValid: (targetPath: string, run?: CodesignRunner) => boolean + signMacosHelpers: (runtimeResources: string, run?: CodesignRunner) => string[] + signMacosAppBundle: (appBundlePath: string, run?: CodesignRunner) => string +} + +type CodesignRunner = (args: string[]) => void + +function createCodesignStub(options: { verifyFails?: (args: string[]) => boolean } = {}): { + calls: string[][] + run: CodesignRunner +} { + const calls: string[][] = [] + const run: CodesignRunner = (args) => { + calls.push(args) + if (options.verifyFails && args[0] === '--verify' && options.verifyFails(args)) { + throw new Error('code object is not signed at all') + } + } + return { calls, run } } const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-')) @@ -217,6 +249,96 @@ describe('production runtime packaging', () => { expect(config).toContain('node_modules/sherpa-onnx-node/**') expect(config).toContain('node_modules/sherpa-onnx-*/**') }) + + it('finds only the macOS helpers that exist in packaged resources', () => { + const resources = join(root, 'helper-detect-resources', 'resources') + mkdirSync(resources, { recursive: true }) + expect(findMacosHelperPaths(join(root, 'helper-detect-resources'))).toEqual([]) + + const helperPath = join(resources, 'xkey_helper') + writeFileSync(helperPath, 'fixture') + const versionedPath = join(resources, 'xkey_helper_4_1_13') + writeFileSync(versionedPath, 'fixture') + expect(findMacosHelperPaths(join(root, 'helper-detect-resources'))).toEqual([ + helperPath, + versionedPath + ]) + }) + + it('ad-hoc signs packaged helpers whose signature is missing or modified', () => { + const resources = join(root, 'helper-sign-resources', 'resources') + mkdirSync(resources, { recursive: true }) + const helperPath = join(resources, 'xkey_helper') + writeFileSync(helperPath, 'fixture') + const stub = createCodesignStub({ verifyFails: (args) => !args.includes('--arch') }) + + expect(signMacosHelpers(join(root, 'helper-sign-resources'), stub.run)).toEqual([helperPath]) + expect(stub.calls).toContainEqual(['--force', '--sign', '-', helperPath]) + expect(stub.calls).toContainEqual(['--verify', '--strict', '--arch', 'arm64', helperPath]) + expect(stub.calls).toContainEqual(['--verify', '--strict', '--arch', 'x86_64', helperPath]) + expect(statSync(helperPath).mode & 0o777).toBe(0o755) + }) + + it('keeps helpers that already verify strictly without re-signing them', () => { + const resources = join(root, 'helper-valid-resources', 'resources') + mkdirSync(resources, { recursive: true }) + const helperPath = join(resources, 'xkey_helper') + writeFileSync(helperPath, 'fixture') + const stub = createCodesignStub() + + expect(signMacosHelpers(join(root, 'helper-valid-resources'), stub.run)).toEqual([helperPath]) + expect(stub.calls.filter((args) => args[0] === '--force')).toEqual([]) + expect(stub.calls).toContainEqual(['--verify', '--strict', '--arch', 'arm64', helperPath]) + expect(stub.calls).toContainEqual(['--verify', '--strict', '--arch', 'x86_64', helperPath]) + }) + + it('fails packaging when a helper signature cannot be repaired', () => { + const resources = join(root, 'helper-broken-resources', 'resources') + mkdirSync(resources, { recursive: true }) + writeFileSync(join(resources, 'xkey_helper'), 'fixture') + const stub = createCodesignStub({ verifyFails: () => true }) + + expect(() => signMacosHelpers(join(root, 'helper-broken-resources'), stub.run)).toThrow( + /xkey_helper \(arm64\)/ + ) + }) + + it('ad-hoc signs an invalid app bundle and verifies it strictly', () => { + const appBundle = join(root, 'TraceMemo.app') + const calls: string[][] = [] + let verifyCount = 0 + const run: CodesignRunner = (args) => { + calls.push(args) + if (args[0] === '--verify') { + verifyCount += 1 + if (verifyCount === 1) throw new Error('bundle is not signed') + } + } + + expect(signMacosAppBundle(appBundle, run)).toBe(appBundle) + expect(calls).toEqual([ + ['--verify', '--strict', appBundle], + ['--force', '--sign', '-', appBundle], + ['--verify', '--strict', appBundle] + ]) + }) + + it('keeps an app bundle that already verifies strictly', () => { + const appBundle = join(root, 'Valid.app') + const stub = createCodesignStub() + + expect(signMacosAppBundle(appBundle, stub.run)).toBe(appBundle) + expect(stub.calls).toEqual([['--verify', '--strict', appBundle]]) + }) + + it('fails packaging when the app bundle cannot be made strictly valid', () => { + const appBundle = join(root, 'Broken.app') + const stub = createCodesignStub({ verifyFails: () => true }) + + expect(() => signMacosAppBundle(appBundle, stub.run)).toThrow( + /app bundle signature verification failed/ + ) + }) }) describe('per-architecture macOS packaging', () => {