diff --git a/.github/MACOS-SIGNING.md b/.github/MACOS-SIGNING.md new file mode 100644 index 0000000..4b431e1 --- /dev/null +++ b/.github/MACOS-SIGNING.md @@ -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=/CN=/C=US" +``` + +At → **+** → +**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 → **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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 162c677..3339d04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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" @@ -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"