fix(setup): pin Hopper's macOS publisher identity before install (#2481)

The macOS installer accepted any bundle that passed `codesign --verify`
and whose `Info.plist` said `com.cryptic-apps.hopper-web-4`. An ad-hoc
signed bundle with that identifier satisfies both, so a substituted DMG
could be copied to ~/Applications and launched.

Verify a pinned code requirement instead, covering the sealed bundle
identifier and the signing team together, and report a distinct
`publisher_verification_failed` code so a vendor key change is not
confused with a corrupt package.
This commit is contained in:
The one -Niu 2026-10-13 14:40:04 +08:00 committed by GitHub
parent 7017c4d68c
commit 650237efb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 216 additions and 26 deletions

View File

@ -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);
});
});

View File

@ -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 <private-mount> <verified-hopper.dmg>",
"codesign --verify --deep --strict <mounted-hopper.app>",
`codesign --verify --strict '-R${HOPPER_PUBLISHER_REQUIREMENT}' <mounted-hopper.app>`,
"ditto <mounted-hopper.app> <user-Applications-stage>",
"open <installed-hopper.app>",
] 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<boolean>;
unmount(mountPath: string): Promise<void>;
appBundle(mountPath: string): Promise<string | undefined>;
validBundle(appPath: string): Promise<boolean>;
validBundle(appPath: string): Promise<MacHopperBundleVerification>;
destinationExists(path: string): Promise<boolean>;
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 {

View File

@ -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.",
});
});
});

View File

@ -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",

View File

@ -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 =>
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleExecutable</key>
<string>hopper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
`;
/** 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<string> => {
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}"' <mounted-hopper.app>`,
);
});
// 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");
},
);
});