From a923fa6494da17e090eea3e1be388e39322099f5 Mon Sep 17 00:00:00 2001 From: HeapSmasher <98077186+las7@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:10:50 +0000 Subject: [PATCH] Put the signing key behind a reviewed, tag-only environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan had the Developer ID key as a repository secret. A repository secret is readable by any workflow in the repository on any branch, including one added by anyone with write access — and this repository is public, has two admins, and has no branch protection on main. For a token that is a bad day. For an Apple code-signing key it is somebody signing malware that macOS trusts as us, which rotation does not repair: every binary ever signed with it has to be re-signed and re-released, and users who already installed one cannot tell. The key now lives in a environment whose rules allow deployment from cli-v* TAGS only, with a required human reviewer, so it is reachable only by a tagged release a person approved. The build job names that environment; the Linux legs are gated too, which is correct — nothing in a release should ship without the same approval. MACOS-SIGNING.md gains the parts that were missing: where the private key is allowed to exist (not a box that runs agents), backing up the p12 before shredding because Apple caps Developer ID certificates, reading secret values from files rather than arguments so they stay out of ps and shell history, a revocation runbook, and the codesign incantation that lets a user verify a download came from us. Greentree-Change-Id: 35e2118c4d30b0d1e70a7cea64a5206f --- .github/MACOS-SIGNING.md | 106 ++++++++++++++++++++++++++++++---- .github/workflows/release.yml | 21 +++++++ 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/.github/MACOS-SIGNING.md b/.github/MACOS-SIGNING.md index 4b431e1..0424a5f 100644 --- a/.github/MACOS-SIGNING.md +++ b/.github/MACOS-SIGNING.md @@ -36,10 +36,32 @@ 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.** +## Where the private key is allowed to exist + +Generate it on a machine **you** control — a laptop, not a shared devbox and +not a box that runs coding agents with shell access. `openssl` is the only +requirement, so this is thirty seconds anywhere. + +The key is the crown jewel of this whole arrangement. Anything holding it can +sign code that macOS trusts as us; that is a different class of secret from a +token, because the damage is done to *users* rather than to our +infrastructure, and it is not repaired by rotation — every binary ever signed +with the leaked key has to be re-signed and re-released, and users who already +installed one have no way to know. + +Three places it will exist, and that should be all three: + +1. Your laptop, briefly, while you build the `.p12`. +2. An encrypted backup in a password manager. +3. The `release-signing` environment secret, used by tagged releases only. + +It should NOT end up in a repository, a scratch directory, a chat transcript, +or an agent's working tree. + ## 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. +All of this runs on Linux or macOS; the Apple developer portal is the only +part that needs a browser, and Keychain Access is never involved. ### 1. Certificate → `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD`, `MACOS_SIGN_IDENTITY` @@ -83,20 +105,43 @@ base64 -w0 AuthKey_XXXXXXXXXX.p8 # -> MACOS_NOTARY_KEY `MACOS_NOTARY_ISSUER` is the Issuer ID shown above the key list — a UUID, shared by every key in the account. -### 3. Install them +### 3. Install them — into the ENVIRONMENT, never the repository ```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 +E="--repo Reachpad/reachpad-cli --env release-signing" +gh secret set MACOS_CERTIFICATE $E < cert.b64 +gh secret set MACOS_CERTIFICATE_PWD $E < pwd.txt +gh secret set MACOS_SIGN_IDENTITY $E < identity.txt +gh secret set MACOS_NOTARY_KEY $E < key.b64 +gh secret set MACOS_NOTARY_KEY_ID $E < keyid.txt +gh secret set MACOS_NOTARY_ISSUER $E < issuer.txt ``` -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. +`--env release-signing` is not optional and not a detail. A **repository** +secret is readable by any workflow in the repository, on any branch, including +one added by anyone with write access — and this repository is public with two +admins and no branch protection on `main`. An **environment** secret is +reachable only by a job naming that environment, after its rules pass: ours +allow deployment from `cli-v*` tags only, with a required human reviewer. + +Read from files, not from arguments: a value passed on a command line is +visible in `ps` to every other process on the machine, and lands in shell +history. + +Back the `.p12` up **before** deleting anything — an encrypted note in a +password manager, not a file on a work machine. Apple caps how many Developer +ID Application certificates an account may hold, so "just make another" is not +free, and revoking one invalidates signatures made with it. + +Then destroy the local copies: + +```sh +shred -u devid.key devid.p12 cert.b64 key.b64 pwd.txt identity.txt \ + keyid.txt issuer.txt AuthKey_*.p8 +``` + +The `.p8` cannot be re-downloaded. If it is lost, revoke that key in App Store +Connect and generate another; unlike the certificate, notary keys are cheap. The next `cli-v*` tag signs and notarizes. The workflow asserts afterwards that the signing authority really is a Developer ID Application certificate, because @@ -124,3 +169,40 @@ 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. + +## If the key leaks + +Assume it leaked if the `.p12` or its password appeared anywhere in the "not +allowed" list above, or if a release was signed that nobody approved — the +environment's reviewer requirement exists so that second one is answerable. + +1. **Revoke the certificate** at developer.apple.com. This invalidates + signatures made with it, including on binaries already installed. +2. **Delete the environment secrets** so no further release can use it: + `gh secret delete MACOS_CERTIFICATE --repo Reachpad/reachpad-cli --env release-signing` + and the other five. +3. **Check what was signed with it**: every `cli-v*` release since the key was + installed, and whether each has an approval in the environment's deployment + history. GitHub records who approved each one. +4. **Issue a new certificate, re-sign, re-release** every version still being + downloaded. Publish what happened — a revoked signature makes existing + installs fail to pass Gatekeeper, and users deserve to know why before + they hit it. + +Notarization tickets are Apple's record, not ours; Apple can revoke those +separately if the binary is found to be malicious. + +## Letting users check us + +Once signing is live, publish the expected authority so anyone can verify a +download came from us rather than from whoever else might have the key: + +```sh +codesign -dv --verbose=4 $(which reachpad) 2>&1 | grep Authority +# Authority=Developer ID Application: () +# Authority=Developer ID Certification Authority +# Authority=Apple Root CA +``` + +A team ID printed in the README is a check a user can actually perform, and it +is what makes a stolen-certificate build detectable by someone other than us. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3339d04..b3a9903 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,27 @@ permissions: contents: write jobs: build: + # The signing key lives HERE, not in repository secrets, and the + # difference is the whole security posture of this workflow. + # + # A repository secret is readable by any workflow in the repository, on + # any branch, including one added in a push by anybody with write access. + # For an ordinary token that is a bad day; for an Apple Developer ID key + # it is an attacker signing malware that macOS trusts as us, which is the + # failure that ends a software company's reputation and cannot be undone + # by rotating anything — every binary ever signed has to be re-released. + # + # An environment secret is readable only by a job that names the + # environment, and only after the environment's rules pass. Ours are: + # deployments allowed from `cli-v*` TAGS only (not from any branch), and + # a required human reviewer. So the key is reachable exclusively by a + # tagged release that a person approved, and never by a workflow someone + # pushed to a branch. + # + # This gates the Linux legs too. That is deliberate: nothing in a release + # should ship without the same approval, and one click per release is not + # a cost worth engineering around. + environment: release-signing strategy: matrix: include: