diff --git a/resources/xkey_helper b/resources/xkey_helper index 1c9b951..ce2178a 100755 Binary files a/resources/xkey_helper and b/resources/xkey_helper differ diff --git a/resources/xkey_helper_4_1_13 b/resources/xkey_helper_4_1_13 index ada9a32..b3a566d 100755 Binary files a/resources/xkey_helper_4_1_13 and b/resources/xkey_helper_4_1_13 differ 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', () => {