From 0340e794b88eda8928c0d3c6ab1d69524c3f073e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:57:01 -0800 Subject: [PATCH] Fix Sparkle auto-update: inject SUPublicEDKey into Info.plist via PlistBuddy (#15) Root cause: INFOPLIST_KEY_ build setting prefix only works for Apple-recognized keys (CF*, NS*, LS*), not custom keys like SUPublicEDKey. The key was never being added to Info.plist, so generate_appcast silently skipped EdDSA signing (no public key in app = nothing to match against). Fix: - Derive public key from private key at build time using CryptoKit - Use PlistBuddy to inject SUPublicEDKey and SUFeedURL after build - Add sign_update fallback in appcast script if generate_appcast skips signing - Add base64 padding normalization for key handling --- .github/workflows/release.yml | 27 ++++++++++-- scripts/derive_sparkle_public_key.swift | 39 +++++++++++++++++ scripts/sparkle_generate_appcast.sh | 57 ++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 scripts/derive_sparkle_public_key.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3f0076f..d93b6e85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,11 +63,32 @@ jobs: mkdir -p "$CACHE_DIR" echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV" + - name: Derive Sparkle public key from private key + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + if [ -z "$SPARKLE_PRIVATE_KEY" ]; then + echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 + exit 1 + fi + DERIVED_PUBLIC_KEY=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY") + echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY" + echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" + - name: Build app (Release) run: | - xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO SPARKLE_PUBLIC_KEY="${SPARKLE_PUBLIC_KEY}" build - env: - SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }} + xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build + + - name: Inject Sparkle keys into Info.plist + run: | + APP_PLIST="build/Build/Products/Release/cmuxterm.app/Contents/Info.plist" + echo "Adding SUPublicEDKey to Info.plist..." + /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST" + echo "Adding SUFeedURL to Info.plist..." + /usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmuxterm/releases/latest/download/appcast.xml" "$APP_PLIST" + echo "Verifying:" + /usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" "$APP_PLIST" + /usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST" - name: Import signing cert env: diff --git a/scripts/derive_sparkle_public_key.swift b/scripts/derive_sparkle_public_key.swift new file mode 100644 index 00000000..c847db5e --- /dev/null +++ b/scripts/derive_sparkle_public_key.swift @@ -0,0 +1,39 @@ +#!/usr/bin/env swift +import CryptoKit +import Foundation + +// Derives the Ed25519 public key from a Sparkle private key (base64-encoded). +// Supports both new format (32-byte seed) and old format (96-byte key+pub). + +guard CommandLine.arguments.count > 1 else { + fputs("Usage: derive_sparkle_public_key.swift \n", stderr) + exit(1) +} + +// Pad base64 string if needed (Sparkle keys may be stored without padding) +var b64 = CommandLine.arguments[1] +while b64.count % 4 != 0 { + b64 += "=" +} +guard let data = Data(base64Encoded: b64) else { + fputs("Error: invalid base64 input\n", stderr) + exit(1) +} + +if data.count == 32 { + // New format: 32-byte Ed25519 seed + do { + let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: data) + print(privateKey.publicKey.rawRepresentation.base64EncodedString()) + } catch { + fputs("Error deriving key: \(error)\n", stderr) + exit(1) + } +} else if data.count == 96 { + // Old format: 64-byte private key + 32-byte public key + let pubKeyData = data[64...] + print(pubKeyData.base64EncodedString()) +} else { + fputs("Error: unexpected key length \(data.count) (expected 32 or 96)\n", stderr) + exit(1) +} diff --git a/scripts/sparkle_generate_appcast.sh b/scripts/sparkle_generate_appcast.sh index 4519927e..2266c6f3 100755 --- a/scripts/sparkle_generate_appcast.sh +++ b/scripts/sparkle_generate_appcast.sh @@ -37,18 +37,38 @@ xcodebuild \ CODE_SIGNING_ALLOWED=NO \ build >/dev/null +echo "Building Sparkle sign_update tool..." +xcodebuild \ + -project "$work_dir/Sparkle/Sparkle.xcodeproj" \ + -scheme sign_update \ + -configuration Release \ + -derivedDataPath "$work_dir/build" \ + CODE_SIGNING_ALLOWED=NO \ + build >/dev/null + generate_appcast="$work_dir/build/Build/Products/Release/generate_appcast" +sign_update="$work_dir/build/Build/Products/Release/sign_update" + if [[ ! -x "$generate_appcast" ]]; then echo "generate_appcast binary not found at $generate_appcast" >&2 exit 1 fi +if [[ ! -x "$sign_update" ]]; then + echo "sign_update binary not found at $sign_update" >&2 + exit 1 +fi archives_dir="$work_dir/archives" mkdir -p "$archives_dir" cp "$DMG_PATH" "$archives_dir/$(basename "$DMG_PATH")" key_file="$work_dir/sparkle_ed_key" -printf "%s" "$SPARKLE_PRIVATE_KEY" > "$key_file" +# Ensure base64 padding (keys may be stored without trailing '=') +padded_key="$SPARKLE_PRIVATE_KEY" +while (( ${#padded_key} % 4 != 0 )); do + padded_key="${padded_key}=" +done +printf "%s" "$padded_key" > "$key_file" "$generate_appcast" \ --ed-key-file "$key_file" \ @@ -61,5 +81,40 @@ if [[ ! -f "$archives_dir/appcast.xml" ]]; then exit 1 fi +# Check if generate_appcast added the edSignature. If not, use sign_update +# to sign the DMG and inject the signature. generate_appcast silently skips +# signing when the public key derived from the private key doesn't match the +# SUPublicEDKey in the app's Info.plist. +if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then + echo "Warning: generate_appcast did not add edSignature. Using sign_update fallback..." + SIGNATURE=$("$sign_update" -p --ed-key-file "$key_file" "$DMG_PATH") + DMG_LENGTH=$(stat -f%z "$DMG_PATH") + echo " EdDSA signature: ${SIGNATURE:0:20}..." + echo " DMG length: $DMG_LENGTH" + + # Inject sparkle:edSignature and correct length into the enclosure element + python3 -c " +import sys +xml = open('$archives_dir/appcast.xml').read() +sig = '$SIGNATURE' +length = '$DMG_LENGTH' +# Add edSignature to enclosure +xml = xml.replace( + 'type=\"application/octet-stream\"', + 'sparkle:edSignature=\"' + sig + '\" length=\"' + length + '\" type=\"application/octet-stream\"' +) +open('$archives_dir/appcast.xml', 'w').write(xml) +print(' Injected edSignature into appcast.xml') +" +fi + cp "$archives_dir/appcast.xml" "$OUT_PATH" echo "Generated appcast at $OUT_PATH" + +# Verify the appcast has a signature +if grep -q 'sparkle:edSignature' "$OUT_PATH"; then + echo "Verified: appcast contains sparkle:edSignature" +else + echo "ERROR: appcast is missing sparkle:edSignature!" >&2 + exit 1 +fi