Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 155 additions & 2 deletions .github/scripts/install-app-store-provisioning-profile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ note() {
TEAM_ID="${IOS_APPSTORE_TEAM_ID:-7WLXT3NR37}"
BUNDLE_IDENTIFIER="${IOS_APPSTORE_BUNDLE_IDENTIFIER:-com.cmux.app}"
EXTENSION_BUNDLE_IDENTIFIER="${IOS_APPSTORE_EXTENSION_BUNDLE_IDENTIFIER:-${BUNDLE_IDENTIFIER}.NotificationService}"
CLOUD_VPN_BUNDLE_IDENTIFIER="${IOS_APPSTORE_CLOUD_VPN_BUNDLE_IDENTIFIER:-${BUNDLE_IDENTIFIER}.CloudVPN}"
EXPECTED_APP_ID="${TEAM_ID}.${BUNDLE_IDENTIFIER}"
EXPECTED_EXTENSION_APP_ID="${TEAM_ID}.${EXTENSION_BUNDLE_IDENTIFIER}"
EXPECTED_CLOUD_VPN_APP_ID="${TEAM_ID}.${CLOUD_VPN_BUNDLE_IDENTIFIER}"
KEYCHAIN_NAME="${IOS_APPSTORE_KEYCHAIN_NAME:-ios-app-store.keychain}"
TMP_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
TMP_PROFILE="$TMP_ROOT/cmux-appstore.mobileprovision"
TMP_PLIST="$TMP_ROOT/cmux-appstore-profile.plist"
TMP_EXTENSION_PROFILE="$TMP_ROOT/cmux-appstore-extension.mobileprovision"
TMP_EXTENSION_PLIST="$TMP_ROOT/cmux-appstore-extension-profile.plist"
TMP_CLOUD_VPN_PROFILE="$TMP_ROOT/cmux-appstore-cloud-vpn.mobileprovision"
TMP_CLOUD_VPN_PLIST="$TMP_ROOT/cmux-appstore-cloud-vpn-profile.plist"
ENV_OUTPUT="${GITHUB_ENV:-$TMP_ROOT/cmux-appstore.env}"
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
RESOLVED_PROFILE_NAME=""
RESOLVED_PROFILE_UUID=""
EXTENSION_PROFILE_NAME=""
EXTENSION_PROFILE_UUID=""
CLOUD_VPN_PROFILE_NAME=""
CLOUD_VPN_PROFILE_UUID=""
EXPECTED_CERT_SHA256=""

validate_profile() {
Expand Down Expand Up @@ -91,17 +97,35 @@ validate_extension_profile() {
local profile_path="$1"
local plist_path="$2"
local label="$3"
local expected_app_id="${4:-$EXPECTED_EXTENSION_APP_ID}"
local require_network_extension="${5:-false}"

if ! security cms -D -i "$profile_path" > "$plist_path"; then
note "$label is not a readable provisioning profile"
return 1
fi
local app_id
app_id="$($PLISTBUDDY -c "Print :Entitlements:application-identifier" "$plist_path" 2>/dev/null || true)"
if [ "$app_id" != "$EXPECTED_EXTENSION_APP_ID" ]; then
note "$label targets unexpected app ID: ${app_id:-<absent>} (expected $EXPECTED_EXTENSION_APP_ID)"
if [ "$app_id" != "$expected_app_id" ]; then
note "$label targets unexpected app ID: ${app_id:-<absent>} (expected $expected_app_id)"
return 1
fi
if [ "$require_network_extension" = "true" ]; then
if ! python3 - "$plist_path" <<'PY'
import plistlib
import sys

with open(sys.argv[1], "rb") as handle:
entitlements = plistlib.load(handle).get("Entitlements", {})
values = entitlements.get("com.apple.developer.networking.networkextension", [])
if "packet-tunnel-provider" not in values:
raise SystemExit(1)
PY
then
note "$label does not authorize packet-tunnel-provider"
return 1
fi
fi
if ! python3 - "$plist_path" "$EXPECTED_CERT_SHA256" <<'PY'
import hashlib
import os
Expand Down Expand Up @@ -155,6 +179,13 @@ install_extension_profile() {
note "installed App Store extension profile '$EXTENSION_PROFILE_NAME'"
}

install_cloud_vpn_profile() {
mkdir -p "$PROFILE_DIR"
cp "$TMP_CLOUD_VPN_PROFILE" "$PROFILE_DIR/$CLOUD_VPN_PROFILE_UUID.mobileprovision"
echo "IOS_APPSTORE_CLOUD_VPN_PROVISIONING_PROFILE_NAME=$CLOUD_VPN_PROFILE_NAME" >> "$ENV_OUTPUT"
note "installed App Store CloudVPN profile '$CLOUD_VPN_PROFILE_NAME'"
}

try_secret_profile() {
local label="$1"
local value="$2"
Expand Down Expand Up @@ -187,6 +218,23 @@ try_secret_extension_profile() {
return 1
}

try_secret_cloud_vpn_profile() {
local label="$1"
local value="$2"
if [ -z "$value" ]; then
return 1
fi

printf '%s' "$value" | base64 --decode > "$TMP_CLOUD_VPN_PROFILE"
if validate_extension_profile "$TMP_CLOUD_VPN_PROFILE" "$TMP_CLOUD_VPN_PLIST" "$label" "$EXPECTED_CLOUD_VPN_APP_ID" true; then
CLOUD_VPN_PROFILE_NAME="$($PLISTBUDDY -c "Print :Name" "$TMP_CLOUD_VPN_PLIST")"
CLOUD_VPN_PROFILE_UUID="$($PLISTBUDDY -c "Print :UUID" "$TMP_CLOUD_VPN_PLIST")"
install_cloud_vpn_profile
return 0
fi
return 1
}

try_installed_extension_profile() {
local profile_path app_id
for profile_path in "$PROFILE_DIR"/*.mobileprovision; do
Expand All @@ -207,6 +255,28 @@ try_installed_extension_profile() {
return 1
}

try_installed_cloud_vpn_profile() {
local profile_path app_id
for profile_path in "$PROFILE_DIR"/*.mobileprovision; do
[ -f "$profile_path" ] || continue
if ! security cms -D -i "$profile_path" > "$TMP_CLOUD_VPN_PLIST" 2>/dev/null; then
continue
fi
app_id="$($PLISTBUDDY -c "Print :Entitlements:application-identifier" "$TMP_CLOUD_VPN_PLIST" 2>/dev/null || true)"
if [ "$app_id" != "$EXPECTED_CLOUD_VPN_APP_ID" ]; then
continue
fi
cp "$profile_path" "$TMP_CLOUD_VPN_PROFILE"
if validate_extension_profile "$TMP_CLOUD_VPN_PROFILE" "$TMP_CLOUD_VPN_PLIST" "installed CloudVPN profile" "$EXPECTED_CLOUD_VPN_APP_ID" true; then
CLOUD_VPN_PROFILE_NAME="$($PLISTBUDDY -c "Print :Name" "$TMP_CLOUD_VPN_PLIST")"
CLOUD_VPN_PROFILE_UUID="$($PLISTBUDDY -c "Print :UUID" "$TMP_CLOUD_VPN_PLIST")"
install_cloud_vpn_profile
return 0
fi
done
return 1
}

resolve_expected_cert_fingerprint() {
# Best effort: an empty fingerprint skips the certificate check in
# validate_extension_profile. Read the certificate in two steps so a
Expand Down Expand Up @@ -341,9 +411,11 @@ PY
ensure_extension_profile_from_asc() {
resolve_expected_cert_fingerprint
if try_secret_extension_profile "extension profile secret" "${IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_BASE64:-}"; then
ensure_cloud_vpn_profile_if_enabled
return 0
fi
if try_installed_extension_profile; then
ensure_cloud_vpn_profile_if_enabled
return 0
fi

Expand Down Expand Up @@ -408,6 +480,87 @@ ensure_extension_profile_from_asc() {
validate_extension_profile "$TMP_EXTENSION_PROFILE" "$TMP_EXTENSION_PLIST" "downloaded profile '$profile_name'" ||
die "downloaded extension profile '$profile_name' is not usable"
install_extension_profile
ensure_cloud_vpn_profile_if_enabled
}

ensure_cloud_vpn_profile_from_asc() {
resolve_expected_cert_fingerprint
if try_secret_cloud_vpn_profile "CloudVPN profile secret" "${IOS_APPSTORE_CLOUD_VPN_PROVISIONING_PROFILE_BASE64:-}"; then
return 0
fi
if try_installed_cloud_vpn_profile; then
return 0
fi

command -v asc >/dev/null || die "release upload CLI is required"
command -v python3 >/dev/null || die "python3 is required"
command -v openssl >/dev/null || die "openssl is required"

export ASC_KEY_ID="${ASC_KEY_ID:-${ASC_API_KEY_ID:-}}"
export ASC_ISSUER_ID="${ASC_ISSUER_ID:-${ASC_API_ISSUER_ID:-}}"
export ASC_PRIVATE_KEY_PATH="${ASC_PRIVATE_KEY_PATH:-${ASC_API_KEY_PATH:-}}"
if [ -z "${ASC_KEY_ID:-}" ] || [ -z "${ASC_ISSUER_ID:-}" ] || [ -z "${ASC_PRIVATE_KEY_PATH:-}" ]; then
die "upload credentials are required to fetch the CloudVPN profile"
fi

local cert_pem cert_serial
cert_pem="$TMP_ROOT/ios-distribution-cert.pem"
security find-certificate -c "$IOS_DISTRIBUTION_IDENTITY" -p "$KEYCHAIN_NAME" > "$cert_pem" ||
die "could not read imported distribution certificate from $KEYCHAIN_NAME"
cert_serial="$(openssl x509 -in "$cert_pem" -noout -serial | sed 's/^serial=//' | tr '[:lower:]' '[:upper:]')"
cert_serial="$(printf '%s' "$cert_serial" | tr -cd '[:alnum:]')"
[ -n "$cert_serial" ] || die "could not resolve imported distribution certificate serial"
EXPECTED_CERT_SHA256="$(security find-certificate -c "$IOS_DISTRIBUTION_IDENTITY" -p "$KEYCHAIN_NAME" | openssl x509 -outform DER | openssl dgst -sha256 -r | awk '{print toupper($1)}')"
[ -n "$EXPECTED_CERT_SHA256" ] || die "could not fingerprint imported distribution certificate"

local bundles_json certs_json profiles_json created_json bundle_id certificate_id profile_id profile_name profile_suffix
bundles_json="$TMP_ROOT/asc-bundle-ids.json"
certs_json="$TMP_ROOT/asc-certificates.json"
profiles_json="$TMP_ROOT/asc-cloud-vpn-profiles.json"
created_json="$TMP_ROOT/asc-created-cloud-vpn-profile.json"

asc bundle-ids list --paginate --output json > "$bundles_json"
bundle_id="$(json_id_by_bundle_identifier "$bundles_json" "$CLOUD_VPN_BUNDLE_IDENTIFIER")" ||
die "configured CloudVPN bundle id not found for $CLOUD_VPN_BUNDLE_IDENTIFIER"

asc certificates list --certificate-type IOS_DISTRIBUTION,DISTRIBUTION --paginate --output json > "$certs_json"
certificate_id="$(json_certificate_id_by_serial "$certs_json" "$cert_serial" || true)"
if [ -z "$certificate_id" ]; then
print_certificate_summary "$certs_json"
die "matching distribution certificate not found for imported certificate serial suffix ${cert_serial: -8}"
fi

profile_suffix="${cert_serial: -8}"
profile_name="cmux App Store CloudVPN CI $profile_suffix"
asc profiles list --profile-type IOS_APP_STORE --paginate --output json > "$profiles_json"
profile_id="$(json_active_profile_id_by_name "$profiles_json" "$profile_name" || true)"
if [ -z "$profile_id" ]; then
note "creating App Store CloudVPN profile '$profile_name'"
asc profiles create \
--name "$profile_name" \
--profile-type IOS_APP_STORE \
--bundle "$bundle_id" \
--certificate "$certificate_id" \
--output json > "$created_json"
profile_id="$(json_single_id "$created_json")" ||
die "could not read created CloudVPN profile id"
else
note "reusing App Store CloudVPN profile '$profile_name'"
fi

rm -f "$TMP_CLOUD_VPN_PROFILE"
asc profiles download --id "$profile_id" --output "$TMP_CLOUD_VPN_PROFILE" >/dev/null
validate_extension_profile "$TMP_CLOUD_VPN_PROFILE" "$TMP_CLOUD_VPN_PLIST" "downloaded CloudVPN profile '$profile_name'" "$EXPECTED_CLOUD_VPN_APP_ID" true ||
die "downloaded CloudVPN profile '$profile_name' is not usable"
CLOUD_VPN_PROFILE_NAME="$($PLISTBUDDY -c "Print :Name" "$TMP_CLOUD_VPN_PLIST")"
CLOUD_VPN_PROFILE_UUID="$($PLISTBUDDY -c "Print :UUID" "$TMP_CLOUD_VPN_PLIST")"
install_cloud_vpn_profile
}

ensure_cloud_vpn_profile_if_enabled() {
if [ "${IOS_APPSTORE_ENABLE_CLOUD_VPN:-0}" = "1" ]; then
ensure_cloud_vpn_profile_from_asc
fi
}

download_profile_from_asc() {
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ios-app-store.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ jobs:
env:
IOS_APPSTORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_PROVISIONING_PROFILE_BASE64 }}
IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_BASE64 }}
IOS_APPSTORE_ENABLE_CLOUD_VPN: "1"
IOS_PROD_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROD_PROVISIONING_PROFILE_BASE64 }}
IOS_BETA_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_BETA_PROVISIONING_PROFILE_BASE64 }}
APPLE_RELEASE_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_RELEASE_PROVISIONING_PROFILE_BASE64 }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ios-appstore-upload.yml
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ jobs:
env:
IOS_PROD_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROD_PROVISIONING_PROFILE_BASE64 }}
IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_BASE64 }}
IOS_APPSTORE_ENABLE_CLOUD_VPN: "1"
IOS_APPSTORE_KEYCHAIN_NAME: ios-appstore.keychain
run: ./.github/scripts/install-app-store-provisioning-profile.sh

Expand Down
100 changes: 100 additions & 0 deletions ios/scripts/upload-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,92 @@ verify_ipa_aps_environment_production() {
return 0
}

verify_ipa_cloud_vpn_extension() {
local ipa="$1"
local workdir app extension ent profile
local bundle_id expected_bundle_id expected_app_id app_id team_id network_extension
local profile_app_id profile_network_extension
workdir="$(mktemp -d)"
if ! ( cd "$workdir" && unzip -q "$ipa" ); then
echo "error: could not unzip IPA to verify CloudVPN signing: $ipa" >&2
rm -rf "$workdir"
return 1
fi
app="$(find "$workdir/Payload" -maxdepth 1 -name '*.app' -type d 2>/dev/null | head -n 1)"
extension="$app/PlugIns/CloudVPN.appex"
if [[ -z "$app" || ! -d "$extension" ]]; then
echo "error: App Store IPA is missing CloudVPN.appex: $ipa" >&2
rm -rf "$workdir"
return 1
fi
if [[ ! -f "$extension/embedded.mobileprovision" ]]; then
echo "error: CloudVPN.appex has no embedded App Store provisioning profile: $extension" >&2
rm -rf "$workdir"
return 1
fi
if ! codesign --verify --strict --verbose=2 "$extension" >&2; then
echo "error: CloudVPN.appex failed code-signature verification: $extension" >&2
rm -rf "$workdir"
return 1
fi
ent="$workdir/CloudVPN.entitlements.plist"
if ! codesign -d --entitlements :- --xml "$extension" > "$ent" 2>/dev/null; then
echo "error: could not read signed CloudVPN entitlements: $ipa" >&2
rm -rf "$workdir"
return 1
fi
bundle_id="$($PLISTBUDDY -c 'Print :CFBundleIdentifier' "$extension/Info.plist" 2>/dev/null || true)"
app_id="$($PLISTBUDDY -c 'Print :application-identifier' "$ent" 2>/dev/null || true)"
team_id="$($PLISTBUDDY -c 'Print :com.apple.developer.team-identifier' "$ent" 2>/dev/null || true)"
network_extension="$($PLISTBUDDY -c 'Print :com.apple.developer.networking.networkextension:0' "$ent" 2>/dev/null || true)"
expected_bundle_id="$CLOUD_VPN_BUNDLE_IDENTIFIER"
expected_app_id="$DEVELOPMENT_TEAM.$expected_bundle_id"
if [[ "$bundle_id" != "$expected_bundle_id" || "$app_id" != "$expected_app_id" || "$team_id" != "$DEVELOPMENT_TEAM" ]] ||
! python3 - "$ent" <<'PY'
import plistlib
import sys

with open(sys.argv[1], "rb") as handle:
entitlements = plistlib.load(handle)
values = entitlements.get("com.apple.developer.networking.networkextension", [])
if "packet-tunnel-provider" not in values:
raise SystemExit(1)
PY
then
echo "error: signed CloudVPN identity is invalid (bundle-id='${bundle_id:-<absent>}', expected-bundle-id='$expected_bundle_id', application-identifier='${app_id:-<absent>}', expected='$expected_app_id', team='${team_id:-<absent>}', network-extension='${network_extension:-<absent>}'): $extension" >&2
plutil -p "$ent" >&2 || true
rm -rf "$workdir"
return 1
fi
profile="$workdir/CloudVPN.profile.plist"
if ! security cms -D -i "$extension/embedded.mobileprovision" > "$profile" 2>/dev/null; then
echo "error: could not decode CloudVPN.appex provisioning profile: $extension" >&2
rm -rf "$workdir"
return 1
fi
profile_app_id="$($PLISTBUDDY -c 'Print :Entitlements:application-identifier' "$profile" 2>/dev/null || true)"
profile_network_extension="$($PLISTBUDDY -c 'Print :Entitlements:com.apple.developer.networking.networkextension:0' "$profile" 2>/dev/null || true)"
if [[ "$profile_app_id" != "$expected_app_id" ]] ||
! python3 - "$profile" <<'PY'
import plistlib
import sys

with open(sys.argv[1], "rb") as handle:
entitlements = plistlib.load(handle).get("Entitlements", {})
values = entitlements.get("com.apple.developer.networking.networkextension", [])
if "packet-tunnel-provider" not in values:
raise SystemExit(1)
PY
then
echo "error: embedded CloudVPN profile does not authorize the signed packet tunnel (application-identifier='${profile_app_id:-<absent>}', network-extension='${profile_network_extension:-<absent>}'): $extension" >&2
plutil -p "$profile" >&2 || true
rm -rf "$workdir"
return 1
fi
rm -rf "$workdir"
return 0
}

verify_ipa_app_store_main_entitlements() {
local ipa="$1"
local workdir app ent
Expand Down Expand Up @@ -831,6 +917,7 @@ NOTIFICATION_SERVICE_BUNDLE_IDENTIFIER="$(bash "$SCRIPT_DIR/notification-service
WORKSPACE="$IOS_DIR/cmux.xcworkspace"
SCHEME="cmux-ios"
DEVELOPMENT_TEAM="${IOS_DEVELOPMENT_TEAM:-7WLXT3NR37}"
CLOUD_VPN_BUNDLE_IDENTIFIER="${PRODUCT_BUNDLE_IDENTIFIER}.CloudVPN"
SHARED_XCCONFIG="$IOS_DIR/Config/Shared.xcconfig"
CHECKED_IN_BETA_MARKETING_VERSION="$(read_xcconfig_setting CMUX_IOS_BETA_MARKETING_VERSION "$SHARED_XCCONFIG")"
CHECKED_IN_APPSTORE_MARKETING_VERSION="$(read_xcconfig_setting CMUX_IOS_APPSTORE_MARKETING_VERSION "$SHARED_XCCONFIG")"
Expand Down Expand Up @@ -1316,6 +1403,14 @@ else
exit 1
fi
"$PLISTBUDDY" -c "Add :provisioningProfiles:$EXTENSION_BUNDLE_IDENTIFIER string $EXTENSION_PROFILE_NAME" "$EXPORT_OPTIONS"
if [[ "$LANE" == "appstore" ]]; then
CLOUD_VPN_PROFILE_NAME="${IOS_APPSTORE_CLOUD_VPN_PROVISIONING_PROFILE_NAME:-}"
if [[ -z "$CLOUD_VPN_PROFILE_NAME" ]]; then
echo "error: manual App Store export needs a provisioning profile name for $CLOUD_VPN_BUNDLE_IDENTIFIER" >&2
exit 1
fi
"$PLISTBUDDY" -c "Add :provisioningProfiles:$CLOUD_VPN_BUNDLE_IDENTIFIER string $CLOUD_VPN_PROFILE_NAME" "$EXPORT_OPTIONS"
fi
fi
fi

Expand Down Expand Up @@ -1666,6 +1761,11 @@ if [[ "$LANE" == "appstore" ]]; then
exit 1
fi
echo "App Store IPA verified to omit unsupported iOS main-app entitlements: $IPA_PATH"
if ! verify_ipa_cloud_vpn_extension "$IPA_PATH"; then
echo "error: App Store IPA CloudVPN extension is not signed with its packet-tunnel profile; refusing to upload" >&2
exit 1
fi
echo "App Store IPA verified to carry a signed CloudVPN packet-tunnel extension: $IPA_PATH"
fi

if [[ "$EXPORT_ONLY" -eq 1 ]]; then
Expand Down
Loading
Loading