diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..244de37 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,230 @@ +name: Release macOS + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: Release tag to publish, for example v1.0.0-beta.1 + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-macos-${{ github.ref_name || inputs.tag }} + cancel-in-progress: false + +jobs: + release-macos: + name: Sign, notarize, and publish Apple Silicon DMG + runs-on: macos-15 + environment: production + timeout-minutes: 60 + + env: + RELEASE_EVENT_NAME: ${{ github.event_name }} + RELEASE_INPUT_TAG: ${{ inputs.tag }} + RELEASE_REF_NAME: ${{ github.ref_name }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} + SWITCHIFY_TELEMETRY_ENDPOINT: ${{ vars.TIMBERLOGS_ENDPOINT }} + + steps: + - name: Resolve release tag + shell: bash + run: | + set -euo pipefail + release_tag="$RELEASE_REF_NAME" + if [[ "$RELEASE_EVENT_NAME" == 'workflow_dispatch' ]]; then + release_tag="$RELEASE_INPUT_TAG" + fi + if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must be a semantic version beginning with v. Received: $release_tag" >&2 + exit 1 + fi + echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV" + + - name: Checkout release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: aarch64-apple-darwin + + - name: Install dependencies + run: npm ci + + - name: Verify release configuration + shell: bash + run: | + set -euo pipefail + node_version="$(node -p "require('./package.json').version")" + tauri_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" + expected_tag="v${node_version}" + if [[ "$node_version" != "$tauri_version" ]]; then + echo "package.json version $node_version does not match Tauri version $tauri_version." >&2 + exit 1 + fi + if [[ "$RELEASE_TAG" != "$expected_tag" ]]; then + echo "Release tag $RELEASE_TAG does not match app version $expected_tag." >&2 + exit 1 + fi + if [[ "$APPLE_SIGNING_IDENTITY" != 'Developer ID Application: '* ]]; then + echo 'APPLE_SIGNING_IDENTITY must name a Developer ID Application certificate.' >&2 + exit 1 + fi + if [[ "$APPLE_SIGNING_IDENTITY" != *"($APPLE_TEAM_ID)" ]]; then + echo 'APPLE_SIGNING_IDENTITY does not contain the configured APPLE_TEAM_ID.' >&2 + exit 1 + fi + for required_name in APPLE_API_ISSUER APPLE_API_KEY TIMBERLOGS_API_KEY SWITCHIFY_TELEMETRY_ENDPOINT; do + if [[ -z "${!required_name:-}" ]]; then + echo "$required_name is not configured for the production environment." >&2 + exit 1 + fi + done + + - name: Install Developer ID certificate + shell: bash + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "$APPLE_CERTIFICATE_BASE64" || -z "$APPLE_CERTIFICATE_PASSWORD" ]]; then + echo 'The production Developer ID certificate is not configured.' >&2 + exit 1 + fi + certificate_path="$RUNNER_TEMP/switchify-developer-id.p12" + keychain_path="$RUNNER_TEMP/switchify-signing.keychain-db" + keychain_password="$(openssl rand -base64 32)" + echo "::add-mask::$keychain_password" + printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" + chmod 600 "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$keychain_path" + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain_path" + security list-keychains -d user -s "$keychain_path" + identity_output="$(security find-identity -v -p codesigning "$keychain_path")" + if ! grep -Fq "\"$APPLE_SIGNING_IDENTITY\"" <<< "$identity_output"; then + echo "The configured Developer ID identity was not found in the imported certificate." >&2 + exit 1 + fi + echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" + + - name: Install notarization API key + shell: bash + env: + APPLE_API_PRIVATE_KEY: ${{ secrets.APPLE_API_PRIVATE_KEY }} + run: | + set -euo pipefail + if [[ -z "$APPLE_API_PRIVATE_KEY" ]]; then + echo 'APPLE_API_PRIVATE_KEY is not configured for the production environment.' >&2 + exit 1 + fi + api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8" + printf '%s' "$APPLE_API_PRIVATE_KEY" > "$api_key_path" + chmod 600 "$api_key_path" + echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV" + + - name: Build signed and notarized app and signed DMG + run: npm run tauri build -- --bundles app,dmg --target aarch64-apple-darwin + + - name: Notarize and staple DMG + shell: bash + run: | + set -euo pipefail + dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" + if [[ -z "$dmg_path" ]]; then + echo 'No signed DMG was produced for notarization.' >&2 + exit 1 + fi + xcrun notarytool submit "$dmg_path" \ + --key "$APPLE_API_KEY_PATH" \ + --key-id "$APPLE_API_KEY" \ + --issuer "$APPLE_API_ISSUER" \ + --wait \ + --timeout 20m + xcrun stapler staple "$dmg_path" + + - name: Verify signed release + shell: bash + run: ./scripts/verify-macos-release.sh "$RELEASE_TAG" "$APPLE_SIGNING_IDENTITY" "$APPLE_TEAM_ID" + + - name: Stage release assets + shell: bash + run: | + set -euo pipefail + mkdir -p dist + dmg_path="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)" + if [[ -z "$dmg_path" ]]; then + echo 'No notarized DMG was produced.' >&2 + exit 1 + fi + cp "$dmg_path" dist/ + asset_name="$(basename "$dmg_path")" + asset_hash="$(shasum -a 256 "dist/$asset_name" | awk '{print $1}')" + printf '%s %s\n' "$asset_hash" "$asset_name" > dist/SHA256SUMS-macos.txt + + - name: Publish GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + shopt -s nullglob + assets=(dist/*) + if (( ${#assets[@]} == 0 )); then + echo 'No release assets found in dist.' >&2 + exit 1 + fi + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber + else + gh release create "$RELEASE_TAG" "${assets[@]}" \ + --title "$RELEASE_TAG" \ + --generate-notes \ + --verify-tag + fi + + - name: Upload workflow artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: switchify-pc-macos-${{ env.RELEASE_TAG }} + path: dist/* + if-no-files-found: error + + - name: Remove temporary signing material + if: always() + shell: bash + run: | + if [[ -n "${APPLE_KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$APPLE_KEYCHAIN_PATH" 2>/dev/null || true + fi + rm -f \ + "$RUNNER_TEMP/switchify-developer-id.p12" \ + "$RUNNER_TEMP/AuthKey_${APPLE_API_KEY:-missing}.p8" diff --git a/README.md b/README.md index 832e1fe..908b259 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ cargo test --manifest-path src-tauri/Cargo.toml Rust tests use fake input adapters and never control the local pointer or keyboard. Native checks and unsigned bundles run on Windows and macOS in `.github/workflows/ci.yml`. +## macOS releases + +Production macOS releases are Apple Silicon DMGs signed with an Apple-issued Developer ID Application certificate, submitted to Apple's notarization service, and stapled for offline Gatekeeper verification. A `v*` tag or manual release run creates or updates the matching GitHub Release after verifying the tag, app version, architecture, nested-code signatures, hardened runtime, secure timestamp, and notarization tickets. + +The production certificate and App Store Connect API key are held only in the GitHub `production` environment and imported into an ephemeral runner keychain. They are separate from the machine-local `Switchify PC Development` identity used by `npm run macos:run`. See [macOS production releases](docs/macos-releases.md) for certificate creation, GitHub configuration, release, recovery, and rotation instructions. + ## Diagnostics Switchify keeps up to 500 sanitized diagnostic events locally in `diagnostic-history.jsonl`. The history covers application startup, Bluetooth and Accessibility transitions, disconnects, runtime failures, and update checks. It never stores typed text, command payloads, pairing secrets, device names, or full paths; malformed or unwritable history is ignored so diagnostics cannot prevent startup. @@ -86,7 +92,7 @@ Existing public Git history, tags, releases, update metadata, and installer down ## Development boundaries -- The macOS development identity is local-only. The application has no production Developer ID signing, notarization, or release publishing workflow; macOS CI builds with `--no-sign`. Windows production packages use the locally available Certum/SimplySign identity and are not published automatically. +- Pull-request and `main` macOS CI remains unsigned. Only authorized release tags and manual recovery runs can access the production Developer ID and notarization credentials. Windows production packages continue to use the locally available Certum/SimplySign identity and are not published automatically. - Linux may appear in capability data but is not a supported Bluetooth target. - Windows Grid 3 output uses the native `Sensory_SwitchInput` broadcast contract. Grid 3 is omitted from macOS capabilities and profiles. - Update installation requires a signed Tauri update feed. Local development builds can only report updater configuration errors. diff --git a/docs/macos-releases.md b/docs/macos-releases.md new file mode 100644 index 0000000..f064047 --- /dev/null +++ b/docs/macos-releases.md @@ -0,0 +1,60 @@ +# macOS production releases + +Switchify PC is distributed outside the Mac App Store as an Apple Silicon DMG. Production releases use an Apple-issued **Developer ID Application** certificate and Apple notarization. The self-signed `Switchify PC Development` identity remains exclusively for local Accessibility testing through `npm run macos:run`. + +## Create and preserve the certificate + +1. In Keychain Access, create a certificate signing request using the Apple Developer account email. Select **Saved to disk** and **Let me specify key pair information**, then use RSA 2048-bit keys. +2. In Apple Developer Certificates, create a **Developer ID Application** certificate from that CSR. Download and install the certificate on the Mac that generated the CSR so it joins the existing private key. +3. Verify it appears under **My Certificates** and in `security find-identity -v -p codesigning` with its full `Developer ID Application: … (TEAMID)` identity. +4. Export the certificate and private key together as a password-protected `.p12`. Keep an encrypted offline backup of the `.p12` and its password. Never commit either one. +5. In App Store Connect → Users and Access → Integrations, create a team API key with **Developer** access. Download the `.p8` immediately; Apple only permits downloading it once. + +Do not revoke or replace the certificate during normal renewal. Revocation invalidates future signing and can require an emergency rotation. Keep the local `Switchify PC Development`, earlier development, POC, and retired identities untouched. + +## Configure GitHub + +Create a `production` environment. Allow deployments from release tags matching `v*` and from `main` for manually dispatched recovery runs. No reviewer gate is required. Add a repository tag ruleset so only maintainers can create or update `v*` tags. + +Configure these environment secrets: + +| Name | Value | +| --- | --- | +| `APPLE_CERTIFICATE_BASE64` | Base64-encoded contents of the encrypted `.p12` | +| `APPLE_CERTIFICATE_PASSWORD` | Password used when exporting the `.p12` | +| `APPLE_API_ISSUER` | App Store Connect API issuer UUID | +| `APPLE_API_KEY` | App Store Connect API key ID | +| `APPLE_API_PRIVATE_KEY` | Complete contents of the downloaded `.p8` file | +| `TIMBERLOGS_API_KEY` | Production telemetry API key | + +Configure these environment variables: + +| Name | Value | +| --- | --- | +| `APPLE_SIGNING_IDENTITY` | Full `Developer ID Application: … (TEAMID)` identity | +| `APPLE_TEAM_ID` | Apple Developer team ID from the identity | +| `TIMBERLOGS_ENDPOINT` | Production HTTPS telemetry endpoint | + +Encode the certificate without line wrapping on macOS: + +```bash +base64 -i Switchify-PC-Developer-ID.p12 | tr -d '\n' +``` + +The workflow writes credentials only beneath the ephemeral runner directory, imports the certificate into a temporary keychain, masks its generated keychain password, and deletes the material during cleanup. Pull-request and `main` CI never receive production signing secrets. + +## Publish a release + +Keep `package.json` and `src-tauri/tauri.conf.json` versions identical, then create and push the corresponding tag, for example `v1.0.0-beta.1`. The release workflow checks out that exact tag, builds on an Apple Silicon runner, lets Tauri sign and notarize the app, then separately submits and staples the finished signed DMG. It validates Gatekeeper and both stapled tickets before creating or updating the matching GitHub Release. + +The workflow can be manually dispatched with an existing tag to recover or replace a macOS asset. It never modifies earlier tags or release assets. The published DMG is accompanied by `SHA256SUMS-macos.txt`. + +Updater feed generation and updater signing are intentionally separate work. Publishing a DMG does not make the in-app updater functional. + +## Rotation and troubleshooting + +- Track the Developer ID certificate expiry date and rotate it before expiry by issuing a new certificate, updating all certificate-related secrets together, and validating a release candidate before retiring the old certificate. +- If import fails, verify the `.p12` contains both certificate and private key and that its password is correct. +- If the identity check fails, copy the exact identity from `security find-identity -v -p codesigning`; it must start with `Developer ID Application:` and end with the configured team ID. +- If notarization fails, inspect the Tauri/notarytool output for unsigned nested code, missing hardened runtime, timestamp failures, or rejected entitlements. Do not bypass notarization or stapling. +- If Gatekeeper or stapling validation fails after Apple accepted the upload, rerun the workflow once before replacing credentials; Apple ticket availability can briefly lag. diff --git a/scripts/verify-macos-release.sh b/scripts/verify-macos-release.sh new file mode 100755 index 0000000..759ddba --- /dev/null +++ b/scripts/verify-macos-release.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +release_tag="$1" +signing_identity="$2" +team_id="$3" +project_directory="$(cd "$(dirname "$0")/.." && pwd)" +bundle_directory="${project_directory}/src-tauri/target/aarch64-apple-darwin/release/bundle" + +if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: $release_tag" >&2 + exit 1 +fi +if [[ "$signing_identity" != 'Developer ID Application: '* ]]; then + echo 'The release identity must be a Developer ID Application certificate.' >&2 + exit 1 +fi +if [[ -z "$team_id" || "$signing_identity" != *"($team_id)" ]]; then + echo 'The release identity does not match the expected Apple team.' >&2 + exit 1 +fi + +app_path="$(find "${bundle_directory}/macos" -maxdepth 1 -type d -name '*.app' -print -quit)" +dmg_path="$(find "${bundle_directory}/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + +if [[ -z "$app_path" || ! -d "$app_path" ]]; then + echo "No application bundle found under ${bundle_directory}/macos." >&2 + exit 1 +fi +if [[ -z "$dmg_path" || ! -f "$dmg_path" ]]; then + echo "No DMG found under ${bundle_directory}/dmg." >&2 + exit 1 +fi + +main_executable="${app_path}/Contents/MacOS/switchify-pc" +if [[ ! -x "$main_executable" ]]; then + echo "The expected application executable is missing: $main_executable" >&2 + exit 1 +fi + +codesign --verify --deep --strict --verbose=2 "$app_path" +codesign --verify --strict --verbose=2 "$dmg_path" + +app_details="$(codesign -dvvv "$app_path" 2>&1)" +if ! grep -Fq "Authority=${signing_identity}" <<< "$app_details"; then + echo 'The application was not signed with the configured Developer ID identity.' >&2 + exit 1 +fi +if ! grep -Fq "TeamIdentifier=${team_id}" <<< "$app_details"; then + echo 'The application signature has the wrong Apple team identifier.' >&2 + exit 1 +fi +if ! grep -Eq 'flags=.*\(runtime\)' <<< "$app_details"; then + echo 'The application signature does not enable hardened runtime.' >&2 + exit 1 +fi +if ! grep -Fq 'Timestamp=' <<< "$app_details"; then + echo 'The application signature does not contain a secure timestamp.' >&2 + exit 1 +fi + +while IFS= read -r -d '' candidate; do + if file "$candidate" | grep -Fq 'Mach-O'; then + candidate_details="$(codesign -dvv "$candidate" 2>&1)" + if ! grep -Fq "Authority=${signing_identity}" <<< "$candidate_details" || \ + ! grep -Fq "TeamIdentifier=${team_id}" <<< "$candidate_details"; then + echo "Nested executable uses a different signing identity: $candidate" >&2 + exit 1 + fi + fi +done < <(find "${app_path}/Contents" -type f -print0) + +architectures="$(lipo -archs "$main_executable")" +if [[ "$architectures" != 'arm64' ]]; then + echo "Expected an Apple Silicon-only executable, found: $architectures" >&2 + exit 1 +fi + +xcrun stapler validate "$app_path" +xcrun stapler validate "$dmg_path" +spctl --assess --type execute --verbose=4 "$app_path" +spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path" + +echo "Verified signed, hardened, notarized Apple Silicon release ${release_tag}." diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ca1b414..e6e0dd6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -48,6 +48,7 @@ "nsis": { "installMode": "perMachine" } }, "macOS": { + "hardenedRuntime": true, "minimumSystemVersion": "13.0", "infoPlist": "Info.plist" } diff --git a/src-tauri/tests/config.rs b/src-tauri/tests/config.rs index e9d6dfb..f108291 100644 --- a/src-tauri/tests/config.rs +++ b/src-tauri/tests/config.rs @@ -31,4 +31,5 @@ fn application_configuration_uses_the_promoted_identity() { macos["bundle"]["macOS"]["signingIdentity"], "Switchify PC Development" ); + assert_eq!(config["bundle"]["macOS"]["hardenedRuntime"], true); }