From 4cd8c2c2377458fa01b8f8eb92161c9d6d8211e8 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 17:07:38 -0800 Subject: [PATCH 1/8] persist: re-sign mac app bundle --- packages/core/src/darwin.ts | 96 ++++++++++++++++++++++++++++++++++ packages/core/src/persist.ts | 45 ++++++++++++++-- packages/injector/src/index.ts | 2 +- 3 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/darwin.ts diff --git a/packages/core/src/darwin.ts b/packages/core/src/darwin.ts new file mode 100644 index 00000000..e1fc87de --- /dev/null +++ b/packages/core/src/darwin.ts @@ -0,0 +1,96 @@ +import { spawn } from "node:child_process"; +import Logger from "./util/logger"; + +/** Flags that may be passed to `codesign(1)` regardless of action. */ +export interface SharedCodesignOptions { + verbosityLevel?: number; +} + +/** Flags that may be passed to `codesign(1)` when signing a bundle. */ +export interface SigningOptions extends SharedCodesignOptions { + /** + * Sign nested code items (such as Helper (Renderer).app, Helper (GPU).app, etc.) + * + * `--deep` is technically Bad (https://forums.developer.apple.com/forums/thread/129980), + * but it works for now. + */ + deep?: boolean; + + /** Steamrolls any existing code signature present in the bundle. */ + force?: boolean; + + /** + * The name of the signing identity to use. Signing identities are automatically queried from the user's keychains. + * + * `-` specifies ad-hoc signing, which does not involve an identity at all + * and is used to sign exactly one instance of code. + */ + identity: "-" | (string & {}); // "& {}" prevents TS from unifying the literal. +} + +const logger = new Logger("core/darwin"); + +async function invokeCodesign(commandLineOptions: string[]) { + logger.debug("Invoking codesign with args:", commandLineOptions); + const codesignChild = spawn("/usr/bin/codesign", commandLineOptions, { stdio: "pipe" }); + + codesignChild.on("spawn", () => { + logger.debug(`Spawned codesign (pid: ${codesignChild.pid})`); + }); + codesignChild.stdout.on("data", (data) => { + logger.debug("codesign stdout:", data.toString()); + }); + codesignChild.stderr.on("data", (data) => { + logger.debug("codesign stderr:", data.toString()); + }); + + await new Promise((resolve, reject) => { + codesignChild.on("exit", (code, signal) => { + if (signal == null && code === 0) { + logger.debug("codesign peacefully exited"); + resolve(); + } else { + const reason = code != null ? `code ${code}` : `signal ${signal}`; + reject(`codesign exited with ${reason}`); + } + }); + }); +} + +function* generateSharedCommandLineOptions(options: SharedCodesignOptions): IterableIterator { + if (options.verbosityLevel) yield "-" + "v".repeat(options.verbosityLevel); +} + +export function sign(bundlePath: string, options: SigningOptions): Promise { + // codesign -s [-v] [--deep] [--force] + function* cliOptions(): IterableIterator { + yield "-s"; + yield options.identity; + + yield* generateSharedCommandLineOptions(options); + if (options.deep) yield "--deep"; + if (options.force) yield "--force"; + + yield bundlePath; + } + + return invokeCodesign(Array.from(cliOptions())); +} + +export function verify(bundlePath: string, options: SharedCodesignOptions = {}): Promise { + // codesign --verify [-v] + function* cliOptions(): IterableIterator { + yield "--verify"; + yield* generateSharedCommandLineOptions(options); + yield bundlePath; + } + + return invokeCodesign(Array.from(cliOptions())).then( + () => true, + + // we're conflating codesign(1) itself erroring and codesign(1) + // successfully returning that the bundle is invalid, because it'd exit in + // an error in either case, but that's probably OK + () => false + ); +} diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index 0384192f..6d7e14e1 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -1,20 +1,55 @@ -import { join, dirname } from "node:path"; +import { resolve, join, dirname } from "node:path"; import { mkdirSync, renameSync, existsSync, copyFileSync, readdirSync } from "node:fs"; import Logger from "./util/logger"; +import * as darwin from "./darwin"; const logger = new Logger("core/persist"); -export default function persist(asarPath: string) { +export default async function persist(asarPath: string) { try { - if (process.platform === "win32") { - persistWin32(asarPath); + persistAsar(asarPath); + + if (process.platform === "darwin") { + // the asar is at Discord.app/Contents/Resources/app.asar + const inferredBundlePath = resolve(join(dirname(asarPath), "..", "..")); + await postPersistSign(inferredBundlePath); } } catch (e) { logger.error(`Failed to persist moonlight: ${e}`); } } -function persistWin32(asarPath: string) { +async function postPersistSign(bundlePath: string) { + if (process.platform !== "darwin") { + logger.error("Ignoring call to postPersistSign because we're not on Darwin"); + return; + } + + logger.debug("Inferred bundle path:", bundlePath); + + if (await darwin.verify(bundlePath, { verbosityLevel: 3 })) { + logger.warn("Bundle is currently passing code signing, no need to sign"); + return; + } else { + logger.debug("Bundle no longer passes code signing (this is expected)"); + } + + await darwin.sign(bundlePath, { + deep: true, + force: true, + // TODO: let this be configurable + identity: "Moonlight", + verbosityLevel: 3 + }); + + if (await darwin.verify(bundlePath, { verbosityLevel: 3 })) { + logger.info("Bundle signed succesfully!"); + } else { + logger.error("Bundle didn't pass code signing even after signing, the app might be broken now :("); + } +} + +function persistAsar(asarPath: string) { const updaterModule = require(join(asarPath, "common", "updater")); const updater = updaterModule.Updater; diff --git a/packages/injector/src/index.ts b/packages/injector/src/index.ts index 4c0ab98d..5917bb0a 100644 --- a/packages/injector/src/index.ts +++ b/packages/injector/src/index.ts @@ -268,7 +268,7 @@ export async function inject(asarPath: string) { if (isMoonlightDesktop) return; if (!hasOpenAsar && !isMoonlightDesktop) { - persist(asarPath); + await persist(asarPath); } // Need to do this instead of require() or it breaks require.main From 72a7af6ee3a0384653d30ed0e11218601953837b Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 17:38:03 -0800 Subject: [PATCH 2/8] darwin: add module comment --- packages/core/src/darwin.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/darwin.ts b/packages/core/src/darwin.ts index e1fc87de..7ba51ffa 100644 --- a/packages/core/src/darwin.ts +++ b/packages/core/src/darwin.ts @@ -1,3 +1,6 @@ +// Helper functions that wrap shelling out to codesign(1). This is only relevant +// for Darwin (macOS). + import { spawn } from "node:child_process"; import Logger from "./util/logger"; From 049b128575b6d143cc2d8663c93145ce4bd7d992 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 17:39:21 -0800 Subject: [PATCH 3/8] darwin: lowercase cert name --- packages/core/src/persist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index 6d7e14e1..1326688f 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -38,7 +38,7 @@ async function postPersistSign(bundlePath: string) { deep: true, force: true, // TODO: let this be configurable - identity: "Moonlight", + identity: "moonlight", verbosityLevel: 3 }); From 80e1bafd7dd520dca836acbccfe157ef18a322ee Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 17:40:00 -0800 Subject: [PATCH 4/8] darwin: move to `core/src/util` --- packages/core/src/persist.ts | 2 +- packages/core/src/{ => util}/darwin.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/core/src/{ => util}/darwin.ts (98%) diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index 1326688f..c480b674 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -1,7 +1,7 @@ import { resolve, join, dirname } from "node:path"; import { mkdirSync, renameSync, existsSync, copyFileSync, readdirSync } from "node:fs"; import Logger from "./util/logger"; -import * as darwin from "./darwin"; +import * as darwin from "./util/darwin"; const logger = new Logger("core/persist"); diff --git a/packages/core/src/darwin.ts b/packages/core/src/util/darwin.ts similarity index 98% rename from packages/core/src/darwin.ts rename to packages/core/src/util/darwin.ts index 7ba51ffa..1e39e1d1 100644 --- a/packages/core/src/darwin.ts +++ b/packages/core/src/util/darwin.ts @@ -2,7 +2,7 @@ // for Darwin (macOS). import { spawn } from "node:child_process"; -import Logger from "./util/logger"; +import Logger from "./logger"; /** Flags that may be passed to `codesign(1)` regardless of action. */ export interface SharedCodesignOptions { From 8025fae7af44a370924ef0955460195e687fa558 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 17:41:42 -0800 Subject: [PATCH 5/8] persist: rename `persistAsar` to `hookUpdaterForPersistence` --- packages/core/src/persist.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index c480b674..f7c03a28 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -7,7 +7,7 @@ const logger = new Logger("core/persist"); export default async function persist(asarPath: string) { try { - persistAsar(asarPath); + hookUpdaterForPersistence(asarPath); if (process.platform === "darwin") { // the asar is at Discord.app/Contents/Resources/app.asar @@ -49,7 +49,7 @@ async function postPersistSign(bundlePath: string) { } } -function persistAsar(asarPath: string) { +function hookUpdaterForPersistence(asarPath: string) { const updaterModule = require(join(asarPath, "common", "updater")); const updater = updaterModule.Updater; From 629be24c145393a77bba28182a8f646f8293ea91 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 17:49:46 -0800 Subject: [PATCH 6/8] darwin: make codesigning utils sync, properly hook them I missed that hookUpdaterForPersistence (FKA persistAsar) merely hooks into the updater, so we need to run it inside of the hooked emit method. Those are synchronous, so our shelling out to codesign(1) needs to be synchronous, too. This blocks the updater. Not sure if this can somehow be async instead. --- packages/core/src/persist.ts | 22 ++++++----- packages/core/src/util/darwin.ts | 63 +++++++++++++------------------- 2 files changed, 37 insertions(+), 48 deletions(-) diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index f7c03a28..5b7845f3 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -8,18 +8,12 @@ const logger = new Logger("core/persist"); export default async function persist(asarPath: string) { try { hookUpdaterForPersistence(asarPath); - - if (process.platform === "darwin") { - // the asar is at Discord.app/Contents/Resources/app.asar - const inferredBundlePath = resolve(join(dirname(asarPath), "..", "..")); - await postPersistSign(inferredBundlePath); - } } catch (e) { logger.error(`Failed to persist moonlight: ${e}`); } } -async function postPersistSign(bundlePath: string) { +function postPersistSign(bundlePath: string) { if (process.platform !== "darwin") { logger.error("Ignoring call to postPersistSign because we're not on Darwin"); return; @@ -27,14 +21,14 @@ async function postPersistSign(bundlePath: string) { logger.debug("Inferred bundle path:", bundlePath); - if (await darwin.verify(bundlePath, { verbosityLevel: 3 })) { + if (darwin.verifySync(bundlePath, { verbosityLevel: 3 })) { logger.warn("Bundle is currently passing code signing, no need to sign"); return; } else { logger.debug("Bundle no longer passes code signing (this is expected)"); } - await darwin.sign(bundlePath, { + darwin.signSync(bundlePath, { deep: true, force: true, // TODO: let this be configurable @@ -42,7 +36,7 @@ async function postPersistSign(bundlePath: string) { verbosityLevel: 3 }); - if (await darwin.verify(bundlePath, { verbosityLevel: 3 })) { + if (darwin.verifySync(bundlePath, { verbosityLevel: 3 })) { logger.info("Bundle signed succesfully!"); } else { logger.error("Bundle didn't pass code signing even after signing, the app might be broken now :("); @@ -77,6 +71,14 @@ function hookUpdaterForPersistence(asarPath: string) { for (const file of readdirSync(currentAppDir)) { copyFileSync(join(currentAppDir, file), join(newAppDir, file)); } + + // on darwin, making changes disrupted the code signature on the app + // bundle, so we need to re-sign + if (process.platform === "darwin") { + // the asar is at Discord.app/Contents/Resources/app.asar + const inferredBundlePath = resolve(join(dirname(asarPath), "..", "..")); + postPersistSign(inferredBundlePath); + } } return realEmit.call(this, event, ...args); diff --git a/packages/core/src/util/darwin.ts b/packages/core/src/util/darwin.ts index 1e39e1d1..be71d8a0 100644 --- a/packages/core/src/util/darwin.ts +++ b/packages/core/src/util/darwin.ts @@ -1,7 +1,8 @@ -// Helper functions that wrap shelling out to codesign(1). This is only relevant -// for Darwin (macOS). +// Helper functions that wrap shelling out to codesign(1). This is only +// relevant for Darwin (macOS). These are synchronous because they need to +// block the updater. -import { spawn } from "node:child_process"; +import { spawnSync } from "node:child_process"; import Logger from "./logger"; /** Flags that may be passed to `codesign(1)` regardless of action. */ @@ -33,38 +34,26 @@ export interface SigningOptions extends SharedCodesignOptions { const logger = new Logger("core/darwin"); -async function invokeCodesign(commandLineOptions: string[]) { +function codesignSync(commandLineOptions: string[]) { logger.debug("Invoking codesign with args:", commandLineOptions); - const codesignChild = spawn("/usr/bin/codesign", commandLineOptions, { stdio: "pipe" }); - - codesignChild.on("spawn", () => { - logger.debug(`Spawned codesign (pid: ${codesignChild.pid})`); - }); - codesignChild.stdout.on("data", (data) => { - logger.debug("codesign stdout:", data.toString()); - }); - codesignChild.stderr.on("data", (data) => { - logger.debug("codesign stderr:", data.toString()); - }); - - await new Promise((resolve, reject) => { - codesignChild.on("exit", (code, signal) => { - if (signal == null && code === 0) { - logger.debug("codesign peacefully exited"); - resolve(); - } else { - const reason = code != null ? `code ${code}` : `signal ${signal}`; - reject(`codesign exited with ${reason}`); - } - }); - }); + const result = spawnSync("/usr/bin/codesign", commandLineOptions, { stdio: "pipe" }); + + if (result.stdout) logger.debug("codesign stdout:", result.stdout); + if (result.stderr) logger.debug("codesign stderr:", result.stderr); + + if (result.signal == null && result.status === 0) { + logger.debug("codesign peacefully exited"); + } else { + const reason = result.status != null ? `code ${result.status}` : `signal ${result.signal}`; + throw new Error(`codesign exited with ${reason}`); + } } function* generateSharedCommandLineOptions(options: SharedCodesignOptions): IterableIterator { if (options.verbosityLevel) yield "-" + "v".repeat(options.verbosityLevel); } -export function sign(bundlePath: string, options: SigningOptions): Promise { +export function signSync(bundlePath: string, options: SigningOptions): void { // codesign -s [-v] [--deep] [--force] function* cliOptions(): IterableIterator { yield "-s"; @@ -77,10 +66,10 @@ export function sign(bundlePath: string, options: SigningOptions): Promise yield bundlePath; } - return invokeCodesign(Array.from(cliOptions())); + codesignSync(Array.from(cliOptions())); } -export function verify(bundlePath: string, options: SharedCodesignOptions = {}): Promise { +export function verifySync(bundlePath: string, options: SharedCodesignOptions = {}): boolean { // codesign --verify [-v] function* cliOptions(): IterableIterator { yield "--verify"; @@ -88,12 +77,10 @@ export function verify(bundlePath: string, options: SharedCodesignOptions = {}): yield bundlePath; } - return invokeCodesign(Array.from(cliOptions())).then( - () => true, - - // we're conflating codesign(1) itself erroring and codesign(1) - // successfully returning that the bundle is invalid, because it'd exit in - // an error in either case, but that's probably OK - () => false - ); + try { + codesignSync(Array.from(cliOptions())); + return true; + } catch { + return false; + } } From da5646e5dd52b0508f4e999d0d756bb67a1a79b8 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 18:03:00 -0800 Subject: [PATCH 7/8] injector: remove straggling `await` --- packages/injector/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/injector/src/index.ts b/packages/injector/src/index.ts index 5917bb0a..4c0ab98d 100644 --- a/packages/injector/src/index.ts +++ b/packages/injector/src/index.ts @@ -268,7 +268,7 @@ export async function inject(asarPath: string) { if (isMoonlightDesktop) return; if (!hasOpenAsar && !isMoonlightDesktop) { - await persist(asarPath); + persist(asarPath); } // Need to do this instead of require() or it breaks require.main From 4d86b935b87c3ac33776ec4a22710e2876d09332 Mon Sep 17 00:00:00 2001 From: Skip R Date: Sun, 15 Dec 2024 18:24:41 -0800 Subject: [PATCH 8/8] persist: make persist actually sync --- packages/core/src/persist.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/persist.ts b/packages/core/src/persist.ts index 5b7845f3..106fa859 100644 --- a/packages/core/src/persist.ts +++ b/packages/core/src/persist.ts @@ -5,7 +5,7 @@ import * as darwin from "./util/darwin"; const logger = new Logger("core/persist"); -export default async function persist(asarPath: string) { +export default function persist(asarPath: string) { try { hookUpdaterForPersistence(asarPath); } catch (e) {