[PR #2481] [MERGED] fix(setup): pin Hopper's macOS publisher identity before install #5263

Closed
opened 2026-10-31 01:05:16 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/2481
Author: @NIU-123370
Created: 10/13/2026
Status: ✅ Merged
Merged: 10/13/2026
Merged by: @morluto

Base: main ← Head: fix/hopper-macos-publisher-pin


📝 Commits (1)

  • 42cb976 fix(setup): pin Hopper's macOS publisher identity before install

📊 Changes

5 files changed (+216 additions, -26 deletions)

View changed files

📝 src/application/MacHopper.test.ts (+23 -4)
📝 src/application/MacHopper.ts (+52 -22)
➕ src/application/SetupInstallFailure.test.ts (+23 -0)
📝 src/application/SetupInstallFailure.ts (+10 -0)
➕ tests/boundary/setup/macosHopperPublisherVerification.test.ts (+108 -0)

📄 Description

Summary

rea setup --install-hopper on macOS accepted any bundle that passed codesign --verify --deep --strict and whose Info.plist said com.cryptic-apps.hopper-web-4. An ad-hoc signed bundle carrying that identifier satisfies both checks, so a substituted DMG would be copied into ~/Applications and launched with open.

This implements the option you specified in the review note on #1681: verify a pinned code requirement covering the sealed bundle identifier and the signing team together, and report a distinct failure code so a vendor key change is not confused with a corrupt package.

Refs #1681

What changed

  • src/application/MacHopper.ts
    • One owned publisher record HOPPER_PUBLISHER (bundle identifier + Apple team), and a code requirement built only from it:
      = identifier "com.cryptic-apps.hopper-web-4" and anchor apple generic and certificate leaf[subject.OU] = "2AMA2753NF"
    • validBundle now returns a three-state verdict (verified / invalid_signature / unverified_publisher) instead of a boolean, so a broken signature and a publisher mismatch stay distinguishable.
    • The CFBundleIdentifier string read through PlistBuddy is gone. The identifier is now asserted against the sealed CodeDirectory, which an attacker cannot change by editing Info.plist.
    • macHopperInstallDisclosure.commands gains the exact requirement, so setup --dry-run shows what will be enforced.
    • New failure reason publisher_verification_failed, still returned before any copy or launch.
  • src/application/SetupInstallFailure.ts — projects that reason onto its own publisher_verification_failed code and a recovery that names the pinned identity and HOPPER_LAUNCHER_PATH. SetupHost.ts needed no change; it passes the reason through.

How the team identifier was captured

Not taken on faith and not guessed. Reproducible:

  1. curl -A rea-installer https://www.hopperapp.com/include/files-api.php?request=releases&public=true → OS X release 6.6.0, filename = .../Hopper-6.6.0-demo.dmg, file_length 34846271, file_hash 0be530f038249742590992199c7b042ea20ca955.
  2. Download the DMG: 34846271 bytes, shasum -a 1 = 0be530f038249742590992199c7b042ea20ca955 (matches the vendor metadata byte for byte).
  3. hdiutil attach -readonly -nobrowse then codesign -dvvv "Hopper Disassembler.app" →
    Authority=Developer ID Application: Cryptic Apps (2AMA2753NF), TeamIdentifier=2AMA2753NF, flags=0x10000(runtime).

The capture command and artifact identity are recorded in the comment above the constant.

Verification (all run in this session)

Reproducing the gap first, on a locally built bundle: an ad-hoc signed Hopper Disassembler.app with CFBundleIdentifier = com.cryptic-apps.hopper-web-4 reports flags=0x2(adhoc) and TeamIdentifier=not set, and passes codesign --verify --deep --strict plus the old identifier comparison. The same bundle fails the new requirement with test-requirement: code failed to satisfy specified code requirement(s) (exit 3).

Positive control, so a quoting or argument mistake cannot hide behind the rejection arms: the real mounted artifact passes codesign --verify --deep --strict -R"<requirement>". spctl --assess reports source=Notarized Developer ID, origin=Developer ID Application: Cryptic Apps (2AMA2753NF).

Check Result
src/application/MacHopper.test.ts + SetupInstallFailure.test.ts + tests/boundary/setup/macosHopperPublisherVerification.test.ts 13 passed
npx vitest run --project services (whole src/application) 65 files / 794 tests passed
npx vitest run --project acceptance tests/acceptance/setup/ 8 files / 48 tests passed
npx tsc --noEmit -p tsconfig.json clean
oxfmt --check on the changed files clean
oxlint on the changed files 0 errors, 1 pre-existing function-length warning (same warning on unchanged main)
npm run verify:module-boundaries passed (1679 files)
npx knip clean

The new boundary cases build a minimal .app whose launcher is a copy of /usr/bin/true, so they need no compiler: ad-hoc signed with the pinned identifier → unverified_publisher; unsigned → invalid_signature; signed then appended to → invalid_signature; plus a guard that the disclosure still carries the exact requirement.

One case is opt-in: REA_HOPPER_MACOS_APP=<mounted app path> npx vitest run --project boundary tests/boundary/setup/.... It was run here against the real artifact and passed in 4.7 s. It is skipped by default because CI would need a DMG download.

Mutation arms (each arm run, each killed exactly one case)

Arm Killed by
Requirement replaced with a constant-true -R=1 ad-hoc signed bundle case
Pinned team changed to a wrong value real-artifact positive control
Requirement wrapped in stray quotes real-artifact positive control
Requirement line dropped from macHopperInstallDisclosure.commands disclosure guard
unverified_publisher verdict ignored by the installer publisher mismatch unit case
Publisher reason folded onto bundle_invalid projection unit case

tests/acceptance/setup/ initially failed 48 cases here on a fresh checkout. The same selection fails identically on untouched origin/main, and both go green after npm run build plus artifacts:mcp-catalog and artifacts:product-catalog. That is a missing generated-artifact effect, not this diff.

Deliberately not in this PR

  • The observed signer in the CLI/MCP result. You asked for the observed subject; SetupHopperInstallResult only carries code and remediation, so surfacing it means adding a field to that shared shape and editing SetupHost.ts, which is currently touched by #2426, #2414 and #2073. The remediation text names the expected identity instead. I will add the observed subject in a follow-up on request.
  • The scheduled canary. Success criteria 2 and 3 need a macOS lane that downloads the real DMG, and .github/workflows/real-hopper.yml is occupied by #1901. That also needs your call on download frequency, per open question 2 in #1681.
  • docs/user-visible-errors.csv is an audit table with no guard test keyed on these codes, and it is touched by an open PR, so it is left alone.

Base: 17240778. Head: this branch, 1 commit, 5 files.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/morluto/rea/pull/2481 **Author:** [@NIU-123370](https://github.com/NIU-123370) **Created:** 10/13/2026 **Status:** ✅ Merged **Merged:** 10/13/2026 **Merged by:** [@morluto](https://github.com/morluto) **Base:** `main` ← **Head:** `fix/hopper-macos-publisher-pin` --- ### 📝 Commits (1) - [`42cb976`](https://github.com/morluto/rea/commit/42cb976a5c552cc58da6ecca23b68e513340a96d) fix(setup): pin Hopper's macOS publisher identity before install ### 📊 Changes **5 files changed** (+216 additions, -26 deletions) <details> <summary>View changed files</summary> 📝 `src/application/MacHopper.test.ts` (+23 -4) 📝 `src/application/MacHopper.ts` (+52 -22) ➕ `src/application/SetupInstallFailure.test.ts` (+23 -0) 📝 `src/application/SetupInstallFailure.ts` (+10 -0) ➕ `tests/boundary/setup/macosHopperPublisherVerification.test.ts` (+108 -0) </details> ### 📄 Description ## Summary `rea setup --install-hopper` on macOS accepted any bundle that passed `codesign --verify --deep --strict` and whose `Info.plist` said `com.cryptic-apps.hopper-web-4`. An ad-hoc signed bundle carrying that identifier satisfies both checks, so a substituted DMG would be copied into `~/Applications` and launched with `open`. This implements the option you specified in the review note on #1681: verify a pinned code requirement covering the sealed bundle identifier and the signing team together, and report a distinct failure code so a vendor key change is not confused with a corrupt package. Refs #1681 ## What changed - `src/application/MacHopper.ts` - One owned publisher record `HOPPER_PUBLISHER` (bundle identifier + Apple team), and a code requirement built only from it: `= identifier "com.cryptic-apps.hopper-web-4" and anchor apple generic and certificate leaf[subject.OU] = "2AMA2753NF"` - `validBundle` now returns a three-state verdict (`verified` / `invalid_signature` / `unverified_publisher`) instead of a boolean, so a broken signature and a publisher mismatch stay distinguishable. - The `CFBundleIdentifier` string read through `PlistBuddy` is gone. The identifier is now asserted against the sealed CodeDirectory, which an attacker cannot change by editing `Info.plist`. - `macHopperInstallDisclosure.commands` gains the exact requirement, so `setup --dry-run` shows what will be enforced. - New failure reason `publisher_verification_failed`, still returned before any copy or launch. - `src/application/SetupInstallFailure.ts` — projects that reason onto its own `publisher_verification_failed` code and a recovery that names the pinned identity and `HOPPER_LAUNCHER_PATH`. `SetupHost.ts` needed no change; it passes the reason through. ## How the team identifier was captured Not taken on faith and not guessed. Reproducible: 1. `curl -A rea-installer https://www.hopperapp.com/include/files-api.php?request=releases&public=true` → `OS X` release `6.6.0`, `filename` = `.../Hopper-6.6.0-demo.dmg`, `file_length` 34846271, `file_hash` `0be530f038249742590992199c7b042ea20ca955`. 2. Download the DMG: 34846271 bytes, `shasum -a 1` = `0be530f038249742590992199c7b042ea20ca955` (matches the vendor metadata byte for byte). 3. `hdiutil attach -readonly -nobrowse` then `codesign -dvvv "Hopper Disassembler.app"` → `Authority=Developer ID Application: Cryptic Apps (2AMA2753NF)`, `TeamIdentifier=2AMA2753NF`, `flags=0x10000(runtime)`. The capture command and artifact identity are recorded in the comment above the constant. ## Verification (all run in this session) Reproducing the gap first, on a locally built bundle: an ad-hoc signed `Hopper Disassembler.app` with `CFBundleIdentifier = com.cryptic-apps.hopper-web-4` reports `flags=0x2(adhoc)` and `TeamIdentifier=not set`, and **passes** `codesign --verify --deep --strict` plus the old identifier comparison. The same bundle **fails** the new requirement with `test-requirement: code failed to satisfy specified code requirement(s)` (exit 3). Positive control, so a quoting or argument mistake cannot hide behind the rejection arms: the real mounted artifact passes `codesign --verify --deep --strict -R"<requirement>"`. `spctl --assess` reports `source=Notarized Developer ID`, `origin=Developer ID Application: Cryptic Apps (2AMA2753NF)`. | Check | Result | | --- | --- | | `src/application/MacHopper.test.ts` + `SetupInstallFailure.test.ts` + `tests/boundary/setup/macosHopperPublisherVerification.test.ts` | 13 passed | | `npx vitest run --project services` (whole `src/application`) | 65 files / 794 tests passed | | `npx vitest run --project acceptance tests/acceptance/setup/` | 8 files / 48 tests passed | | `npx tsc --noEmit -p tsconfig.json` | clean | | `oxfmt --check` on the changed files | clean | | `oxlint` on the changed files | 0 errors, 1 pre-existing function-length warning (same warning on unchanged `main`) | | `npm run verify:module-boundaries` | passed (1679 files) | | `npx knip` | clean | The new boundary cases build a minimal `.app` whose launcher is a copy of `/usr/bin/true`, so they need no compiler: ad-hoc signed with the pinned identifier → `unverified_publisher`; unsigned → `invalid_signature`; signed then appended to → `invalid_signature`; plus a guard that the disclosure still carries the exact requirement. One case is opt-in: `REA_HOPPER_MACOS_APP=<mounted app path> npx vitest run --project boundary tests/boundary/setup/...`. It was run here against the real artifact and passed in 4.7 s. It is skipped by default because CI would need a DMG download. ### Mutation arms (each arm run, each killed exactly one case) | Arm | Killed by | | --- | --- | | Requirement replaced with a constant-true `-R=1` | ad-hoc signed bundle case | | Pinned team changed to a wrong value | real-artifact positive control | | Requirement wrapped in stray quotes | real-artifact positive control | | Requirement line dropped from `macHopperInstallDisclosure.commands` | disclosure guard | | `unverified_publisher` verdict ignored by the installer | publisher mismatch unit case | | Publisher reason folded onto `bundle_invalid` | projection unit case | `tests/acceptance/setup/` initially failed 48 cases here on a fresh checkout. The same selection fails identically on untouched `origin/main`, and both go green after `npm run build` plus `artifacts:mcp-catalog` and `artifacts:product-catalog`. That is a missing generated-artifact effect, not this diff. ## Deliberately not in this PR - **The observed signer in the CLI/MCP result.** You asked for the observed subject; `SetupHopperInstallResult` only carries `code` and `remediation`, so surfacing it means adding a field to that shared shape and editing `SetupHost.ts`, which is currently touched by #2426, #2414 and #2073. The remediation text names the expected identity instead. I will add the observed subject in a follow-up on request. - **The scheduled canary.** Success criteria 2 and 3 need a macOS lane that downloads the real DMG, and `.github/workflows/real-hopper.yml` is occupied by #1901. That also needs your call on download frequency, per open question 2 in #1681. - **`docs/user-visible-errors.csv`** is an audit table with no guard test keyed on these codes, and it is touched by an open PR, so it is left alone. Base: `17240778`. Head: this branch, 1 commit, 5 files. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-31 01:05:16 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#5263
No description provided.