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
This commit is contained in:
Lawrence Chen 2026-02-05 19:57:01 -08:00 committed by GitHub
parent 1733f697a7
commit 0340e794b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 119 additions and 4 deletions

View file

@ -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:

View file

@ -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 <base64-private-key>\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)
}

View file

@ -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