From f08dae4fb4e4e720ec0a02d4d5067a09c083a819 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Thu, 20 Aug 2026 00:54:57 +0100 Subject: [PATCH 1/2] feat(release): sign and notarize darwin binaries via goreleaser/quill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binaries are now signed with the Developer ID Application certificate and notarized with Apple before archiving, from the existing Linux runner (no macOS runner needed). The Homebrew cask drops its de-quarantine preflight hack — Gatekeeper passes the notarized binary on its own. A preflight step fails the release if the signing secrets are absent from the release environment, so a misconfiguration cannot silently publish unsigned binaries; secrets are exposed to the goreleaser step only. Credential setup, rotation, and leak response are documented in .github/SIGNING.md. --- .github/SIGNING.md | 101 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 26 ++++++++- .goreleaser.yaml | 46 ++++++++++++---- 3 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 .github/SIGNING.md diff --git a/.github/SIGNING.md b/.github/SIGNING.md new file mode 100644 index 0000000..de6787a --- /dev/null +++ b/.github/SIGNING.md @@ -0,0 +1,101 @@ +# Release signing & notarization + +Release binaries are code-signed with a Developer ID Application certificate +and notarized with Apple, from the Linux release runner (goreleaser's +`notarize` block, quill under the hood — no macOS runner involved). This +document covers the credentials that make that work: what they are, how to +(re)create them, and what happens if they leak. + +## Secrets + +All five live in the `release` GitHub environment (protected, `v*` tags only), +and are exposed only to the goreleaser step of `release.yml`. A preflight step +fails the release if they're missing, so a misconfiguration can't silently +ship unsigned binaries. + +| Secret | Contents | +| --- | --- | +| `MACOS_SIGN_P12` | base64 of the Developer ID Application cert + private key (`.p12`) | +| `MACOS_SIGN_PASSWORD` | password protecting the `.p12` | +| `MACOS_NOTARY_ISSUER_ID` | App Store Connect API issuer ID (UUID) | +| `MACOS_NOTARY_KEY_ID` | App Store Connect API key ID | +| `MACOS_NOTARY_KEY` | base64 of the App Store Connect API key (`.p8`) | + +## Setup / rotation + +Team ID: `N58LV5U4Q3` (individual membership, "RYAN ANDREW LEWIS"). + +### 1. Developer ID Application certificate (.p12) + +The cert lives in the login keychain (created via Xcode → Settings → Accounts +→ Manage Certificates). To export: + +1. Keychain Access → login keychain → My Certificates. +2. Expand "Developer ID Application: RYAN ANDREW LEWIS (N58LV5U4Q3)", select + the certificate **and** its private key, File → Export Items → `.p12`. +3. Choose a strong password; store it (and ideally the `.p12` itself) in the + password manager. + +If notarization later fails with an incomplete-chain error, re-export making +sure the Developer ID intermediate CA is included (quill normally +reconstructs the Apple chain itself, so this is unlikely). + +### 2. App Store Connect API key (.p8) + +Needed because quill authenticates to the notary API with an ASC API key — +Apple-ID + app-specific-password (the `notarytool store-credentials` route +used for local signing) does not work here. + +1. [App Store Connect](https://appstoreconnect.apple.com) → Users and Access + → Integrations → App Store Connect API → Team Keys → Generate API Key. +2. Role: **Developer** — the least-privileged role that can notarize. Do not + use Admin. +3. Download the `.p8` (single chance), note the **Key ID** and the page-level + **Issuer ID**. Store the `.p8` in the password manager. + +### 3. Load the GitHub secrets + +```sh +REPO=ryanlewis/things-cli +base64 -i DeveloperID.p12 | gh secret set MACOS_SIGN_P12 --env release --repo "$REPO" +gh secret set MACOS_SIGN_PASSWORD --env release --repo "$REPO" # prompts +gh secret set MACOS_NOTARY_ISSUER_ID --env release --repo "$REPO" --body "" +gh secret set MACOS_NOTARY_KEY_ID --env release --repo "$REPO" --body "" +base64 -i AuthKey_.p8 | gh secret set MACOS_NOTARY_KEY --env release --repo "$REPO" +``` + +Then delete any loose `.p12`/`.p8` copies from disk — the password manager +holds the canonical copies. + +### 4. Verify after the first signed release + +```sh +curl -fsSL https://raw.githubusercontent.com/ryanlewis/things-cli/main/install.sh | INSTALL_DIR=/tmp/things-verify sh +codesign -dvv /tmp/things-verify/things # expect the Developer ID identity + Team ID +codesign --verify --strict /tmp/things-verify/things +xcrun notarytool history --key AuthKey_.p8 --key-id --issuer # status: Accepted +``` + +Note: bare Mach-O binaries can't have the notarization ticket stapled +(stapling is app/dmg/pkg only), so Gatekeeper's first-run check does an online +lookup. That's fine for Homebrew/curl installs; it only bites a fully-offline +first run. + +## Threat model — if a secret leaks + +The release job is the exposure point (this is why its egress is locked down +and the secrets are step-scoped): + +- **`.p12` + password**: an attacker can sign arbitrary software as + "RYAN ANDREW LEWIS" until revoked. Revoke the certificate at + [developer.apple.com → Certificates](https://developer.apple.com/account/resources/certificates/list), + then mint a new one and rotate `MACOS_SIGN_P12`/`MACOS_SIGN_PASSWORD`. + Apple can also revoke notarization tickets for known-malicious binaries. +- **ASC API key (Developer role)**: can submit notarizations under the + account (no cert access, can't sign). Revoke it in App Store Connect → + Integrations, generate a fresh key, rotate the three `MACOS_NOTARY_*` + secrets. +- **`HOMEBREW_TAP_GITHUB_TOKEN`**: can push casks to `ryanlewis/homebrew-tap` + only (fine-grained PAT). Revoke/rotate in GitHub settings. + +None of these grant access to this repository's code or tags. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1b30b7..1bec620 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,14 +36,38 @@ jobs: # another ref would flow straight into published artifacts. cache: false + # goreleaser only signs when MACOS_SIGN_P12 is set (so local snapshot + # builds work without secrets) — which means a missing secret in CI + # would silently publish unsigned binaries and a Homebrew cask with no + # de-quarantine hook. Fail loudly instead. + - name: Ensure signing secrets are present + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + run: | + if [ -z "$MACOS_SIGN_P12" ] || [ -z "$MACOS_NOTARY_KEY" ]; then + echo "::error::signing/notarization secrets missing from the release environment — refusing to publish unsigned binaries. See .github/SIGNING.md" + exit 1 + fi + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser version: "~> v2" - args: release --clean + # --timeout raised from the 30m default: notarization waits on + # Apple's queue, which can be slow for a young developer account. + args: release --clean --timeout 60m + # Secrets are exposed to this step only — never job-wide — so other + # steps (and anything they download) can't read them from the + # environment. env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - name: Attest build provenance uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 318552b..b537adc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -33,6 +33,37 @@ archives: - README.md - LICENSE* +# Sign and notarize the darwin binaries in place before archiving, so the +# tarballs (and therefore checksums and provenance attestations) contain the +# signed Mach-O. Uses quill under the hood — runs fine on the Linux runner, no +# macOS runner or keychain needed. Skipped when MACOS_SIGN_P12 is unset so +# local snapshot builds still work; CI enforces the secrets separately (see +# the preflight step in .github/workflows/release.yml). +notarize: + macos: + - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + # Must match the build id above. The default filter is the project name + # (things-cli), which matches no build here and would silently sign + # nothing. + ids: + - things + sign: + certificate: "{{ .Env.MACOS_SIGN_P12 }}" + password: "{{ .Env.MACOS_SIGN_PASSWORD }}" + notarize: + issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}" + key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}" + key: "{{ .Env.MACOS_NOTARY_KEY }}" + # Block until Apple accepts the submission. This runs before archiving + # and publishing, so a rejected or timed-out notarization fails the + # whole release cleanly — nothing partial is published and the job can + # simply be re-run. + wait: true + # First-ever submissions on a new developer account can sit in Apple's + # queue for a while; the workflow raises goreleaser's global --timeout + # to match. + timeout: 30m + checksum: name_template: "checksums.txt" @@ -78,16 +109,11 @@ homebrew_casks: name: goreleaserbot email: bot@goreleaser.com commit_msg_template: "chore(brew): {{ .ProjectName }} {{ .Tag }}" - # De-quarantine in preflight (before artifact install) rather than postflight, - # so the binary is runnable when generate_completions_from_executable invokes - # it during install — otherwise Gatekeeper blocks the unsigned binary and no - # completion files are written. - hooks: - pre: - install: | - if OS.mac? - system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/things"] - end + # No de-quarantine hook: the binary is signed and notarized (see the + # notarize block above), so Gatekeeper passes it — including when + # generate_completions_from_executable runs it at install time. Bare + # Mach-O binaries can't have a ticket stapled, so the first run needs + # network for Gatekeeper's online notarization lookup. zap: trash: - ~/Library/Caches/things-cli From 383730b46d9c5d1bff1bebddb28c1fe342706997 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Thu, 20 Aug 2026 00:55:23 +0100 Subject: [PATCH 2/2] feat(release): block egress on the release job with an explicit allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job now holds code-signing keys, so audit-only egress monitoring is no longer enough — block mode limits a compromised dependency or action to the listed endpoints (GitHub, Go module proxy/toolchain, Apple notary API and its S3 upload buckets, GitHub Sigstore for attestations). The list is reconstructed from what each step is known to contact; the audit reports from v0.5.x releases can confirm it before the first signed release. A blocked legitimate endpoint shows up by name in the harden-runner report and should be added deliberately. --- .github/workflows/release.yml | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bec620..c5a7419 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,13 +16,34 @@ jobs: id-token: write attestations: write steps: - # Egress monitoring: this job holds the tap token, which can push - # formulae. Audit mode records every outbound connection per step - # (report linked from the run); flip to egress-policy: block with an - # allowed-endpoints list once a few releases have mapped the baseline. + # Egress lockdown: this job holds code-signing keys and the tap token, + # making it the prime exfiltration target — a compromised dependency or + # action can only talk to the endpoints below. If a legitimate step + # starts failing on a blocked connection, the harden-runner report + # linked from the run names the endpoint; add it here deliberately + # rather than reverting to audit. - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: - egress-policy: audit + egress-policy: block + allowed-endpoints: > + api.github.com:443 + appstoreconnect.apple.com:443 + codeload.github.com:443 + dl.google.com:443 + fulcio.githubapp.com:443 + github.com:443 + go.dev:443 + objects.githubusercontent.com:443 + proxy.golang.org:443 + raw.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + storage.googleapis.com:443 + sum.golang.org:443 + timestamp.githubapp.com:443 + uploads.github.com:443 + *.actions.githubusercontent.com:443 + *.s3.amazonaws.com:443 + *.s3.us-west-2.amazonaws.com:443 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: