Skip to content

Commit 80b075f

Browse files
committed
Sign and notarize releases
Releases were ad-hoc signed, so every download had to be walked past Gatekeeper by hand. Now that a Developer ID certificate is available, build-release.sh signs with it, submits to Apple, staples the ticket and re-zips, then checks the result with stapler and spctl. The release workflow runs that same script rather than a CI-only copy, and refuses to publish at all if the signing secrets are missing, so a misconfigured job fails loudly instead of quietly shipping an ad-hoc build. A signed build no longer needs to disable library validation. Xcode re-signs the embedded Sparkle framework with the app's own identity, so under a Developer ID the two share a team and the check passes; only ad-hoc builds, whose signature has no team for anything to match, still need the exemption. That is why there are now two entitlement files, picked by PQA_ENTITLEMENTS_SUFFIX. The suffix is a variable interpolated on the app target because overriding CODE_SIGN_ENTITLEMENTS on the xcodebuild command line reaches every target, and the SPM packages then fail looking for the file inside their own checkout. The workflow also gained a manual trigger that builds, signs and notarizes but publishes nothing, since otherwise the only way to find out whether the credentials work is to cut a release and watch it fail.
1 parent 1f96db2 commit 80b075f

8 files changed

Lines changed: 363 additions & 46 deletions

File tree

.github/workflows/release.yml

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ name: Release
33
on:
44
release:
55
types: [published]
6+
# A dry run: builds, signs and notarizes exactly as a real release does, but
7+
# publishes nothing. It is the only way to find out whether the signing and
8+
# notarization secrets actually work, short of cutting a release and watching
9+
# it fail. Worth running after any change to the credentials or this file.
10+
workflow_dispatch:
11+
inputs:
12+
tag:
13+
description: "Tag to stand in for, only used to derive a version number"
14+
required: false
15+
# Well-formed but obviously not a real release, so a dry-run build is
16+
# never mistaken for one. An all-zero date would derive a CFBundleVersion
17+
# of 00000000.0, and this test exists precisely to avoid finding out the
18+
# hard way whether something down the line objects to that.
19+
default: "v1970-01-01.1"
620

721
permissions:
822
contents: write
@@ -27,25 +41,73 @@ jobs:
2741
- name: Derive versions from the tag
2842
id: version
2943
env:
30-
TAG: ${{ github.event.release.tag_name }}
44+
TAG: ${{ github.event.release.tag_name || inputs.tag }}
3145
run: |
3246
# Tag v2026-06-19.1 -> build number 20260619.1, which climbs with the
3347
# date so Sparkle always treats a newer release as an upgrade.
3448
stripped="${TAG#v}"
3549
build="${stripped//-/}"
3650
echo "build=$build" >> "$GITHUB_OUTPUT"
3751
38-
- name: Build release artifact
52+
# The certificate goes into a throwaway keychain rather than the login one,
53+
# so it exists only for this job and is deleted below whatever happens.
54+
- name: Import the Developer ID certificate
55+
env:
56+
CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
57+
CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
58+
run: |
59+
if [[ -z "$CERTIFICATE_P12" ]]; then
60+
echo "MACOS_CERTIFICATE_P12 is not set; a published release must be signed." >&2
61+
exit 1
62+
fi
63+
keychain="$RUNNER_TEMP/signing.keychain-db"
64+
password="$(uuidgen)"
65+
echo "SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV"
66+
67+
printf '%s' "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
68+
security create-keychain -p "$password" "$keychain"
69+
# Without this the keychain relocks on a timer mid-build and signing
70+
# fails partway through with a generic error.
71+
security set-keychain-settings -lut 21600 "$keychain"
72+
security unlock-keychain -p "$password" "$keychain"
73+
security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" \
74+
-P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
75+
# Lets codesign use the key without the interactive "allow access"
76+
# prompt, which nothing can answer on a runner.
77+
security set-key-partition-list -S apple-tool:,apple:,codesign: \
78+
-s -k "$password" "$keychain" > /dev/null
79+
security list-keychain -d user -s "$keychain" login.keychain-db
80+
rm -f "$RUNNER_TEMP/certificate.p12"
81+
82+
- name: Build, sign and notarize
3983
env:
4084
PQA_BUILD_VERSION: ${{ steps.version.outputs.build }}
41-
run: ./scripts/build-release.sh
85+
PQA_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
86+
PQA_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
87+
NOTARY_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
88+
NOTARY_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
89+
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
90+
run: |
91+
if [[ -z "$PQA_SIGN_IDENTITY" || -z "$NOTARY_KEY_ID" || -z "$APPLE_API_KEY_P8" ]]; then
92+
echo "Signing or notarization secrets are missing; refusing to publish an unsigned build." >&2
93+
exit 1
94+
fi
95+
# notarytool wants the key as a file. Write it outside the workspace and
96+
# remove it on the way out, including when the build fails.
97+
export NOTARY_KEY_PATH="$RUNNER_TEMP/notary-key.p8"
98+
trap 'rm -f "$NOTARY_KEY_PATH"' EXIT
99+
install -m 600 /dev/null "$NOTARY_KEY_PATH"
100+
printf '%s' "$APPLE_API_KEY_P8" > "$NOTARY_KEY_PATH"
101+
./scripts/build-release.sh
42102
43103
- name: Attach artifact to the release
104+
if: github.event_name == 'release'
44105
env:
45106
GH_TOKEN: ${{ github.token }}
46107
TAG: ${{ github.event.release.tag_name }}
47108
run: gh release upload "$TAG" dist/PassQuickAccess.zip --clobber
48109

110+
# Runs on a dry run too, so the Sparkle key is exercised along with the rest.
49111
- name: Sign the update
50112
id: sign
51113
env:
@@ -60,7 +122,9 @@ jobs:
60122
echo "signature=$(echo "$out" | sed -E 's/.*sparkle:edSignature="([^"]+)".*/\1/')" >> "$GITHUB_OUTPUT"
61123
echo "length=$(echo "$out" | sed -E 's/.*length="([0-9]+)".*/\1/')" >> "$GITHUB_OUTPUT"
62124
125+
# Skipped on a dry run: this one pushes to main.
63126
- name: Update the appcast on the default branch
127+
if: github.event_name == 'release'
64128
env:
65129
TAG: ${{ github.event.release.tag_name }}
66130
BUILD: ${{ steps.version.outputs.build }}
@@ -92,3 +156,12 @@ jobs:
92156
git add docs/appcast.xml
93157
git commit -m "Publish $TAG to the appcast"
94158
git push origin main
159+
160+
# The runner is thrown away regardless, but the signing key should not
161+
# outlive the step that needed it, including when the build failed.
162+
- name: Remove the signing keychain
163+
if: always()
164+
run: |
165+
if [[ -n "${SIGNING_KEYCHAIN:-}" && -f "$SIGNING_KEYCHAIN" ]]; then
166+
security delete-keychain "$SIGNING_KEYCHAIN"
167+
fi

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
was never entitled to do, so Safari and Chromium tabs were quietly never
99
matched no matter how the setting was left.
1010

11+
### Changed
12+
- The app now opens straight from the download, with no right-click or
13+
Terminal command to get past macOS. Releases are signed and notarized, and
14+
they keep the hardened runtime fully intact: the shipped build no longer has
15+
to make an exception for loading the updater.
16+
1117
## v2026-06-30.1
1218

1319
### Added

RELEASING.md

Lines changed: 135 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Releasing
22

3-
The recommended way to install the app is to build it from source (see the
4-
README). A pre-built binary can be attached to a GitHub Release, but without an
5-
Apple Developer ID certificate it is ad-hoc signed and not notarized, so it has
6-
to be allowed through Gatekeeper by hand.
3+
Releases are signed with a Developer ID certificate and notarized by Apple, so
4+
the download opens without a Gatekeeper prompt. All of it happens in the
5+
`Release` workflow when a GitHub Release is published; the one-time credential
6+
setup is at the bottom of this file.
77

88
Release tags use the format `vYYYY-MM-DD.X`, where `X` counts the releases made
99
that day starting at 1 (for example `v2026-06-11.1`). The app's
@@ -20,22 +20,106 @@ valid in `CFBundleShortVersionString`.
2020
next sequence number (for example `v2026-06-11.1`), target `main`, and paste
2121
the changelog entries as the notes. Publishing it creates the tag.
2222

23-
Publishing the Release triggers the `Release` workflow, which builds the
24-
artifact, attaches `PassQuickAccess.zip` to that same Release, signs it with the
23+
Publishing the Release triggers the `Release` workflow, which signs and notarizes
24+
the build, attaches `PassQuickAccess.zip` to that same Release, signs it with the
2525
Sparkle EdDSA key, and publishes the new version to `docs/appcast.xml` (served by
2626
GitHub Pages). The release notes you wrote become the changelog the app shows.
27-
To build the zip locally instead:
27+
28+
The job refuses to run if the signing or notarization secrets are missing, rather
29+
than quietly publishing an ad-hoc build that every user would have to right-click
30+
past.
31+
32+
## Dry run
33+
34+
The same workflow can be started by hand from the Actions tab (*Run workflow*).
35+
It builds, signs and notarizes exactly as a real release does, but skips the two
36+
steps that publish: nothing is attached to a release and nothing is pushed to
37+
`main`. The Sparkle signing step still runs, so a dry run exercises every secret.
38+
39+
This is the only way to find out whether the credentials work without cutting a
40+
release and watching it fail, so run it after changing any of them, and after
41+
touching the workflow or the build script.
42+
43+
## Building locally
44+
45+
`scripts/build-release.sh` does the whole artifact: build, signature check, zip,
46+
notarize, staple, re-zip, and a final Gatekeeper assessment. It is the same
47+
script CI runs, so a local build is the real thing rather than an approximation.
48+
49+
```sh
50+
./scripts/build-release.sh
51+
```
52+
53+
With nothing configured that builds ad-hoc and warns that the result is not
54+
distributable, which is fine for testing a Release build. To produce a signed,
55+
notarized zip:
2856

2957
```sh
58+
export PQA_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)"
59+
export PQA_TEAM_ID="TEAMID"
60+
export NOTARY_KEYCHAIN_PROFILE="NOTARY"
3061
./scripts/build-release.sh
3162
```
3263

33-
This writes `dist/PassQuickAccess.zip`.
64+
`security find-identity -v -p codesigning` prints the identity string to use. The
65+
notarization profile is created once with:
66+
67+
```sh
68+
xcrun notarytool store-credentials NOTARY \
69+
--key ~/private_keys/AuthKey_XXXXXXXXXX.p8 \
70+
--key-id XXXXXXXXXX --issuer 00000000-0000-0000-0000-000000000000
71+
```
72+
73+
Either way it writes `dist/PassQuickAccess.zip`.
74+
75+
## Signing and notarization (one-time setup)
76+
77+
### 1. Developer ID Application certificate
78+
79+
In Xcode, *Settings → Accounts → Manage Certificates → + → Developer ID
80+
Application*, or create it in the Apple Developer portal under *Certificates,
81+
Identifiers & Profiles*. Note that Apple allows a limited number of Developer ID
82+
certificates per account and they cannot be revoked casually, so keep the export
83+
below somewhere safe: losing it means asking Apple to reset.
84+
85+
Export it from Keychain Access (the certificate together with its private key) as
86+
a `.p12` with a strong password, then load both into the repository:
87+
88+
```sh
89+
base64 -i DeveloperID.p12 | gh secret set MACOS_CERTIFICATE_P12
90+
gh secret set MACOS_CERTIFICATE_PASSWORD
91+
gh secret set MACOS_SIGN_IDENTITY # "Developer ID Application: Your Name (TEAMID)"
92+
gh secret set APPLE_TEAM_ID # TEAMID
93+
```
94+
95+
Delete the `.p12` from disk afterwards, or move it into your password manager.
96+
97+
### 2. App Store Connect API key for notarization
98+
99+
An API key is preferred over an app-specific password: it is scoped to a role, it
100+
can be revoked on its own without touching the Apple ID, and it never exposes
101+
account credentials to CI. In App Store Connect, *Users and Access → Integrations
102+
→ App Store Connect API*, create a key with the **Developer** role. The `.p8`
103+
downloads once and cannot be downloaded again.
104+
105+
```sh
106+
gh secret set APPLE_API_KEY_P8 < AuthKey_XXXXXXXXXX.p8
107+
gh secret set APPLE_API_KEY_ID # the key ID, e.g. XXXXXXXXXX
108+
gh secret set APPLE_API_ISSUER_ID # the issuer UUID shown above the key list
109+
```
110+
111+
### 3. What the workflow does with them
112+
113+
The certificate is imported into a keychain created for the job and deleted when
114+
it ends, and the `.p8` is written outside the workspace and removed on the way
115+
out. Neither is ever placed in the built artifact. The workflow only runs on a
116+
published release, never from a pull request, so a fork cannot reach the secrets.
34117

35118
## Sparkle update signing (one-time setup)
36119

37120
The app updates itself through [Sparkle](https://sparkle-project.org), and every
38-
update is verified against an EdDSA key. Set this up once:
121+
update is verified against an EdDSA key, independently of Apple's notarization.
122+
Set this up once:
39123

40124
1. Generate the key pair with Sparkle's tool (it stores the private key in your
41125
login Keychain and prints the public key):
@@ -65,24 +149,52 @@ user picks "Update Now" (see [SECURITY.md](SECURITY.md)).
65149
release as an upgrade. `MARKETING_VERSION` is still the human version you bump in
66150
`project.yml`.
67151

68-
## Installing an ad-hoc build
152+
## Entitlements
69153

70-
A downloaded ad-hoc app is quarantined. The user opens it once with right-click
71-
to "Open", or clears the quarantine attribute:
154+
There are two entitlement files, and which one is used follows the signing mode:
72155

73-
```sh
74-
xattr -dr com.apple.quarantine /Applications/PassQuickAccess.app
75-
```
156+
- `PassQuickAccess-Distribution.entitlements` for a Developer ID build, selected
157+
by `build-release.sh` when `PQA_SIGN_IDENTITY` is set.
158+
- `PassQuickAccess.entitlements` everywhere else (development, ad-hoc), which
159+
additionally disables library validation.
160+
161+
The switch is the `PQA_ENTITLEMENTS_SUFFIX` variable, interpolated into
162+
`CODE_SIGN_ENTITLEMENTS` on the app target in `project.yml`. Overriding the path
163+
itself on the `xcodebuild` command line does not work: a command-line setting
164+
applies to every target, so the SPM packages inherit it and fail the build
165+
looking for the file inside their own checkout.
166+
167+
The exemption exists because Xcode re-signs the embedded Sparkle.framework with
168+
the app's own identity: under a Developer ID both carry the same team and library
169+
validation passes, while an ad-hoc signature has no team for anything to match
170+
and the app would crash at launch. A shipped build therefore keeps the hardened
171+
runtime intact.
76172

77-
## Notarized releases
173+
## Upgrading from an ad-hoc build
78174

79-
Once an Apple Developer ID Application certificate is available, sign the Release
80-
build with it (set it in `Config/Local.xcconfig`), enable the hardened runtime,
81-
then notarize and staple:
175+
Releases up to and including `v2026-06-30.1` were ad-hoc signed, so the first
176+
signed release is the one update where the app's code signing identity changes
177+
underneath an existing install. The EdDSA key is unchanged, but Sparkle also
178+
compares the incoming build's signature against the running app's, and how it
179+
treats ad-hoc as the starting point is not obvious from the outside.
180+
181+
This was tested before the first signed release, against a local appcast signed
182+
with the real EdDSA key: an ad-hoc build updated itself to a Developer ID signed
183+
one and came back with the new team identifier. **Sparkle accepts the
184+
transition**, so existing installs update themselves with nothing to announce.
185+
186+
Worth redoing if the signing identity ever changes again, since it is the same
187+
question in a new form. The harness is three parts: an ad-hoc build whose
188+
`SUFeedURL` is repointed at a local server (which breaks its seal, so re-sign the
189+
bundle ad-hoc afterwards), a Developer ID build with a much higher
190+
`CFBundleVersion`, and an appcast signed with `sign_update`. Note that a build
191+
that is signed but not notarized can still be refused at relaunch by Gatekeeper,
192+
which looks like a Sparkle failure and is not one.
193+
194+
Anyone still holding an old ad-hoc build can also just download the new one.
195+
Those older builds are quarantined until opened once with right-click → *Open*,
196+
or cleared with:
82197

83198
```sh
84-
xcrun notarytool submit dist/PassQuickAccess.zip --keychain-profile NOTARY --wait
85-
xcrun stapler staple dist/dd/Build/Products/Release/PassQuickAccess.app
199+
xattr -dr com.apple.quarantine /Applications/PassQuickAccess.app
86200
```
87-
88-
A notarized, stapled build opens without the Gatekeeper prompt.

SECURITY.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@ Nothing is written to disk by the app.
1616

1717
The trust boundary is the `pass-cli` session: anyone able to run code as your
1818
user can read your vault through the CLI directly, so the app's goal is to never
19-
be a weaker link than the CLI already is. Signed release builds use the hardened
20-
runtime without `get-task-allow`. The optional Touch ID lock guards casual
21-
access to an unlocked Mac, not local code execution.
19+
be a weaker link than the CLI already is. The optional Touch ID lock guards
20+
casual access to an unlocked Mac, not local code execution.
21+
22+
Released builds are signed with a Developer ID certificate and notarized by
23+
Apple. They run under the hardened runtime with `get-task-allow` left out, so
24+
another process cannot attach to the app, and with library validation on, so the
25+
app loads no code signed by anyone else. A build made from source without a
26+
certificate is ad-hoc signed and has to relax library validation, because an
27+
ad-hoc signature carries no team identifier for the embedded Sparkle framework to
28+
match; that is the only difference between the two.
2229

2330
See the security model section of the [README](README.md) for more detail.
2431

@@ -37,10 +44,11 @@ Nothing installs on its own. When a newer version exists the app shows a small
3744
"Update" pill and waits; it downloads and replaces the app only after you pick
3845
"Update Now". Every update is verified against an ed25519 (EdDSA) public key
3946
pinned inside the app before it is allowed to install, so a tampered or
40-
intercepted download is rejected even though the build itself is not notarized.
41-
The private half of the signing key lives only in the maintainer's Keychain and a
42-
GitHub Actions secret, and the release workflow that uses it runs only when a
43-
maintainer publishes a release, never from a pull request.
47+
intercepted download is rejected. That check is Sparkle's own and is independent
48+
of Apple's notarization, which the download also carries: a forged update has to
49+
defeat both. The private half of the signing key lives only in the maintainer's
50+
Keychain and a GitHub Actions secret, and the release workflow that uses it runs
51+
only when a maintainer publishes a release, never from a pull request.
4452

4553
## Scope
4654

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<!-- The entitlements of the signed, notarized build. Selected by
6+
scripts/build-release.sh when a Developer ID identity is configured;
7+
everything else uses PassQuickAccess.entitlements.
8+
9+
The only difference is what is missing: this one does not disable
10+
library validation, so the hardened runtime will load nothing but code
11+
signed by the same team. That works here because Xcode re-signs the
12+
embedded Sparkle.framework, its XPC services and Updater.app with the
13+
app's identity, which an ad-hoc build has no team for. -->
14+
<!-- Reads the active tab's URL from Safari and Chromium browsers, to
15+
pre-select the matching login. Under the hardened runtime an app may
16+
not send Apple events at all without this. -->
17+
<key>com.apple.security.automation.apple-events</key>
18+
<true/>
19+
</dict>
20+
</plist>

0 commit comments

Comments
 (0)