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:
parent
7017c4d68c
commit
650237efb0
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue