Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/MACOS-SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Signing and notarizing the macOS binaries

The `sign and notarize` step in `release.yml` is written and inert. It skips
with a warning while none of the six `MACOS_*` secrets exist, and fails loudly
if only some do. This file is how to make it run.

## What it actually fixes

Nothing that any advertised install path suffers from. `brew install`,
`npm install -g @reachpad/cli` and `curl … | sh` all deliver the binary without
`com.apple.quarantine`, and an ad-hoc signed binary with no quarantine tag just
runs.

It fixes exactly one path: **downloading a tarball from the GitHub releases page
in a browser.** The browser sets the quarantine attribute, macOS then assesses
the binary, and an ad-hoc signature can never pass that assessment — the user
gets "Apple could not verify reachpad is free of malware". Today the release
body explains this and gives them `xattr -d`; notarization removes the dialog
instead of explaining it.

Decide whether that path is worth $99/year before doing any of the below. It is
not on the critical path for any customer we point at an install command.

## One decision first: individual or organization

Organization enrollment shows **Reachpad** in the certificate, and requires a
legal entity and a D-U-N-S number. As of 2026-08 no Reachpad legal entity
exists, so this is weeks of company formation, not an afternoon.

Individual enrollment shows **Seiji Sakurai** in the certificate and takes
hours to days. Gatekeeper does not care which it is; a curious user running
`codesign -dv` sees a person's name rather than a company's.

There is no in-place conversion from individual to organization later — you
enroll the org separately and re-sign. That costs nothing here, because these
are bare binaries with no bundle identity and no update rules keyed to a team
ID. **Start individual.**

## Producing the six secrets

All of this runs on Linux; a Mac is not required at any point. `openssl` is
enough, and it avoids the Keychain Access dance entirely.

### 1. Certificate → `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD`, `MACOS_SIGN_IDENTITY`

```sh
# A private key and a certificate signing request. Keep devid.key secret and
# out of any repository — the .p12 below is the only thing that leaves here.
openssl genrsa -out devid.key 2048
openssl req -new -key devid.key -out devid.csr \
-subj "/emailAddress=<your apple id email>/CN=<your name>/C=US"
```

At <https://developer.apple.com/account/resources/certificates> → **+** →
**Developer ID Application**, upload `devid.csr`, and download the resulting
`developerID_application.cer`. Creating a Developer ID certificate needs the
Account Holder role, which on an individual account is you.

```sh
openssl x509 -inform DER -in developerID_application.cer -out devid.pem
openssl pkcs12 -export -out devid.p12 -inkey devid.key -in devid.pem \
-passout pass:"$P12_PASSWORD"

base64 -w0 devid.p12 # -> MACOS_CERTIFICATE
echo "$P12_PASSWORD" # -> MACOS_CERTIFICATE_PWD
openssl x509 -in devid.pem -noout -subject # the CN is MACOS_SIGN_IDENTITY
```

`MACOS_SIGN_IDENTITY` is the full common name, including the team id in
parentheses — `Developer ID Application: Seiji Sakurai (ABCDE12345)`.

### 2. Notarization key → `MACOS_NOTARY_KEY`, `MACOS_NOTARY_KEY_ID`, `MACOS_NOTARY_ISSUER`

At <https://appstoreconnect.apple.com/access/integrations/api> → **Keys** →
generate a key with the **Developer** role. The `.p8` downloads **once** and
cannot be downloaded again.

```sh
base64 -w0 AuthKey_XXXXXXXXXX.p8 # -> MACOS_NOTARY_KEY
```

`MACOS_NOTARY_KEY_ID` is the `XXXXXXXXXX` in that filename.
`MACOS_NOTARY_ISSUER` is the Issuer ID shown above the key list — a UUID,
shared by every key in the account.

### 3. Install them

```sh
gh secret set MACOS_CERTIFICATE --repo Reachpad/reachpad-cli < cert.b64
gh secret set MACOS_CERTIFICATE_PWD --repo Reachpad/reachpad-cli
gh secret set MACOS_SIGN_IDENTITY --repo Reachpad/reachpad-cli
gh secret set MACOS_NOTARY_KEY --repo Reachpad/reachpad-cli < key.b64
gh secret set MACOS_NOTARY_KEY_ID --repo Reachpad/reachpad-cli
gh secret set MACOS_NOTARY_ISSUER --repo Reachpad/reachpad-cli
```

Then shred the local copies: `shred -u devid.key devid.p12 cert.b64 key.b64
AuthKey_*.p8`. The `.p8` is the one that cannot be re-downloaded — if you lose
it, revoke the key and make another.

The next `cli-v*` tag signs and notarizes. The workflow asserts afterwards that
the signing authority really is a Developer ID Application certificate, because
`codesign --verify` passes just as happily against an ad-hoc or an "Apple
Development" signature, both of which Gatekeeper refuses exactly like no
signature at all.

## The limit of this, which is real

