Repository navigation
fix(desktop): give macOS executables their own LC_UUID #6027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| /* | ||
| * 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. | ||
| */ | ||
|
|
||
| // electron-builder ships Electron's prebuilt executables renamed, not relinked, | ||
| // so every app on the same Electron release carries the same Mach-O LC_UUID. | ||
| // macOS Local Network Privacy (NECP) identifies a program by its main | ||
| // executable's LC_UUID, so a shared UUID lets one app's grant or denial land on | ||
| // another (Apple TN3178, r. 30421029). The packaging hook stamps each executable | ||
| // with a UUID of its own before signing; the release verifier checks the stamp | ||
| // survived. | ||
|
|
||
| import { createHash } from 'node:crypto'; | ||
| import { readFile, readdir, writeFile } from 'node:fs/promises'; | ||
| import { join } from 'node:path'; | ||
|
|
||
| const MH_MAGIC_64 = 0xfeedfacf; | ||
| const FAT_MAGIC = 0xcafebabe; | ||
| const LC_UUID = 0x1b; | ||
| const MACH_HEADER_64_SIZE = 32; | ||
| const FAT_HEADER_SIZE = 8; | ||
| const FAT_ARCH_SIZE = 20; | ||
| const UUID_COMMAND_SIZE = 24; | ||
|
|
||
| // Fixed namespace for the UUIDv5 names below. Changing it changes every | ||
| // shipped UUID, which macOS treats as a different program. | ||
| const MAKA_EXECUTABLE_UUID_NAMESPACE = Buffer.from('4195730630d44e84a842a14db6f67a8f', 'hex'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: this namespace and the |
||
|
|
||
| /** | ||
| * The UUID an executable ships with. Deterministic, so the release verifier can | ||
| * recompute it from the bundle alone; the app id keeps it distinct from every | ||
| * other Electron app and the CPU type keeps the arm64 and x64 builds apart. | ||
| */ | ||
| export function expectedExecutableUuid({ appId, executableName, cputype }) { | ||
| const digest = createHash('sha1') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] Preserve native-build identity, or validate the deliberate loss of it. Two different native payloads/upstream UUIDs currently get the same output UUID for the same app/name/CPU. Apple's TN3178 distinguishes native images for symbol matching. There is no repository crash-upload pipeline, but that does not eliminate OS crash-report or external-symbol costs. I have not reproduced an application failure; this is a follow-up tradeoff, not a P2 regression claim. |
||
| .update(MAKA_EXECUTABLE_UUID_NAMESPACE) | ||
| .update(`${appId}/${executableName}/${cputype}`) | ||
| .digest(); | ||
| const uuid = digest.subarray(0, 16); | ||
| uuid[6] = (uuid[6] & 0x0f) | 0x50; | ||
| uuid[8] = (uuid[8] & 0x3f) | 0x80; | ||
| return formatUuid(uuid); | ||
| } | ||
|
|
||
| function formatUuid(bytes) { | ||
| const hex = bytes.toString('hex').toUpperCase(); | ||
| return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; | ||
| } | ||
|
|
||
| // Each slice as [start, end) within the file; a thin executable is one slice. | ||
| function slices(binary) { | ||
| if (binary.length < 4 || binary.readUInt32BE(0) !== FAT_MAGIC) { | ||
| return [{ start: 0, end: binary.length }]; | ||
| } | ||
| if (binary.length < FAT_HEADER_SIZE) throw new Error('truncated universal header'); | ||
| const count = binary.readUInt32BE(4); | ||
| if (count === 0) throw new Error('universal executable has no slices'); | ||
| if (FAT_HEADER_SIZE + count * FAT_ARCH_SIZE > binary.length) { | ||
| throw new Error('truncated universal header'); | ||
| } | ||
| return Array.from({ length: count }, (_, index) => { | ||
| const entry = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE; | ||
| const start = binary.readUInt32BE(entry + 8); | ||
| const end = start + binary.readUInt32BE(entry + 12); | ||
| if (end > binary.length) throw new Error('universal slice extends past the file'); | ||
| return { start, end }; | ||
| }); | ||
| } | ||
|
|
||
| function uuidCommandOffset(binary, { start, end }) { | ||
| if (end < start + MACH_HEADER_64_SIZE) throw new Error('truncated Mach-O header'); | ||
| if (binary.readUInt32LE(start) !== MH_MAGIC_64) { | ||
| throw new Error('not a 64-bit Mach-O executable'); | ||
| } | ||
| const cputype = binary.readUInt32LE(start + 4); | ||
| const commandCount = binary.readUInt32LE(start + 16); | ||
| const commandsEnd = start + MACH_HEADER_64_SIZE + binary.readUInt32LE(start + 20); | ||
| if (commandsEnd > end) throw new Error('Mach-O load commands extend past the slice'); | ||
| let cursor = start + MACH_HEADER_64_SIZE; | ||
| for (let index = 0; index < commandCount; index++) { | ||
| if (cursor + 8 > commandsEnd) throw new Error('malformed Mach-O load command'); | ||
| const command = binary.readUInt32LE(cursor); | ||
| const size = binary.readUInt32LE(cursor + 4); | ||
| if (size < 8 || cursor + size > commandsEnd) { | ||
| throw new Error('malformed Mach-O load command'); | ||
| } | ||
| if (command === LC_UUID) { | ||
| if (size !== UUID_COMMAND_SIZE) throw new Error('malformed LC_UUID load command'); | ||
| return { cputype, offset: cursor + 8 }; | ||
| } | ||
| cursor += size; | ||
| } | ||
| throw new Error('Mach-O executable has no LC_UUID load command'); | ||
| } | ||
|
|
||
| /** Every slice's CPU type and LC_UUID, in file order. */ | ||
| export function machOUuids(binary) { | ||
| return slices(binary).map((slice) => { | ||
| const { cputype, offset } = uuidCommandOffset(binary, slice); | ||
| return { cputype, uuid: formatUuid(binary.subarray(offset, offset + 16)) }; | ||
| }); | ||
| } | ||
|
|
||
| /** A copy of `binary` with each slice's LC_UUID replaced by its expected UUID. */ | ||
| export function stampMachOUuids(binary, { appId, executableName }) { | ||
| const stamped = Buffer.from(binary); | ||
| for (const slice of slices(stamped)) { | ||
| const { cputype, offset } = uuidCommandOffset(stamped, slice); | ||
| const uuid = expectedExecutableUuid({ appId, executableName, cputype }); | ||
| Buffer.from(uuid.replaceAll('-', ''), 'hex').copy(stamped, offset); | ||
| } | ||
| return stamped; | ||
| } | ||
|
|
||
| /** | ||
| * The app's main executable and each helper app's, read from the bundle rather | ||
| * than from Electron's naming convention: one file in every `Contents/MacOS`. | ||
| */ | ||
| export async function macAppExecutables(appPath) { | ||
| const frameworks = join(appPath, 'Contents', 'Frameworks'); | ||
| const helpers = (await readdir(frameworks)).filter((name) => name.endsWith('.app')).sort(); | ||
| const bundles = [appPath, ...helpers.map((name) => join(frameworks, name))]; | ||
| return Promise.all( | ||
| bundles.map(async (bundle) => { | ||
| const directory = join(bundle, 'Contents', 'MacOS'); | ||
| const entries = await readdir(directory); | ||
| if (entries.length !== 1) { | ||
| throw new Error(`Expected one executable in ${directory}, found ${entries.length}.`); | ||
| } | ||
| return { name: entries[0], path: join(directory, entries[0]) }; | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| export async function stampMacAppExecutableUuids(appPath, appId) { | ||
| for (const { name, path } of await macAppExecutables(appPath)) { | ||
| await writeFile(path, stampMachOUuids(await readFile(path), { appId, executableName: name })); | ||
| } | ||
| } | ||
|
|
||
| export async function assertMacAppExecutableUuids(appPath, appId) { | ||
| const executables = await macAppExecutables(appPath); | ||
| if (executables.length < 2) { | ||
| throw new Error(`Expected Maka and its helper executables in ${appPath}.`); | ||
| } | ||
| for (const { name, path } of executables) { | ||
| for (const { cputype, uuid } of machOUuids(await readFile(path))) { | ||
| const expected = expectedExecutableUuid({ appId, executableName: name, cputype }); | ||
| if (uuid !== expected) { | ||
| throw new Error( | ||
| `${name} ships LC_UUID ${uuid}, expected ${expected}; the packaging hook did not stamp it.`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3 note: I verified that in electron-builder 26.17.0 the config
afterPackruns before fuses anddoSignAfterPack, andmac.forceCodeSigning: trueprevents an unsigned (linker-signature-invalidated) arm64 build. PR CI never runs this hook on macOS, though. The first real run is the post-merge signed Desktop Nightly (macos-15 / macos-15-intel), so please watch that run.