From a816f92db5f428c578b79a959d996e03bcbab264 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 21 Oct 2026 00:00:22 +0800 Subject: [PATCH 1/5] fix(desktop): rewrite Mach-O LC_UUID in packaged macOS apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder renames Electron's executables but never relinks them, so every binary in a packaged Maka.app keeps the LC_UUID burned in by Electron's own build — identical across every Electron 43 app. macOS 15+ local-network privacy (NECP) identifies a program by its main executable's LC_UUID, so Maka shares a privacy identity with all of them, leaks cross-app network consent, and never gets an entry of its own in system settings. Add an afterPack hook (it runs before signing, so the signature covers the rewritten bytes) that replaces the 16-byte LC_UUID payload of the main and every helper executable with UUIDv5 (SHA-1, RFC 4122) over the app id, the executable file name and the slice's CPU type. Derivation is deterministic: repackaging yields the same identity and the rewrite is idempotent. The file length and every other byte stay untouched; Windows and Linux are unaffected. Fixes #6026 Generated-by: GLM-5.3-Flash (ZCode) --- apps/desktop/electron-builder.config.mjs | 4 + package.json | 2 +- scripts/desktop-release-targets.test.mjs | 9 +- scripts/macos-lc-uuid.mjs | 195 +++++++++++++++++++++++ scripts/macos-lc-uuid.test.mjs | 192 ++++++++++++++++++++++ 5 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 scripts/macos-lc-uuid.mjs create mode 100644 scripts/macos-lc-uuid.test.mjs diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 9a9eac76014..dde7b4019e3 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -26,6 +26,9 @@ import { } from '../../scripts/desktop-nightly.mjs'; import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; +// afterPack runs before signing, so the rewritten LC_UUIDs (#6026) are covered +// by the code signature and notarization sees a self-consistent bundle. +import { rewritePackagedMacosUuids as afterPack } from '../../scripts/macos-lc-uuid.mjs'; function readManifest(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); @@ -73,6 +76,7 @@ const baseDesktopBuilderConfig = { artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, beforePack: stageReleaseManifests, + afterPack, extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { output: 'release', diff --git a/package.json b/package.json index a574866441e..0125a634284 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test --test-concurrency=1 scripts/staple-macos-artifact.test.mjs scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test --test-concurrency=1 scripts/staple-macos-artifact.test.mjs scripts/product-nightly.test.mjs scripts/desktop-release-targets.test.mjs scripts/verify-linux-harness.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/audit-shipped-dependencies.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/macos-lc-uuid.test.mjs scripts/macos-update-archive.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/prepare-windows-upgrade-baseline.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos.mjs arm64", "package:macos-x64": "node scripts/package-macos.mjs x64", "verify:macos": "node scripts/verify-macos-dmg.mjs", diff --git a/scripts/desktop-release-targets.test.mjs b/scripts/desktop-release-targets.test.mjs index 2d3b19a5f07..88e1cc60e25 100644 --- a/scripts/desktop-release-targets.test.mjs +++ b/scripts/desktop-release-targets.test.mjs @@ -192,12 +192,13 @@ const DESKTOP_PROJECT_DIRECTORY = fileURLToPath(new URL('../apps/desktop', impor * * Copied through JSON rather than `structuredClone`: everything electron-builder reads * here has to survive serialization anyway, and the structured clone algorithm rejected - * this object on Node 24 while accepting it on 26. `beforePack` is the one function in - * the configuration; it is carried across by reference and never invoked by this test. + * this object on Node 24 while accepting it on 26. The lifecycle hooks are the only + * functions in the configuration; they are carried across by reference and never + * invoked by this test. */ function isolatedBuilderConfig() { - const { beforePack, ...rest } = resolveDesktopBuilderConfig({}); - return { ...JSON.parse(JSON.stringify(rest)), beforePack }; + const { beforePack, afterPack, ...rest } = resolveDesktopBuilderConfig({}); + return { ...JSON.parse(JSON.stringify(rest)), beforePack, afterPack }; } /** diff --git a/scripts/macos-lc-uuid.mjs b/scripts/macos-lc-uuid.mjs new file mode 100644 index 00000000000..45860e4d7ff --- /dev/null +++ b/scripts/macos-lc-uuid.mjs @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Rewrites the LC_UUID load command of every Mach-O in a packaged macOS app + * bundle (#6026). + * + * electron-builder renames Electron's executable files but never relinks them, + * so every shipped binary keeps the UUID burned into it by Electron's build: + * identical across every Electron app of the same version. macOS 15+ local- + * network privacy (NECP) identifies a program by its main executable's + * LC_UUID, so Maka shares a privacy identity with every other Electron 43 + * install and never gets an entry of its own. + * + * The replacement is UUIDv5 (SHA-1, RFC 4122) over the app id, the executable + * file name and the slice's CPU type — deterministic, so repackaging the same + * inputs yields the same identity, and running the rewrite twice changes + * nothing. It runs from the `afterPack` hook, which is before code signing: + * the signature then covers the rewritten bytes, and notarization sees a + * self-consistent bundle. + */ + +import { createHash } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +const MH_MAGIC_64 = 0xfeedfacf; +// Fat headers store their magic big-endian, so a little-endian read of +// 0xcafebabe / 0xcafebabf yields the swapped values below. +const FAT_MAGICS = new Map([ + [0xbebafeca, { archEntrySize: 20 }], + [0xbfbafeca, { archEntrySize: 32 }], +]); +const LC_UUID = 0x1b; +const MACHO_HEADER_64_SIZE = 32; +const LOAD_COMMAND_HEADER_SIZE = 8; +const UUID_SIZE = 16; + +// RFC 4122 Appendix C.1 namespace for fully-qualified domain names. +const DNS_NAMESPACE_UUID = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; +// Derived, not invented: no magic constant here needs to be trusted, only the +// project domain this namespace is pulled from. +const MAKA_UUID_NAMESPACE = uuidv5(DNS_NAMESPACE_UUID, 'maka.apache.org'); + +// The cputypes that actually appear in an Electron fat binary. arm64e shares +// the ARM64 cputype and differs only in cpusubtype, so it is not +// distinguishable — and not distinguishable is fine, because Electron ships +// no arm64e slices; anything unexpected still gets a stable name via the +// fallback below. +const CPU_TYPE_NAMES = new Map([ + [0x01000007, 'x86_64'], + [0x0100000c, 'arm64'], +]); + +export function uuidv5(namespaceUuid, name) { + const digest = createHash('sha1') + .update(Buffer.from(namespaceUuid.replaceAll('-', ''), 'hex')) + .update(name, 'utf8') + .digest(); + digest[6] = (digest[6] & 0x0f) | 0x50; // version 5 + digest[8] = (digest[8] & 0x3f) | 0x80; // RFC 4122 variant + const hex = digest.toString('hex', 0, 16); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function cpuTypeName(cpuType) { + return CPU_TYPE_NAMES.get(cpuType) ?? `cpu-0x${cpuType.toString(16)}`; +} + +/** + * The deterministic replacement UUID for one executable slice: distinct per + * app, per executable and per architecture, yet stable across rebuilds. + */ +export function deterministicExecutableUuid(appId, executableName, cpuType) { + // '/' cannot appear in a file name, so the joined name is unambiguous. + return uuidv5(MAKA_UUID_NAMESPACE, `${appId}/${executableName}/${cpuTypeName(cpuType)}`); +} + +/** + * Offsets of every LC_UUID payload in a Mach-O buffer (thin 64-bit or fat), + * each with its slice's CPU type. Thin 32-bit slices cannot appear in an + * Electron bundle and are rejected rather than misparsed. + */ +export function machoLcUuidLocations(buffer) { + const magic = buffer.readUInt32LE(0); + const fat = FAT_MAGICS.get(magic); + if (!fat) return thinLcUuidLocations(buffer, 0); + const locations = []; + const sliceCount = buffer.readUInt32BE(4); + for (let index = 0; index < sliceCount; index += 1) { + const entry = 8 + index * fat.archEntrySize; + const sliceOffset = Number( + fat.archEntrySize === 32 ? buffer.readBigUInt64BE(entry + 8) : buffer.readUInt32BE(entry + 8), + ); + locations.push(...thinLcUuidLocations(buffer, sliceOffset)); + } + return locations; +} + +function thinLcUuidLocations(buffer, base) { + const magic = buffer.readUInt32LE(base); + if (magic !== MH_MAGIC_64) { + throw new Error(`unsupported Mach-O magic 0x${magic.toString(16)} at offset ${base}`); + } + const cpuType = buffer.readUInt32LE(base + 4); + const commandCount = buffer.readUInt32LE(base + 16); + const locations = []; + let cursor = base + MACHO_HEADER_64_SIZE; + for (let index = 0; index < commandCount; index += 1) { + const commandSize = buffer.readUInt32LE(cursor + 4); + if (commandSize < LOAD_COMMAND_HEADER_SIZE) { + throw new Error(`corrupt load command size ${commandSize} at offset ${cursor}`); + } + if (buffer.readUInt32LE(cursor) === LC_UUID) { + locations.push({ offset: cursor + LOAD_COMMAND_HEADER_SIZE, cpuType }); + } + cursor += commandSize; + } + return locations; +} + +/** + * Replaces every LC_UUID payload in `buffer` in place; the buffer length and + * all other bytes are left exactly as they were. + */ +export function rewriteBufferUuids(buffer, appId, executableName) { + for (const { offset, cpuType } of machoLcUuidLocations(buffer)) { + Buffer.from( + deterministicExecutableUuid(appId, executableName, cpuType).replaceAll('-', ''), + 'hex', + ).copy(buffer, offset); + } +} + +async function rewriteExecutableFile(path, appId) { + const buffer = await readFile(path); + if (machoLcUuidLocations(buffer).length === 0) { + throw new Error(`no LC_UUID load command in packaged executable ${path}`); + } + rewriteBufferUuids(buffer, appId, basename(path)); + await writeFile(path, buffer); +} + +async function findAppBundleExecutables(directory) { + const executables = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const path = join(directory, entry.name); + if (entry.name.endsWith('.app')) { + const macOS = join(path, 'Contents', 'MacOS'); + for (const file of await readdir(macOS, { withFileTypes: true })) { + if (file.isFile()) executables.push(join(macOS, file.name)); + } + } + // Helper bundles are nested inside the main one + // (`Maka.app/Contents/Frameworks/*.app`), so the walk has to descend + // through found bundles to reach them. + executables.push(...(await findAppBundleExecutables(path))); + } + return executables; +} + +/** + * The electron-builder `afterPack` hook: rewrites the main executable and + * every helper executable of the packaged macOS app. Runs before signing, so + * the new UUIDs are covered by the signature. Other platforms have no Mach-O + * to fix and are untouched. + */ +export async function rewritePackagedMacosUuids(context) { + if (context.electronPlatformName !== 'darwin') return; + const appId = context.packager.appInfo.id; + const executables = await findAppBundleExecutables(context.appOutDir); + if (executables.length === 0) { + throw new Error(`no .app bundle found under ${context.appOutDir}`); + } + for (const executable of executables) { + await rewriteExecutableFile(executable, appId); + } +} diff --git a/scripts/macos-lc-uuid.test.mjs b/scripts/macos-lc-uuid.test.mjs new file mode 100644 index 00000000000..f036c3e573c --- /dev/null +++ b/scripts/macos-lc-uuid.test.mjs @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { + deterministicExecutableUuid, + machoLcUuidLocations, + rewriteBufferUuids, + rewritePackagedMacosUuids, + uuidv5, +} from './macos-lc-uuid.mjs'; + +/** + * Every packaged Mach-O keeps its byte length and load-command layout; only + * the 16 bytes under each LC_UUID move. These fixtures are the smallest + * headers that prove that: one LC_UUID and one unrelated load command whose + * bytes must survive untouched. + */ +const MH_MAGIC_64 = 0xfeedfacf; +const LC_UUID = 0x1b; +const LC_SOURCE_VERSION = 0x2a; +const CPU_TYPE_X86_64 = 0x01000007; +const CPU_TYPE_ARM64 = 0x0100000c; +const DNS_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + +// The UUID from the issue: every Electron 43.4.1-based app ships this exact +// Mach-O UUID, which is what makes macOS local-network privacy collide. +const OLD_UUID = Buffer.from('4c4c447055553144a124518576dd11e3', 'hex'); + +function thinMachO(cpuType, uuid) { + const header = Buffer.alloc(32); + header.writeUInt32LE(MH_MAGIC_64, 0); + header.writeUInt32LE(cpuType, 4); + header.writeUInt32LE(2, 12); // MH_EXECUTE + header.writeUInt32LE(2, 16); // ncmds + header.writeUInt32LE(24 + 16, 20); // sizeofcmds + const uuidCommand = Buffer.alloc(24); + uuidCommand.writeUInt32LE(LC_UUID, 0); + uuidCommand.writeUInt32LE(24, 4); + uuid.copy(uuidCommand, 8); + const sourceVersion = Buffer.alloc(16); + sourceVersion.writeUInt32LE(LC_SOURCE_VERSION, 0); + sourceVersion.writeUInt32LE(16, 4); + sourceVersion.writeBigUInt64LE(0x1122334455667788n, 8); + return Buffer.concat([header, uuidCommand, sourceVersion]); +} + +function fatMachO(slices) { + const sliceSize = 4096; + const tableSize = 8 + slices.length * 20; + const buffer = Buffer.alloc(tableSize + slices.length * sliceSize); + buffer.writeUInt32BE(0xcafebabe, 0); + buffer.writeUInt32BE(slices.length, 4); + slices.forEach((slice, index) => { + const arch = 8 + index * 20; + const offset = tableSize + index * sliceSize; + buffer.writeUInt32BE(slice.cpuType, arch); + buffer.writeUInt32BE(offset, arch + 8); + buffer.writeUInt32BE(slice.data.length, arch + 12); + buffer.writeUInt32BE(12, arch + 16); // 2^12 slice alignment + slice.data.copy(buffer, offset); + }); + return buffer; +} + +/** Rewrites `buffer` and asserts everything but the UUID payloads survived. */ +function rewrittenLike(original, appId, executableName, uuidFor) { + const rewritten = Buffer.from(original); + const expected = Buffer.from(original); + const locations = machoLcUuidLocations(rewritten); + assert.ok(locations.length > 0, 'fixture must carry an LC_UUID'); + for (const { offset, cpuType } of locations) { + const uuid = uuidFor(cpuType); + rewritten.write(uuid.replaceAll('-', ''), offset, 'hex'); + expected.write(uuid.replaceAll('-', ''), offset, 'hex'); + } + rewriteBufferUuids(rewritten, appId, executableName); + assert.equal(rewritten.length, original.length, 'file size must not change'); + assert.deepEqual(rewritten, expected, 'only LC_UUID payloads may move'); + return { rewritten, locations }; +} + +test('uuidv5 follows RFC 4122', () => { + // python.org under the DNS namespace is the reference example every + // conforming implementation agrees on (verified against python's uuid5). + assert.equal(uuidv5(DNS_NAMESPACE, 'python.org'), '886313e1-3b8a-5372-9b90-0c9aee199e5d'); + assert.match(uuidv5(DNS_NAMESPACE, 'example.org'), /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); +}); + +test('executable UUIDs are deterministic and distinguish every input', () => { + const base = deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64); + assert.equal(base, deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64)); + assert.notEqual(base, deterministicExecutableUuid('com.maka.desktop', 'Maka Helper', CPU_TYPE_ARM64)); + assert.notEqual(base, deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_X86_64)); + assert.notEqual(base, deterministicExecutableUuid('com.other.app', 'Maka', CPU_TYPE_ARM64)); +}); + +test('rewriting a thin Mach-O replaces only the LC_UUID payload', () => { + rewrittenLike( + thinMachO(CPU_TYPE_ARM64, OLD_UUID), + 'com.maka.desktop', + 'Maka', + () => deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64), + ); +}); + +test('rewriting a fat Mach-O gives each architecture its own UUID', () => { + const { locations } = rewrittenLike( + fatMachO([ + { cpuType: CPU_TYPE_X86_64, data: thinMachO(CPU_TYPE_X86_64, OLD_UUID) }, + { cpuType: CPU_TYPE_ARM64, data: thinMachO(CPU_TYPE_ARM64, OLD_UUID) }, + ]), + 'com.maka.desktop', + 'Maka', + (cpuType) => deterministicExecutableUuid('com.maka.desktop', 'Maka', cpuType), + ); + assert.equal(locations.length, 2); + assert.notEqual( + deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_X86_64), + deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64), + 'each slice must carry its own UUID', + ); +}); + +test('rewriting is idempotent', () => { + const once = thinMachO(CPU_TYPE_ARM64, OLD_UUID); + rewriteBufferUuids(once, 'com.maka.desktop', 'Maka'); + const twice = Buffer.from(once); + rewriteBufferUuids(twice, 'com.maka.desktop', 'Maka'); + assert.deepEqual(twice, once); +}); + +test('the afterPack hook rewrites the app and every helper executable', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-lc-uuid-')); + const bundle = join(root, 'Maka.app'); + const helpers = join(bundle, 'Contents', 'Frameworks'); + const executables = [ + [join(bundle, 'Contents', 'MacOS', 'Maka'), 'Maka'], + [join(helpers, 'Maka Helper.app', 'Contents', 'MacOS', 'Maka Helper'), 'Maka Helper'], + ]; + for (const [path] of executables) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, thinMachO(CPU_TYPE_ARM64, OLD_UUID)); + } + + await rewritePackagedMacosUuids({ + electronPlatformName: 'darwin', + appOutDir: root, + packager: { appInfo: { id: 'com.maka.desktop', productFilename: 'Maka' } }, + }); + + for (const [path, name] of executables) { + const bytes = await readFile(path); + const expected = deterministicExecutableUuid('com.maka.desktop', name, CPU_TYPE_ARM64); + const [location] = machoLcUuidLocations(bytes); + assert.equal( + bytes.subarray(location.offset, location.offset + 16).toString('hex'), + expected.replaceAll('-', ''), + `${path} must carry the deterministic UUID`, + ); + } +}); + +test('the afterPack hook leaves non-macOS targets alone', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-lc-uuid-')); + await rewritePackagedMacosUuids({ + electronPlatformName: 'win32', + appOutDir: root, + packager: { appInfo: { id: 'com.maka.desktop', productFilename: 'Maka' } }, + }); + assert.equal((await readdir(root)).length, 0, 'nothing may be written for win32'); +}); From 0fa32d6d08870d8ba41a839b306273bcde8ef0af Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 21 Oct 2026 00:07:39 +0800 Subject: [PATCH 2/5] fix(release): register the macOS LC_UUID suite with the release contract lane The CI test planner asserts that every test file named in `check:release` reaches a lane that runs that gate; `scripts/macos-lc-uuid.test.mjs` matched no release-contract path, so the planner failed the job. Add the suite to RELEASE_CONTRACT_FILES like its sibling packaged-app suites. Generated-by: GLM-5.3-Flash (ZCode) --- scripts/ci-test-plan.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 3276c4c4092..4a28e6d531d 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -87,6 +87,7 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/verify-packaged-app.mjs', 'scripts/verify-packaged-app.test.mjs', 'scripts/macos-update-archive.test.mjs', + 'scripts/macos-lc-uuid.test.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', 'scripts/verify-windows-x64.mjs', From 77eeeea14df3fa890e6effdd5e49aa1608151bf2 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Wed, 21 Oct 2026 07:25:10 +0800 Subject: [PATCH 3/5] style(release): format the macOS LC_UUID suite biome format over `scripts/macos-lc-uuid.test.mjs`: three assertions the formatter would reflow. `npm run format:check` is clean on the full tree. Generated-by: GLM-5.3-Flash (ZCode) --- scripts/macos-lc-uuid.test.mjs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/macos-lc-uuid.test.mjs b/scripts/macos-lc-uuid.test.mjs index f036c3e573c..44f6bc6acc2 100644 --- a/scripts/macos-lc-uuid.test.mjs +++ b/scripts/macos-lc-uuid.test.mjs @@ -104,23 +104,26 @@ test('uuidv5 follows RFC 4122', () => { // python.org under the DNS namespace is the reference example every // conforming implementation agrees on (verified against python's uuid5). assert.equal(uuidv5(DNS_NAMESPACE, 'python.org'), '886313e1-3b8a-5372-9b90-0c9aee199e5d'); - assert.match(uuidv5(DNS_NAMESPACE, 'example.org'), /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.match( + uuidv5(DNS_NAMESPACE, 'example.org'), + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); }); test('executable UUIDs are deterministic and distinguish every input', () => { const base = deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64); assert.equal(base, deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64)); - assert.notEqual(base, deterministicExecutableUuid('com.maka.desktop', 'Maka Helper', CPU_TYPE_ARM64)); + assert.notEqual( + base, + deterministicExecutableUuid('com.maka.desktop', 'Maka Helper', CPU_TYPE_ARM64), + ); assert.notEqual(base, deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_X86_64)); assert.notEqual(base, deterministicExecutableUuid('com.other.app', 'Maka', CPU_TYPE_ARM64)); }); test('rewriting a thin Mach-O replaces only the LC_UUID payload', () => { - rewrittenLike( - thinMachO(CPU_TYPE_ARM64, OLD_UUID), - 'com.maka.desktop', - 'Maka', - () => deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64), + rewrittenLike(thinMachO(CPU_TYPE_ARM64, OLD_UUID), 'com.maka.desktop', 'Maka', () => + deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64), ); }); From 465ec366ba976f239dd888542d7f0071509000c4 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Fri, 23 Oct 2026 00:20:54 +0800 Subject: [PATCH 4/5] fix(release): keep native build identity in the macOS LC_UUID rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on 77eeeea14: the replacement UUID took only the app id, executable name and CPU type, so every Electron dist rewritten to the same app advertised one identical build UUID and crash reports could no longer pick the right native image (Apple TN3178). The slice's ORIGINAL UUID now feeds the derivation as the native-build discriminator — two Electron builds stay distinguishable, the same dist stays stable across repackaging — and the afterPack hook writes lc-uuid-map.json next to the bundle mapping every original UUID to its replacement so the published Electron dSYMs still symbolicate the rewritten executables. The header comment now states the actual scope (regular files under Contents/MacOS; frameworks, dylibs and helpers like crashpad keep Electron's UUIDs) and the dSYM trade-off. The suite no longer pre-writes the expected bytes into the rewritten buffer (a no-op rewrite used to pass the thin/fat/idempotence assertions — verified red with a no-op probe), and the idempotence test is honest about its contract: the original UUID feeds the hash, so the stable identity is guaranteed per pristine Electron dist, not across rewrite-of-rewrite. Generated-by: GLM-5.3-Flash (ZCode) --- scripts/macos-lc-uuid.mjs | 72 +++++++++++----- scripts/macos-lc-uuid.test.mjs | 151 ++++++++++++++++++++++++++++----- 2 files changed, 182 insertions(+), 41 deletions(-) diff --git a/scripts/macos-lc-uuid.mjs b/scripts/macos-lc-uuid.mjs index 45860e4d7ff..6284cbe44a2 100644 --- a/scripts/macos-lc-uuid.mjs +++ b/scripts/macos-lc-uuid.mjs @@ -18,8 +18,8 @@ */ /** - * Rewrites the LC_UUID load command of every Mach-O in a packaged macOS app - * bundle (#6026). + * Rewrites the LC_UUID load command of the main and helper executables of a + * packaged macOS app bundle (#6026). * * electron-builder renames Electron's executable files but never relinks them, * so every shipped binary keeps the UUID burned into it by Electron's build: @@ -28,12 +28,22 @@ * LC_UUID, so Maka shares a privacy identity with every other Electron 43 * install and never gets an entry of its own. * + * Scope: regular files under `*.app/Contents/MacOS` — the main executable and + * the helpers, the binaries NECP keys on. `Electron Framework`, the dylibs, + * `chrome_crashpad_handler` and ShipIt keep Electron's own UUIDs; symbolizing + * their crash reports keeps working unchanged. + * * The replacement is UUIDv5 (SHA-1, RFC 4122) over the app id, the executable - * file name and the slice's CPU type — deterministic, so repackaging the same - * inputs yields the same identity, and running the rewrite twice changes - * nothing. It runs from the `afterPack` hook, which is before code signing: - * the signature then covers the rewritten bytes, and notarization sees a - * self-consistent bundle. + * file name, the slice's CPU type and the slice's original UUID. The original + * UUID is the native-build discriminator (Apple TN3178): two Electron builds + * of the same version carry different originals, so they stay distinguishable + * after the rewrite, and repackaging the same Electron dist yields the same + * identity. The trade-off: Electron's published dSYMs no longer match the + * rewritten executables, so the hook writes `lc-uuid-map.json` next to the + * bundle mapping every original UUID to its replacement for crash + * diagnostics. It runs from the `afterPack` hook, which is before code + * signing: the signature then covers the rewritten bytes, and notarization + * sees a self-consistent bundle. */ import { createHash } from 'node:crypto'; @@ -68,6 +78,11 @@ const CPU_TYPE_NAMES = new Map([ [0x0100000c, 'arm64'], ]); +function formatUuid(bytes) { + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + export function uuidv5(namespaceUuid, name) { const digest = createHash('sha1') .update(Buffer.from(namespaceUuid.replaceAll('-', ''), 'hex')) @@ -85,11 +100,16 @@ function cpuTypeName(cpuType) { /** * The deterministic replacement UUID for one executable slice: distinct per - * app, per executable and per architecture, yet stable across rebuilds. + * app, per executable, per architecture and per native build (via the slice's + * original UUID), yet stable across rebuilds of the same Electron dist. */ -export function deterministicExecutableUuid(appId, executableName, cpuType) { - // '/' cannot appear in a file name, so the joined name is unambiguous. - return uuidv5(MAKA_UUID_NAMESPACE, `${appId}/${executableName}/${cpuTypeName(cpuType)}`); +export function deterministicExecutableUuid(appId, executableName, cpuType, originalUuid) { + // '/' cannot appear in a file name or a UUID, so the joined name is + // unambiguous. + return uuidv5( + MAKA_UUID_NAMESPACE, + `${appId}/${executableName}/${cpuTypeName(cpuType)}/${originalUuid}`, + ); } /** @@ -137,15 +157,19 @@ function thinLcUuidLocations(buffer, base) { /** * Replaces every LC_UUID payload in `buffer` in place; the buffer length and - * all other bytes are left exactly as they were. + * all other bytes are left exactly as they were. Returns one record per + * rewritten payload, keyed by CPU type, with the original and replacement + * UUIDs. */ export function rewriteBufferUuids(buffer, appId, executableName) { + const mapping = []; for (const { offset, cpuType } of machoLcUuidLocations(buffer)) { - Buffer.from( - deterministicExecutableUuid(appId, executableName, cpuType).replaceAll('-', ''), - 'hex', - ).copy(buffer, offset); + const original = formatUuid(buffer.subarray(offset, offset + UUID_SIZE)); + const rewritten = deterministicExecutableUuid(appId, executableName, cpuType, original); + Buffer.from(rewritten.replaceAll('-', ''), 'hex').copy(buffer, offset); + mapping.push({ cpuType: cpuTypeName(cpuType), original, rewritten }); } + return mapping; } async function rewriteExecutableFile(path, appId) { @@ -153,8 +177,9 @@ async function rewriteExecutableFile(path, appId) { if (machoLcUuidLocations(buffer).length === 0) { throw new Error(`no LC_UUID load command in packaged executable ${path}`); } - rewriteBufferUuids(buffer, appId, basename(path)); + const mapping = rewriteBufferUuids(buffer, appId, basename(path)); await writeFile(path, buffer); + return mapping; } async function findAppBundleExecutables(directory) { @@ -178,9 +203,10 @@ async function findAppBundleExecutables(directory) { /** * The electron-builder `afterPack` hook: rewrites the main executable and - * every helper executable of the packaged macOS app. Runs before signing, so - * the new UUIDs are covered by the signature. Other platforms have no Mach-O - * to fix and are untouched. + * every helper executable of the packaged macOS app and writes the + * original-to-rewritten UUID map next to the bundle for crash diagnostics. + * Runs before signing, so the new UUIDs are covered by the signature. Other + * platforms have no Mach-O to fix and are untouched. */ export async function rewritePackagedMacosUuids(context) { if (context.electronPlatformName !== 'darwin') return; @@ -189,7 +215,11 @@ export async function rewritePackagedMacosUuids(context) { if (executables.length === 0) { throw new Error(`no .app bundle found under ${context.appOutDir}`); } + const map = {}; for (const executable of executables) { - await rewriteExecutableFile(executable, appId); + map[executable] = await rewriteExecutableFile(executable, appId); } + const mapPath = join(context.appOutDir, 'lc-uuid-map.json'); + await writeFile(mapPath, `${JSON.stringify(map, null, 2)}\n`); + return map; } diff --git a/scripts/macos-lc-uuid.test.mjs b/scripts/macos-lc-uuid.test.mjs index 44f6bc6acc2..21c64ba4aca 100644 --- a/scripts/macos-lc-uuid.test.mjs +++ b/scripts/macos-lc-uuid.test.mjs @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { test } from 'node:test'; import { deterministicExecutableUuid, @@ -83,16 +83,27 @@ function fatMachO(slices) { return buffer; } +/** Formats 16 raw bytes as the canonical dashed lowercase UUID form. */ +function dashedUuid(bytes) { + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + /** Rewrites `buffer` and asserts everything but the UUID payloads survived. */ function rewrittenLike(original, appId, executableName, uuidFor) { const rewritten = Buffer.from(original); const expected = Buffer.from(original); - const locations = machoLcUuidLocations(rewritten); + const locations = machoLcUuidLocations(original); assert.ok(locations.length > 0, 'fixture must carry an LC_UUID'); for (const { offset, cpuType } of locations) { - const uuid = uuidFor(cpuType); - rewritten.write(uuid.replaceAll('-', ''), offset, 'hex'); - expected.write(uuid.replaceAll('-', ''), offset, 'hex'); + // Only `expected` gets the target bytes: pre-writing them into `rewritten` + // would let a no-op rewrite pass the comparison below. The slice's ORIGINAL + // UUID is the native-build discriminator, so it feeds the derivation. + expected.write( + uuidFor(cpuType, dashedUuid(original.subarray(offset, offset + 16))).replaceAll('-', ''), + offset, + 'hex', + ); } rewriteBufferUuids(rewritten, appId, executableName); assert.equal(rewritten.length, original.length, 'file size must not change'); @@ -111,19 +122,44 @@ test('uuidv5 follows RFC 4122', () => { }); test('executable UUIDs are deterministic and distinguish every input', () => { - const base = deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64); - assert.equal(base, deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64)); + const original = OLD_UUID.toString('hex'); + const base = deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64, original); + assert.equal( + base, + deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64, original), + ); + assert.notEqual( + base, + deterministicExecutableUuid('com.maka.desktop', 'Maka Helper', CPU_TYPE_ARM64, original), + ); + assert.notEqual( + base, + deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_X86_64, original), + ); assert.notEqual( base, - deterministicExecutableUuid('com.maka.desktop', 'Maka Helper', CPU_TYPE_ARM64), + deterministicExecutableUuid('com.other.app', 'Maka', CPU_TYPE_ARM64, original), + ); + // Two different Electron builds of the same version stay distinguishable + // (Apple TN3178): their native originals differ, so the replacements do too. + assert.notEqual( + base, + deterministicExecutableUuid( + 'com.maka.desktop', + 'Maka', + CPU_TYPE_ARM64, + 'a16a76f57ae254c59b4b0032949b1d91', + ), ); - assert.notEqual(base, deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_X86_64)); - assert.notEqual(base, deterministicExecutableUuid('com.other.app', 'Maka', CPU_TYPE_ARM64)); }); test('rewriting a thin Mach-O replaces only the LC_UUID payload', () => { - rewrittenLike(thinMachO(CPU_TYPE_ARM64, OLD_UUID), 'com.maka.desktop', 'Maka', () => - deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64), + rewrittenLike( + thinMachO(CPU_TYPE_ARM64, OLD_UUID), + 'com.maka.desktop', + 'Maka', + (_cpuType, original) => + deterministicExecutableUuid('com.maka.desktop', 'Maka', CPU_TYPE_ARM64, original), ); }); @@ -135,7 +171,8 @@ test('rewriting a fat Mach-O gives each architecture its own UUID', () => { ]), 'com.maka.desktop', 'Maka', - (cpuType) => deterministicExecutableUuid('com.maka.desktop', 'Maka', cpuType), + (cpuType, original) => + deterministicExecutableUuid('com.maka.desktop', 'Maka', cpuType, original), ); assert.equal(locations.length, 2); assert.notEqual( @@ -145,12 +182,36 @@ test('rewriting a fat Mach-O gives each architecture its own UUID', () => { ); }); -test('rewriting is idempotent', () => { - const once = thinMachO(CPU_TYPE_ARM64, OLD_UUID); - rewriteBufferUuids(once, 'com.maka.desktop', 'Maka'); - const twice = Buffer.from(once); - rewriteBufferUuids(twice, 'com.maka.desktop', 'Maka'); - assert.deepEqual(twice, once); +test('repackaging the same Electron dist yields the same identity', () => { + // The original UUID feeds the derivation, so feeding a REWRITTEN buffer back + // in derives a different (still stable) identity by design — the hook always + // starts from electron's pristine dist. What must hold: the same original + // always lands on the same replacement. + const first = thinMachO(CPU_TYPE_ARM64, OLD_UUID); + rewriteBufferUuids(first, 'com.maka.desktop', 'Maka'); + const second = thinMachO(CPU_TYPE_ARM64, OLD_UUID); + rewriteBufferUuids(second, 'com.maka.desktop', 'Maka'); + assert.deepEqual(second, first); +}); + +test('rewrites record the original UUID per rewritten slice', () => { + const mapping = rewriteBufferUuids( + thinMachO(CPU_TYPE_ARM64, OLD_UUID), + 'com.maka.desktop', + 'Maka', + ); + assert.deepEqual(mapping, [ + { + cpuType: 'arm64', + original: dashedUuid(OLD_UUID), + rewritten: deterministicExecutableUuid( + 'com.maka.desktop', + 'Maka', + CPU_TYPE_ARM64, + dashedUuid(OLD_UUID), + ), + }, + ]); }); test('the afterPack hook rewrites the app and every helper executable', async () => { @@ -174,7 +235,12 @@ test('the afterPack hook rewrites the app and every helper executable', async () for (const [path, name] of executables) { const bytes = await readFile(path); - const expected = deterministicExecutableUuid('com.maka.desktop', name, CPU_TYPE_ARM64); + const expected = deterministicExecutableUuid( + 'com.maka.desktop', + name, + CPU_TYPE_ARM64, + dashedUuid(OLD_UUID), + ); const [location] = machoLcUuidLocations(bytes); assert.equal( bytes.subarray(location.offset, location.offset + 16).toString('hex'), @@ -184,6 +250,51 @@ test('the afterPack hook rewrites the app and every helper executable', async () } }); +test('the afterPack hook records the rewritten-to-original UUID map', async () => { + // Electron's published dSYMs are keyed by the ORIGINAL build UUID (Apple + // TN3178): without a retained mapping, a rewritten executable can never be + // symbolicated. The map lands NEXT TO the bundle so it stays out of the + // signed/notarized payload. + const root = await mkdtemp(join(tmpdir(), 'maka-lc-uuid-')); + const bundle = join(root, 'Maka.app'); + const helper = join( + bundle, + 'Contents', + 'Frameworks', + 'Maka Helper.app', + 'Contents', + 'MacOS', + 'Maka Helper', + ); + const main = join(bundle, 'Contents', 'MacOS', 'Maka'); + for (const path of [main, helper]) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, thinMachO(CPU_TYPE_ARM64, OLD_UUID)); + } + + const map = await rewritePackagedMacosUuids({ + electronPlatformName: 'darwin', + appOutDir: root, + packager: { appInfo: { id: 'com.maka.desktop', productFilename: 'Maka' } }, + }); + + for (const path of [main, helper]) { + const rewritten = deterministicExecutableUuid( + 'com.maka.desktop', + basename(path), + CPU_TYPE_ARM64, + dashedUuid(OLD_UUID), + ); + assert.deepEqual( + map[path], + [{ cpuType: 'arm64', original: dashedUuid(OLD_UUID), rewritten }], + `${path}'s rewritten UUID must map back to the original build`, + ); + } + const persisted = JSON.parse(await readFile(join(root, 'lc-uuid-map.json'), 'utf8')); + assert.deepEqual(persisted, map, 'the persisted file must match the returned map'); +}); + test('the afterPack hook leaves non-macOS targets alone', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-lc-uuid-')); await rewritePackagedMacosUuids({ From 43ae3cf3baf9898070b3b4386a558f2c17944c67 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Thu, 29 Oct 2026 05:19:42 +0800 Subject: [PATCH 5/5] fix(deps): restore the undici override pin to the upstream value The upstream sync merge eecb655b1 resolved the package.json conflict by keeping the stale branch-side pin, regressing the @jackwener/opencli@1.8.8 undici override from 7.30.1 back to 7.29.1, while package-lock.json kept the upstream 7.30.1 resolution. That left package.json and the lockfile inconsistent (npm ci would fail). Restore the pin to 7.30.1, matching upstream/main (eeeae5128, #6236) byte for byte. The lockfile already resolves 7.30.1 and is byte-identical to the one at the merge's upstream parent, so no lock changes are needed; npm install confirms the tree is in sync. Generated-by: GLM-5.3-Flash (ZCode) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7772aeeccae..c72e1b339a5 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "@hono/node-server": "2.0.12" }, "@jackwener/opencli@1.8.8": { - "undici": "7.29.1" + "undici": "7.30.1" } } }