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..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: @@ -36,14 +57,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