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:
parent
1733f697a7
commit
0340e794b8
3 changed files with 119 additions and 4 deletions
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
39
scripts/derive_sparkle_public_key.swift
Normal file
39
scripts/derive_sparkle_public_key.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue