diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fd167e..e926820 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -235,9 +235,47 @@ jobs: echo "JAVA_HOME=$(pwd)/$JVM_NAME" >> $GITHUB_ENV echo "$(pwd)/$JVM_NAME/bin" >> $GITHUB_PATH + - name: Import Developer ID signing certificate + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + run: | + if [ -z "$MACOS_CERT_P12" ]; then + echo "No MACOS_CERT_P12 secret set -> CI will build UNSIGNED." + exit 0 + fi + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + echo "$MACOS_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PW" "$KEYCHAIN_PATH" >/dev/null + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + rm -f "$RUNNER_TEMP/cert.p12" + echo "MACOS_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Prepare notarization API key + env: + NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }} + run: | + if [ -z "$NOTARY_KEY_P8" ]; then + echo "No NOTARY_KEY_P8 secret -> notarization falls back to Apple ID creds if present." + exit 0 + fi + echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary_key.p8" + echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary_key.p8" >> "$GITHUB_ENV" + - name: Build env: VERSION_SEMVER: ${{ steps.get_version.outputs.VERSION }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} + NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} + NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} run: bash package-macos.sh - name: Upload result for job Mac-arm @@ -277,9 +315,47 @@ jobs: echo "JAVA_HOME=$(pwd)/$JVM_NAME" >> $GITHUB_ENV echo "$(pwd)/$JVM_NAME/bin" >> $GITHUB_PATH + - name: Import Developer ID signing certificate + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + run: | + if [ -z "$MACOS_CERT_P12" ]; then + echo "No MACOS_CERT_P12 secret set -> CI will build UNSIGNED." + exit 0 + fi + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN_PATH" + echo "$MACOS_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -P "$MACOS_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PW" "$KEYCHAIN_PATH" >/dev/null + security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db + rm -f "$RUNNER_TEMP/cert.p12" + echo "MACOS_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Prepare notarization API key + env: + NOTARY_KEY_P8: ${{ secrets.NOTARY_KEY_P8 }} + run: | + if [ -z "$NOTARY_KEY_P8" ]; then + echo "No NOTARY_KEY_P8 secret -> notarization falls back to Apple ID creds if present." + exit 0 + fi + echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary_key.p8" + echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary_key.p8" >> "$GITHUB_ENV" + - name: Build env: VERSION_SEMVER: ${{ steps.get_version.outputs.VERSION }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.NOTARY_ISSUER_ID }} + NOTARY_APPLE_ID: ${{ secrets.NOTARY_APPLE_ID }} + NOTARY_TEAM_ID: ${{ secrets.NOTARY_TEAM_ID }} + NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} run: bash package-macos.sh - name: Upload result for job Mac-x86 diff --git a/MACOS-SIGNING.md b/MACOS-SIGNING.md new file mode 100644 index 0000000..3566117 --- /dev/null +++ b/MACOS-SIGNING.md @@ -0,0 +1,138 @@ +# macOS Code Signing & Notarization + +CaDoodle ships two macOS artifacts with different signing requirements: + +| Artifact | Distribution | Signing | Status | +|---|---|---|---| +| `CaDoodle-MacOS-{arm64,x86_64}.dmg` | Direct download (GitHub Releases) | **Developer ID Application** + **notarization** + staple | Wired up (this doc) | +| Mac App Store build | App Store | Apple Distribution + sandbox + provisioning profile | **Not yet implemented** — see [Mac App Store](#mac-app-store-pending) | + +This doc covers the Developer ID / notarization path used by [`package-macos.sh`](package-macos.sh). That is what stops macOS (including macOS 26) from blocking the downloaded DMG with *"Apple could not verify this app is free of malware."* + +## How signing is wired + +`package-macos.sh` is driven entirely by environment variables, so the same script runs on a dev machine and in CI: + +- **No variables set** → the DMG is built **unsigned** (local dev builds keep working). macOS warns on such a build when downloaded. +- `MACOS_SIGN_IDENTITY` set → `jpackage` signs the app with the hardened runtime + [`mac-entitlements.plist`](mac-entitlements.plist). +- Notarization credentials also set → the DMG is submitted to Apple's notary service, stapled, and validated. + +### Environment variables + +**Signing** + +| Var | Required | Meaning | +|---|---|---| +| `MACOS_SIGN_IDENTITY` | for signing | Name portion of the identity, e.g. `Common Wealth Robotics (TEAMID)`. `jpackage` resolves `Developer ID Application: ` in the keychain. | +| `MACOS_KEYCHAIN` | optional | Path to the keychain holding the cert. Omit to use the login keychain (CI sets a temporary keychain). | + +**Notarization** — provide **one** of these sets (API key preferred): + +| Vars | Set | Meaning | +|---|---|---| +| `NOTARY_KEY_PATH`, `NOTARY_KEY_ID`, `NOTARY_ISSUER_ID` | A | App Store Connect API key (`.p8`), its key ID, and the issuer UUID | +| `NOTARY_APPLE_ID`, `NOTARY_TEAM_ID`, `NOTARY_PASSWORD` | B | Apple ID, 10-char Team ID, app-specific password | + +## Prerequisites + +- An **Apple Developer Program** membership. Your 10-char **Team ID** is on the Membership page. +- Xcode (provides `notarytool`, `stapler`, and the easy cert-creation flow). + +## 1. Create the Developer ID Application certificate + +**Xcode (easiest):** Xcode → Settings → Accounts → sign in → select the team → *Manage Certificates…* → **+** → **Developer ID Application**. The certificate and its private key land in your login keychain. + +> Developer ID certificates can only be created by the team's **Account Holder**. If the **+** menu doesn't offer it, use the portal flow below from the Account Holder's account. + +**Portal:** create a CSR (Keychain Access → Certificate Assistant → *Request a Certificate From a CA…*, "Saved to disk"), upload it at developer.apple.com → Certificates → **+** → *Developer ID Application*, download the `.cer`, and double-click to install. + +Confirm: +```bash +security find-identity -v -p codesigning +# 1) ABC… "Developer ID Application: Common Wealth Robotics (TEAMID)" +``` + +## 2. Create the notarization credential + +App Store Connect → Users and Access → Integrations → **Keys** → generate a key with **Developer** access. Download `AuthKey_XXXX.p8` (one-time download). Note the **Key ID** (beside the key) and the **Issuer ID** (top of the page). + +## 3. Build & verify locally + +```bash +export MACOS_SIGN_IDENTITY="Common Wealth Robotics (TEAMID)" +export NOTARY_KEY_PATH="$HOME/keys/AuthKey_XXXX.p8" +export NOTARY_KEY_ID="XXXXXXXXXX" +export NOTARY_ISSUER_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +bash package-macos.sh +``` + +Verify the result: +```bash +xcrun stapler validate release/CaDoodle-MacOS-arm64.dmg +spctl -a -t open --context context:primary-signature -v release/CaDoodle-MacOS-arm64.dmg +# → accepted source=Notarized Developer ID +``` + +## 4. CI (GitHub Actions) + +**You do not edit the workflow.** [`.github/workflows/release.yml`](.github/workflows/release.yml) already imports the certificate into a throwaway keychain, writes the notary key to a temp file, and runs `package-macos.sh` in both the `macos-arm` and `macos` jobs. The only thing to add in GitHub is the secrets below. + +Add each at **Settings → Secrets and variables → Actions → New repository secret** +(`https://github.com/CommonWealthRobotics/CaDoodle/settings/secrets/actions`): + +| Secret | Value / how to produce it | +|---|---| +| `MACOS_SIGN_IDENTITY` | The identity's name portion, e.g. `Your Name (TEAMID)` — copy it verbatim from `security find-identity -v -p codesigning` | +| `MACOS_CERT_PASSWORD` | The password you choose when exporting the `.p12` (below) | +| `MACOS_CERT_P12` | base64 of the exported identity (below) | +| `NOTARY_KEY_ID` | The key's 10-char Key ID | +| `NOTARY_ISSUER_ID` | The Issuer UUID from the Keys page | +| `NOTARY_KEY_P8` | base64 of your `AuthKey_XXXX.p8` (below) | + +**Export the signing identity → `.p12` → base64.** In Keychain Access, expand the **Developer ID Application** identity, select the certificate **and** its private key, right-click → **Export 2 items…** → save as `.p12` and set a password (that password becomes `MACOS_CERT_PASSWORD`). Then: + +```bash +base64 -i devid.p12 | pbcopy # paste into MACOS_CERT_P12 +``` + +**base64 the notary key:** + +```bash +base64 -i ~/keys/AuthKey_XXXX.p8 | pbcopy # paste into NOTARY_KEY_P8 +``` + +Secret **values live only in GitHub Secrets — never commit them** to this (public) repo. + +The workflow runs on every push, so once these six secrets exist, the next push to any branch builds signed + notarized DMGs as artifacts (publishing is still gated on a tag). If the signing secrets are absent the jobs log a notice and build an **unsigned** DMG instead of failing — so forks/PRs without secrets still work; if you push before adding them, add the secrets and **Re-run all jobs** on that run. + +> Alternative to the `NOTARY_KEY_*` trio: set `NOTARY_APPLE_ID` / `NOTARY_TEAM_ID` / `NOTARY_PASSWORD` secrets instead; the script accepts either form. + +## Entitlements + +[`mac-entitlements.plist`](mac-entitlements.plist) grants the hardened runtime what a JVM needs: + +- `com.apple.security.cs.allow-jit` and `…allow-unsigned-executable-memory` — the JIT writes and executes generated code. +- `com.apple.security.cs.disable-library-validation` — the bundled JavaFX/runtime native libraries (and the separately launched application JVM) are not all signed by this Team. +- `com.apple.security.cs.allow-dyld-environment-variables` — the launcher passes `JAVA_HOME` and other env through to the child process. + +These suit a notarized Developer ID build; the App Store build needs a different, sandboxed set. + +## Troubleshooting + +- **codesign prompts for keychain access (local):** click *Always Allow* once, or run + `security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ~/Library/Keychains/login.keychain-db`. +- **Notarization rejected:** pull the detailed log with + `xcrun notarytool log --key … --key-id … --issuer …`. The usual cause is an unsigned nested binary (hardened runtime not applied) — confirm `--mac-sign` actually ran. +- **"The binary is not signed with a valid Developer ID certificate":** the name in `MACOS_SIGN_IDENTITY` doesn't match `security find-identity` output, or the cert isn't in the searched keychain (set `MACOS_KEYCHAIN`). + +## Mac App Store (pending) + +The App Store target is **not yet built**. It cannot reuse the DMG binary, because the App Store sandbox prohibits what the updater does at runtime: download a JVM + `CaDoodle-Application.jar`, execute them, and write under `~/bin`. A compliant build must: + +1. Bundle the full JVM + `CaDoodle-Application.jar` at **build time** — no runtime download, no self-updater. +2. Enable `com.apple.security.app-sandbox` plus the file-access / network entitlements the app actually uses. +3. Sign with **Apple Distribution**, embed a provisioning profile, and wrap in a `.pkg` signed with the Mac installer identity. +4. Upload to App Store Connect via Transporter (notarization does not apply to App Store builds). + +This is planned as a separate `package-macos-appstore.sh`. diff --git a/docs/macos-signing-and-notarization.md b/docs/macos-signing-and-notarization.md new file mode 100644 index 0000000..b9b110d --- /dev/null +++ b/docs/macos-signing-and-notarization.md @@ -0,0 +1,75 @@ +# macOS signing & notarization — work record (2026-06-20) + +A description of the change that added Developer ID signing + notarization to the +macOS build, and the scoping of the Mac App Store target. For *how to set it up* +(certificates, secrets, local/CI usage), see [`MACOS-SIGNING.md`](../MACOS-SIGNING.md). + +## Problem + +`package-macos.sh` produced an **unsigned, un-notarized** DMG. On macOS 26, +Gatekeeper blocks such a download with *"Apple could not verify this app is free +of malware,"* with no easy user override. A second goal was to prepare the app +for the **Mac App Store**. + +## Architecture context (why the two goals diverge) + +This repo (`Cadoodle`) is an **installer / auto-updater**, not the application. +At runtime [`CadoodleUpdater.java`](../CaDoodleUpdater/src/main/java/com/commonwealthrobotics/CadoodleUpdater.java) +and [`JvmManager.java`](../CaDoodleUpdater/src/main/java/com/commonwealthrobotics/JvmManager.java) +download a JVM and `CaDoodle-Application.jar` from the `CaDoodle-Application` +releases, write them under `~/bin/CaDoodle-ApplicationInstall/`, and execute +them. + +- **Direct download (Developer ID):** notarizing the updater DMG is sufficient. + Files the updater downloads later are not browser-quarantined, so they run. +- **Mac App Store:** the sandbox **prohibits** downloading/executing code and + writing outside the container, and bans self-updaters. The updater model is + therefore incompatible; the App Store needs a *self-contained, sandboxed* + build instead (see [Pending](#pending)). + +## What was implemented (Developer ID path) + +| File | Change | +|---|---| +| [`mac-entitlements.plist`](../mac-entitlements.plist) | New. Hardened-runtime entitlements for a JVM: `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`, `allow-dyld-environment-variables`. | +| [`package-macos.sh`](../package-macos.sh) | Adds `--mac-sign` + entitlements to `jpackage`, then `notarytool submit --wait` → `stapler staple` → `stapler validate`. | +| [`.github/workflows/release.yml`](../.github/workflows/release.yml) | Both mac jobs import the Developer ID cert into a temporary keychain, materialize the notary API key, and pass credentials to the build. | + +**Credential-driven, no-op without creds.** Everything reads from environment +variables, so the script runs unchanged locally and in CI: + +- nothing set → **unsigned** DMG (prior behavior; dev builds keep working); +- `MACOS_SIGN_IDENTITY` set → signed with hardened runtime; +- notary creds also set → notarized + stapled. + +Variables: `MACOS_SIGN_IDENTITY`, optional `MACOS_KEYCHAIN`; notarization via an +App Store Connect API key (`NOTARY_KEY_PATH` / `NOTARY_KEY_ID` / +`NOTARY_ISSUER_ID`) or Apple ID (`NOTARY_APPLE_ID` / `NOTARY_TEAM_ID` / +`NOTARY_PASSWORD`). + +## Decisions & rationale + +- **Developer ID + notarization (not just signing).** Signing alone does not + clear Gatekeeper on macOS 26; the DMG must be notarized and stapled. +- **Kept `jpackage --type dmg`** rather than refactoring to a manual + app-image → codesign → dmg pipeline. The `.app` inside is signed with the + hardened runtime and the DMG is stapled, which satisfies the download case. + Revisit only if a copied-out `.app` needs its own offline-stapled ticket. +- **App Store as a separate self-contained build** (chosen over putting MAS + packaging in the `CaDoodle-Application` repo). Bundles the full JVM + + application jar at build time, enables the sandbox, drops the updater. + +## Verification gates + +- `bash -n package-macos.sh` — passes. +- `release.yml` — valid YAML; both `macos` and `macos-arm` jobs carry the new steps. +- End-to-end signing/notarization **not yet exercised** — no signing identity is + installed on the build machine (`security find-identity -v -p codesigning` + returns 0). Activation prerequisites are in + [`MACOS-SIGNING.md`](../MACOS-SIGNING.md). + +## Pending + +- A self-contained, sandboxed Mac App Store build, planned as + `package-macos-appstore.sh` (Apple Distribution signing, provisioning profile, + `.pkg` via Transporter). diff --git a/mac-entitlements.plist b/mac-entitlements.plist new file mode 100644 index 0000000..6bfd1eb --- /dev/null +++ b/mac-entitlements.plist @@ -0,0 +1,21 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/package-macos.sh b/package-macos.sh index dc8122d..940268c 100644 --- a/package-macos.sh +++ b/package-macos.sh @@ -67,6 +67,46 @@ cp $MACIMAGE $NAME.iconset/icon_512x512@2x.png iconutil -c icns $NAME.iconset rm -R $NAME.iconset +# --------------------------------------------------------------------------- +# Code signing + notarization (Developer ID, for direct download). +# +# Everything is driven by environment variables so this one script runs +# unchanged on a dev machine and in CI. With NO credentials set, the build +# proceeds UNSIGNED (a local dev build) and notarization is skipped -- the old +# behavior. macOS will still warn on such a build when downloaded; only a +# notarized + stapled DMG clears Gatekeeper on macOS 26. +# +# Signing: +# MACOS_SIGN_IDENTITY Name portion of the "Developer ID Application" identity, +# e.g. "Common Wealth Robotics (TEAMID)". jpackage looks +# up "Developer ID Application: " in the keychain. +# MACOS_KEYCHAIN Optional path to the keychain holding the cert (CI uses +# a temporary keychain; omit to use the login keychain). +# +# Notarization (only attempted when signing happened). Provide ONE set: +# A) App Store Connect API key (recommended, esp. for CI): +# NOTARY_KEY_PATH path to the AuthKey_XXXX.p8 file +# NOTARY_KEY_ID the 10-char key ID +# NOTARY_ISSUER_ID the issuer UUID +# B) Apple ID + app-specific password: +# NOTARY_APPLE_ID your Apple ID email +# NOTARY_TEAM_ID your 10-char Team ID +# NOTARY_PASSWORD an app-specific password (appleid.apple.com) +# --------------------------------------------------------------------------- +SIGN_ARGS=() +DO_SIGN=false +if [[ -n "${MACOS_SIGN_IDENTITY}" ]]; then + DO_SIGN=true + echo "Signing as: Developer ID Application: ${MACOS_SIGN_IDENTITY}" + SIGN_ARGS+=( --mac-sign + --mac-signing-key-user-name "${MACOS_SIGN_IDENTITY}" + --mac-package-identifier "com.commonwealthrobotics.cadoodle" + --mac-entitlements "$SCRIPT_DIR/mac-entitlements.plist" ) + [[ -n "${MACOS_KEYCHAIN}" ]] && SIGN_ARGS+=( --mac-signing-keychain "${MACOS_KEYCHAIN}" ) +else + echo "MACOS_SIGN_IDENTITY not set -> building UNSIGNED (notarization skipped)." +fi + $JAVA_HOME/bin/jpackage --input $BUILDDIR \ --name $NAME \ --main-jar $TARGETJAR \ @@ -76,8 +116,33 @@ $JAVA_HOME/bin/jpackage --input $BUILDDIR \ --vendor "Common Wealth Robotics" \ --icon $NAME.icns \ --app-version "$VERSION" \ + "${SIGN_ARGS[@]}" \ --java-options '--enable-preview -Dcom.sun.net.ssl.checkRevocation=false -Djava.security.revocation=false' ls -al rm -rf release mkdir release -mv $NAME-$VERSION.dmg release/$NAME-MacOS-$ARCH.dmg +DMG="release/$NAME-MacOS-$ARCH.dmg" +mv $NAME-$VERSION.dmg "$DMG" + +# ---- Notarize + staple (only when the build was signed) ---- +if [[ "$DO_SIGN" == "true" ]]; then + NOTARY_ARGS=() + if [[ -n "${NOTARY_KEY_PATH}" && -n "${NOTARY_KEY_ID}" && -n "${NOTARY_ISSUER_ID}" ]]; then + echo "Notarizing with App Store Connect API key..." + NOTARY_ARGS=( --key "${NOTARY_KEY_PATH}" --key-id "${NOTARY_KEY_ID}" --issuer "${NOTARY_ISSUER_ID}" ) + elif [[ -n "${NOTARY_APPLE_ID}" && -n "${NOTARY_TEAM_ID}" && -n "${NOTARY_PASSWORD}" ]]; then + echo "Notarizing with Apple ID + app-specific password..." + NOTARY_ARGS=( --apple-id "${NOTARY_APPLE_ID}" --team-id "${NOTARY_TEAM_ID}" --password "${NOTARY_PASSWORD}" ) + fi + + if [[ ${#NOTARY_ARGS[@]} -gt 0 ]]; then + xcrun notarytool submit "$DMG" "${NOTARY_ARGS[@]}" --wait + echo "Stapling notarization ticket to $DMG ..." + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + spctl -a -t open --context context:primary-signature -v "$DMG" || true + else + echo "Signed, but no notarization credentials set -> skipping notarization." + echo "WARNING: the DMG is signed but NOT notarized; macOS 26 will still warn on download." + fi +fi