diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index d75959fd5..7896208c5 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -2,12 +2,16 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { installMacHopper, type MacHopperInstallHost } from "./MacHopper.js"; +import { + installMacHopper, + type MacHopperBundleVerification, + type MacHopperInstallHost, +} from "./MacHopper.js"; class FakeMacHopperHost implements MacHopperInstallHost { readonly archive = new TextEncoder().encode("hopper-dmg"); archiveOk = true; - valid = true; + verification: MacHopperBundleVerification = "verified"; existing = false; mountSucceeds = true; copySucceeds = true; @@ -15,6 +19,7 @@ class FakeMacHopperHost implements MacHopperInstallHost { badHash = false; unmounts = 0; cleanups = 0; + installs = 0; opens = 0; replaces = false; @@ -47,7 +52,7 @@ class FakeMacHopperHost implements MacHopperInstallHost { return Promise.resolve(); }; appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app"); - validBundle = () => Promise.resolve(this.valid); + validBundle = () => Promise.resolve(this.verification); destinationExists = () => Promise.resolve(this.existing); installBundle = ( _source: string, @@ -55,6 +60,7 @@ class FakeMacHopperHost implements MacHopperInstallHost { _stage: string, replaceExisting: boolean, ) => { + this.installs += 1; this.replaces = replaceExisting; return Promise.resolve(this.copySucceeds); }; @@ -115,7 +121,7 @@ describe("macOS Hopper installation", () => { it("cleans and detaches after bundle validation fails", async () => { const host = new FakeMacHopperHost(); - host.valid = false; + host.verification = "invalid_signature"; await expect(installMacHopper({}, host)).resolves.toEqual({ status: "failed", reason: "bundle", @@ -123,4 +129,17 @@ describe("macOS Hopper installation", () => { expect(host.unmounts).toBe(1); expect(host.cleanups).toBe(1); }); + + it("reports a publisher mismatch without copying or launching the bundle", async () => { + const host = new FakeMacHopperHost(); + host.verification = "unverified_publisher"; + await expect(installMacHopper({}, host)).resolves.toEqual({ + status: "failed", + reason: "publisher_verification_failed", + }); + expect(host.installs).toBe(0); + expect(host.opens).toBe(0); + expect(host.unmounts).toBe(1); + expect(host.cleanups).toBe(1); + }); }); diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index 31ca522f4..a211e9f2d 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -24,7 +24,35 @@ const execFileAsync = promisify(execFile); const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; -const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4"; + +/** + * Apple publisher identity pinned for the official Hopper macOS bundle. The + * team identifier was captured from `Hopper-6.6.0-demo.dmg` at + * `https://www.hopperapp.com:443/downloader/public/Hopper-6.6.0-demo.dmg` + * (34846271 bytes, SHA-1 0be530f038249742590992199c7b042ea20ca955) with + * `codesign -dvvv`, which reported + * `Authority=Developer ID Application: Cryptic Apps (2AMA2753NF)`. + */ +export const HOPPER_PUBLISHER = Object.freeze({ + bundleIdentifier: "com.cryptic-apps.hopper-web-4", + teamIdentifier: "2AMA2753NF", +}); + +/** + * Code requirement enforcing the bundle identifier and signing team together. + * The identifier is read from the sealed CodeDirectory, so it cannot be changed + * by editing `Info.plist` the way the previous string comparison could. + */ +const HOPPER_PUBLISHER_REQUIREMENT = + `= identifier "${HOPPER_PUBLISHER.bundleIdentifier}"` + + ` and anchor apple generic` + + ` and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"`; + +/** Outcome of reading a mounted bundle's signature and publisher identity. */ +export type MacHopperBundleVerification = + | "verified" + | "invalid_signature" + | "unverified_publisher"; /** Stable network and command effects disclosed before macOS Hopper setup. */ export const macHopperInstallDisclosure = { @@ -32,6 +60,7 @@ export const macHopperInstallDisclosure = { commands: [ "hdiutil attach -readonly -nobrowse -mountpoint ", "codesign --verify --deep --strict ", + `codesign --verify --strict '-R${HOPPER_PUBLISHER_REQUIREMENT}' `, "ditto ", "open ", ] as const, @@ -52,6 +81,7 @@ export type MacHopperInstallResult = | "integrity" | "mount" | "bundle" + | "publisher_verification_failed" | "destination_exists" | "copy" | "launcher_missing"; @@ -69,7 +99,7 @@ export interface MacHopperInstallHost { mount(packagePath: string, mountPath: string): Promise; unmount(mountPath: string): Promise; appBundle(mountPath: string): Promise; - validBundle(appPath: string): Promise; + validBundle(appPath: string): Promise; destinationExists(path: string): Promise; installBundle( source: string, @@ -124,8 +154,12 @@ export const installMacHopper = async ( return { status: "failed", reason: "mount" }; mounted = true; const source = await host.appBundle(mountPath); - if (source === undefined || !(await host.validBundle(source))) + if (source === undefined) return { status: "failed", reason: "bundle" }; + const verification = await host.validBundle(source); + if (verification === "invalid_signature") return { status: "failed", reason: "bundle" }; + if (verification === "unverified_publisher") + return { status: "failed", reason: "publisher_verification_failed" }; const stage = `${destination}.rea-stage`; if ( !(await host.installBundle( @@ -222,25 +256,21 @@ export const systemMacHopperInstallHost = ( environment, )) ) - return false; - try { - const identifier = ( - await execFileAsync( - "/usr/libexec/PlistBuddy", - [ - "-c", - "Print :CFBundleIdentifier", - join(appPath, "Contents/Info.plist"), - ], - { env: environment }, - ) - ).stdout.trim(); - return identifier === BUNDLE_IDENTIFIER; - } catch (cause: unknown) { - // best-effort cleanup: optional bundle probing; unreadable means invalid. - void cause; - return false; - } + return "invalid_signature"; + if ( + !(await commandSucceeds( + "codesign", + [ + "--verify", + "--strict", + `-R${HOPPER_PUBLISHER_REQUIREMENT}`, + appPath, + ], + environment, + )) + ) + return "unverified_publisher"; + return "verified"; }, async destinationExists(path) { try { diff --git a/src/application/SetupInstallFailure.test.ts b/src/application/SetupInstallFailure.test.ts new file mode 100644 index 000000000..980e22151 --- /dev/null +++ b/src/application/SetupInstallFailure.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { HOPPER_PUBLISHER } from "./MacHopper.js"; +import { setupInstallFailure } from "./SetupInstallFailure.js"; + +describe("macOS Hopper setup failure projection", () => { + it("projects a publisher mismatch onto its own code and recovery", () => { + expect(setupInstallFailure("publisher_verification_failed")).toEqual({ + status: "failed", + code: "publisher_verification_failed", + remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, + }); + }); + + it("keeps a broken bundle distinct from a publisher mismatch", () => { + expect(setupInstallFailure("bundle")).toEqual({ + status: "failed", + code: "bundle_invalid", + remediation: + "The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.", + }); + }); +}); diff --git a/src/application/SetupInstallFailure.ts b/src/application/SetupInstallFailure.ts index fe2a2739e..0b332fa25 100644 --- a/src/application/SetupInstallFailure.ts +++ b/src/application/SetupInstallFailure.ts @@ -1,3 +1,5 @@ +import { HOPPER_PUBLISHER } from "./MacHopper.js"; + export type SetupFailureCode = | "unsupported_host" | "download_failed" @@ -10,6 +12,7 @@ export type SetupFailureCode = | "destination_exists" | "mount_failed" | "bundle_invalid" + | "publisher_verification_failed" | "copy_failed" | "skill_install_failed" | "cleanup_failed"; @@ -39,6 +42,7 @@ export type HopperInstallFailureReason = | "destination_exists" | "mount" | "bundle" + | "publisher_verification_failed" | "copy"; /** Map provider installer failures to stable setup codes and safe recovery. */ @@ -115,6 +119,12 @@ export const setupInstallFailure = ( remediation: "The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.", }; + case "publisher_verification_failed": + return { + status: "failed", + code: "publisher_verification_failed", + remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, + }; case "copy": return { status: "failed", diff --git a/tests/boundary/setup/macosHopperPublisherVerification.test.ts b/tests/boundary/setup/macosHopperPublisherVerification.test.ts new file mode 100644 index 000000000..29b696e85 --- /dev/null +++ b/tests/boundary/setup/macosHopperPublisherVerification.test.ts @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process"; +import { appendFile, copyFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect, it } from "vitest"; + +import { + HOPPER_PUBLISHER, + macHopperInstallDisclosure, + systemMacHopperInstallHost, +} from "../../../src/application/MacHopper.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const run = promisify(execFile); +const itDarwin = it.skipIf(process.platform !== "darwin"); +const executableSegments = ["Contents", "MacOS", "hopper"]; + +const infoPlist = (identifier: string): string => + ` + + + + CFBundleIdentifier + ${identifier} + CFBundleExecutable + hopper + CFBundlePackageType + APPL + + +`; + +/** Build a minimal `.app` whose launcher is a real signed Mach-O copy. */ +const buildBundle = async ( + root: string, + identifier: string, + options: { readonly sign?: boolean } = {}, +): Promise => { + const app = join(root, "Hopper Disassembler.app"); + const executable = join(app, ...executableSegments); + await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); + await copyFile("/usr/bin/true", executable); + await writeFile(join(app, "Contents", "Info.plist"), infoPlist(identifier)); + if (options.sign !== false) + await run("/usr/bin/codesign", [ + "--sign", + "-", + "--force", + "--deep", + "--identifier", + identifier, + app, + ]); + return app; +}; + +const host = () => + systemMacHopperInstallHost("/home/rea-test", { ...process.env }); + +describe("macOS Hopper publisher verification", () => { + itDarwin( + "rejects an ad-hoc signed bundle carrying the pinned bundle identifier", + async () => { + const root = await createTestTempDirectory("rea-hopper-publisher-"); + const app = await buildBundle(root, HOPPER_PUBLISHER.bundleIdentifier); + // The pre-pinned checks accept this bundle: `codesign --verify` succeeds + // for ad-hoc signatures and the identifier clause alone also matches. + await run("/usr/bin/codesign", ["--verify", "--deep", "--strict", app]); + await expect(host().validBundle(app)).resolves.toBe( + "unverified_publisher", + ); + }, + ); + + itDarwin("rejects an unsigned bundle before any copy", async () => { + const root = await createTestTempDirectory("rea-hopper-unsigned-"); + const app = await buildBundle(root, HOPPER_PUBLISHER.bundleIdentifier, { + sign: false, + }); + await expect(host().validBundle(app)).resolves.toBe("invalid_signature"); + }); + + itDarwin("rejects a bundle modified after it was signed", async () => { + const root = await createTestTempDirectory("rea-hopper-tampered-"); + const app = await buildBundle(root, HOPPER_PUBLISHER.bundleIdentifier); + await appendFile(join(app, ...executableSegments), "appended\n"); + await expect(host().validBundle(app)).resolves.toBe("invalid_signature"); + }); + + it("discloses the exact code requirement the installer enforces", () => { + expect(macHopperInstallDisclosure.commands).toContain( + `codesign --verify --strict '-R= identifier "${HOPPER_PUBLISHER.bundleIdentifier}" and anchor apple generic and certificate leaf[subject.OU] = "${HOPPER_PUBLISHER.teamIdentifier}"' `, + ); + }); + + // Opt-in positive control: point this at a mounted official Hopper bundle, + // for example `hdiutil attach -readonly -nobrowse Hopper-6.6.0-demo.dmg`. + // It proves the pinned requirement is satisfiable by the real publisher, so + // a quoting or argument mistake cannot hide behind the rejection arms. + const realApp = process.env.REA_HOPPER_MACOS_APP; + it.skipIf(realApp === undefined)( + "accepts the officially signed Hopper application", + async () => { + await expect(host().validBundle(realApp ?? "")).resolves.toBe("verified"); + }, + ); +});