**A bare Mach-O executable cannot be stapled.** `xcrun stapler` attaches a
notarization ticket to bundles, disk images and installer packages — there is
nowhere in a plain executable to put one. So a notarized `reachpad` still needs
Gatekeeper to check notarization **online** the first time it runs. On a machine
with no network at that moment, the assessment can still fail.

Closing that would mean shipping a signed, notarized, stapled `.pkg` for macOS
alongside the tarball. That is a second artifact, a second signing identity
(`Developer ID Installer`), and a `productbuild` step — worth doing only if
people actually report the offline case. They cannot report it yet, because the
paths we advertise never reach Gatekeeper at all.

## Renewal

Developer ID certificates last five years; the Apple Developer Program
membership is annual and the certificate stops being usable if the membership
lapses. `--timestamp` is passed at signing time, so binaries already released
keep verifying after the certificate expires. Only new signings break.
46 changes: 44 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ jobs:
# MACOS_NOTARY_KEY base64 of the App Store Connect .p8 key
# MACOS_NOTARY_KEY_ID the key id
# MACOS_NOTARY_ISSUER the issuer uuid
#
# .github/MACOS-SIGNING.md is the runbook that produces all six — on
# Linux, with openssl, no Mac involved — and says what this does and
# does not fix before you spend $99 on it.
- name: sign and notarize (macOS, when the signing secrets exist)
if: runner.os == 'macOS'
env:
Expand All @@ -90,10 +94,32 @@ jobs:
NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER }}
BINARY: target/${{ matrix.target }}/release/reachpad
run: |
if [ -z "$CERT" ]; then
echo "::warning title=Unsigned macOS binary::MACOS_CERTIFICATE is not set. This release ships an ad-hoc signed binary: it runs from brew, npm and the curl installer, but a browser download will be blocked by Gatekeeper. See the comment in .github/workflows/release.yml."
# All six or none. Signing with a certificate and then failing to
# notarize is the WORST outcome available here: it produces a
# Developer ID binary that Gatekeeper still refuses, and it refuses
# it with the same dialog as the unsigned one — so the release looks
# signed, the secrets look configured, and the symptom is unchanged.
missing=""
for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PWD MACOS_SIGN_IDENTITY \
MACOS_NOTARY_KEY MACOS_NOTARY_KEY_ID MACOS_NOTARY_ISSUER; do
case "$name" in
MACOS_CERTIFICATE) value="$CERT" ;;
MACOS_CERTIFICATE_PWD) value="$CERT_PWD" ;;
MACOS_SIGN_IDENTITY) value="$IDENTITY" ;;
MACOS_NOTARY_KEY) value="$NOTARY_KEY" ;;
MACOS_NOTARY_KEY_ID) value="$NOTARY_KEY_ID" ;;
MACOS_NOTARY_ISSUER) value="$NOTARY_ISSUER" ;;
esac
[ -n "$value" ] || missing="$missing $name"
done
if [ -n "$missing" ] && [ -z "$CERT$CERT_PWD$IDENTITY$NOTARY_KEY$NOTARY_KEY_ID$NOTARY_ISSUER" ]; then
echo "::warning title=Unsigned macOS binary::None of the MACOS_* signing secrets are set. This release ships an ad-hoc signed binary: it runs from brew, npm and the curl installer, but a browser download from the releases page will be blocked by Gatekeeper. See the comment in .github/workflows/release.yml."
exit 0
fi
if [ -n "$missing" ]; then
echo "::error title=Signing is half-configured::Missing:$missing. Signing without notarizing produces a binary Gatekeeper still refuses, with the same dialog as an unsigned one — so this fails instead of shipping something that looks fixed."
exit 1
fi
keychain="$RUNNER_TEMP/build.keychain"
password="$(uuidgen)"
security create-keychain -p "$password" "$keychain"
Expand All @@ -112,6 +138,22 @@ jobs:
--sign "$IDENTITY" "$BINARY"
codesign --verify --strict --verbose=2 "$BINARY"

# A POST-CONDITION, not a formality. `codesign --sign` accepts an
# identity string that resolves to the wrong certificate, and
# --verify then passes happily against it: an ad-hoc or a plain
# "Apple Development" signature verifies fine and is refused by
# Gatekeeper exactly like no signature at all. The only thing that
# clears quarantine is a Developer ID Application authority, so that
# is what gets asserted before this release is allowed to continue.
authority="$(codesign -dv --verbose=4 "$BINARY" 2>&1 | grep '^Authority=' | head -1)"
case "$authority" in
*"Developer ID Application"*) echo "signed: $authority" ;;
*)
echo "::error title=Wrong signing certificate::MACOS_SIGN_IDENTITY resolved to ${authority:-no authority at all}, not a Developer ID Application certificate. Gatekeeper refuses that the same way it refuses an unsigned binary."
exit 1
;;
esac

# notarytool takes an archive, not a bare executable.
echo "$NOTARY_KEY" | base64 --decode > "$RUNNER_TEMP/notary.p8"
ditto -c -k "$BINARY" "$RUNNER_TEMP/notarize.zip"
Expand Down
Loading