From aac65b5d4ca267548f2876d082e808c8561e7c9e Mon Sep 17 00:00:00 2001 From: HeapSmasher <98077186+las7@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:01:57 +0000 Subject: [PATCH] Refuse a half-configured signing setup, and write the runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in a step that has never executed. It keyed on MACOS_CERTIFICATE alone, so a certificate without notary credentials would sign and then fail at notarytool — and a Developer ID binary that is not notarized is refused by Gatekeeper with the SAME dialog as an unsigned one. The release would look signed, the secrets would look configured, and the symptom would be unchanged. All six or none now, with the missing ones named. And codesign --verify passes against an ad-hoc or an Apple Development signature just as happily as a Developer ID one, while Gatekeeper refuses both like no signature at all. The authority is asserted after signing rather than assumed. .github/MACOS-SIGNING.md is the runbook: the openssl path that produces all six secrets on Linux with no Mac, the individual-vs-organization call (no Reachpad legal entity exists, so organization is company formation, not an afternoon), and the limit worth knowing before paying — a bare Mach-O cannot be stapled, so notarization is still checked online on first run. Greentree-Change-Id: b313a40fdf40d088b620ec9e8ce17130 --- .github/MACOS-SIGNING.md | 126 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 46 ++++++++++++- 2 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 .github/MACOS-SIGNING.md 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"