diff --git a/.github/scripts/install-app-store-provisioning-profile.sh b/.github/scripts/install-app-store-provisioning-profile.sh index 86c3c7e11481..3801d85a5f4d 100755 --- a/.github/scripts/install-app-store-provisioning-profile.sh +++ b/.github/scripts/install-app-store-provisioning-profile.sh @@ -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() { @@ -91,6 +97,8 @@ 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" @@ -98,10 +106,26 @@ validate_extension_profile() { 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:-} (expected $EXPECTED_EXTENSION_APP_ID)" + if [ "$app_id" != "$expected_app_id" ]; then + note "$label targets unexpected app ID: ${app_id:-} (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 @@ -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" @@ -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 @@ -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 @@ -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 @@ -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() { diff --git a/.github/workflows/ios-app-store.yml b/.github/workflows/ios-app-store.yml index 0d6098a9bb35..f21d72cf5374 100644 --- a/.github/workflows/ios-app-store.yml +++ b/.github/workflows/ios-app-store.yml @@ -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 }} diff --git a/.github/workflows/ios-appstore-upload.yml b/.github/workflows/ios-appstore-upload.yml index 83552d0f4719..4bb12c6c0fb0 100644 --- a/.github/workflows/ios-appstore-upload.yml +++ b/.github/workflows/ios-appstore-upload.yml @@ -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 diff --git a/ios/scripts/upload-testflight.sh b/ios/scripts/upload-testflight.sh index 7b6945081708..d498dac80920 100755 --- a/ios/scripts/upload-testflight.sh +++ b/ios/scripts/upload-testflight.sh @@ -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:-}', expected-bundle-id='$expected_bundle_id', application-identifier='${app_id:-}', expected='$expected_app_id', team='${team_id:-}', network-extension='${network_extension:-}'): $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:-}', network-extension='${profile_network_extension:-}'): $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 @@ -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")" @@ -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 @@ -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 diff --git a/tests/test_ios_appstore_lane_identity.py b/tests/test_ios_appstore_lane_identity.py index 2a889d54bf21..d74952285491 100644 --- a/tests/test_ios_appstore_lane_identity.py +++ b/tests/test_ios_appstore_lane_identity.py @@ -30,6 +30,8 @@ APPSTORE_APP_ID = f"{TEAM_ID}.{APPSTORE_BUNDLE_ID}" APPSTORE_EXTENSION_BUNDLE_ID = f"{APPSTORE_BUNDLE_ID}.NotificationService" APPSTORE_EXTENSION_PROFILE_NAME = "cmux App Store Notification Service Distribution" +APPSTORE_CLOUD_VPN_BUNDLE_ID = f"{APPSTORE_BUNDLE_ID}.CloudVPN" +APPSTORE_CLOUD_VPN_PROFILE_NAME = "cmux App Store CloudVPN Distribution" BETA_BUNDLE_ID = "dev.cmux.app.beta" BETA_APP_ID = f"{TEAM_ID}.{BETA_BUNDLE_ID}" ASC_APP_ID = "6783338052" @@ -110,6 +112,23 @@ def _extension_profile_plist() -> dict[str, object]: return profile +def _cloud_vpn_profile_plist() -> dict[str, object]: + profile = _profile_plist( + APPSTORE_CLOUD_VPN_BUNDLE_ID, + APPSTORE_CLOUD_VPN_PROFILE_NAME, + "00000000-0000-0000-0000-000000000005", + ) + entitlements = profile["Entitlements"] + assert isinstance(entitlements, dict) + entitlements.pop("aps-environment", None) + entitlements.pop("com.apple.developer.usernotifications.time-sensitive", None) + entitlements["com.apple.developer.networking.networkextension"] = [ + "app-proxy-provider", + "packet-tunnel-provider", + ] + return profile + + def _write_executable(path: Path, body: str) -> None: path.write_text(body, encoding="utf-8") path.chmod(path.stat().st_mode | stat.S_IXUSR) @@ -128,6 +147,8 @@ def _install_fake_tools(fakebin: Path) -> None: APPSTORE_EXTENSION_BUNDLE_ID = {APPSTORE_EXTENSION_BUNDLE_ID!r} APPSTORE_EXTENSION_APP_ID = TEAM_ID + "." + APPSTORE_EXTENSION_BUNDLE_ID APPSTORE_EXTENSION_PROFILE_NAME = {APPSTORE_EXTENSION_PROFILE_NAME!r} +APPSTORE_CLOUD_VPN_BUNDLE_ID = {APPSTORE_CLOUD_VPN_BUNDLE_ID!r} +APPSTORE_CLOUD_VPN_PROFILE_NAME = {APPSTORE_CLOUD_VPN_PROFILE_NAME!r} BETA_BUNDLE_ID = {BETA_BUNDLE_ID!r} BETA_APP_ID = {BETA_APP_ID!r} IDENTITY = {IDENTITY!r} @@ -142,6 +163,7 @@ def write_plist(path, value): APPSTORE_PROFILE = plistlib.loads({_plist_bytes(_profile_plist())!r}) BETA_PROFILE = plistlib.loads({_plist_bytes(_profile_plist(BETA_BUNDLE_ID, "cmux Beta Distribution Test", "00000000-0000-0000-0000-000000000002"))!r}) EXTENSION_PROFILE = plistlib.loads({_plist_bytes(_extension_profile_plist())!r}) +APPSTORE_CLOUD_VPN_PROFILE = plistlib.loads({_plist_bytes(_cloud_vpn_profile_plist())!r}) BETA_EXTENSION_PROFILE = plistlib.loads({_plist_bytes(_profile_plist(BETA_BUNDLE_ID + ".NotificationServiceV2", "cmux Beta Notification Service Distribution", "00000000-0000-0000-0000-000000000004"))!r}) BETA_EXTENSION_PROFILE["Entitlements"]["keychain-access-groups"] = [TEAM_ID + ".*"] FIXTURE_CERTIFICATE = {ssl.DER_cert_to_PEM_cert(FIXTURE_CERTIFICATE_DER)!r} @@ -153,6 +175,8 @@ def profile_for_bundle(bundle_id): source = BETA_EXTENSION_PROFILE elif bundle_id == APPSTORE_EXTENSION_BUNDLE_ID: source = EXTENSION_PROFILE + elif bundle_id == APPSTORE_CLOUD_VPN_BUNDLE_ID: + source = APPSTORE_CLOUD_VPN_PROFILE else: source = APPSTORE_PROFILE if os.environ.get("CMUX_FAKE_PROFILE_MISSING_TIME_SENSITIVE") != "1": @@ -181,6 +205,8 @@ def entitlements_for_bundle(bundle_id): "get-task-allow": False, }} entitlements = dict(profile_for_bundle(bundle_id)["Entitlements"]) + if bundle_id == APPSTORE_CLOUD_VPN_BUNDLE_ID: + entitlements["com.apple.developer.networking.networkextension"] = ["packet-tunnel-provider"] override_group = os.environ.get("CMUX_FAKE_SIGNED_KEYCHAIN_GROUP") if override_group: entitlements["keychain-access-groups"] = [override_group] @@ -448,6 +474,13 @@ def setting(prefix): "CMUXKeychainAccessGroup": bundle_id, }}, ) + cloud_vpn = app / "PlugIns" / "CloudVPN.appex" + write_plist( + cloud_vpn / "Info.plist", + {{ + "CFBundleIdentifier": f"{{bundle_id}}.CloudVPN", + }}, + ) # upload-testflight.sh refuses archives without dSYM bundles. (archive / "dSYMs" / "cmux.app.dSYM" / "Contents").mkdir(parents=True, exist_ok=True) sys.exit(0) @@ -472,6 +505,13 @@ def setting(prefix): "CMUXKeychainAccessGroup": bundle_id, }}, ) + cloud_vpn = app / "PlugIns" / "CloudVPN.appex" + write_plist( + cloud_vpn / "Info.plist", + {{ + "CFBundleIdentifier": f"{{bundle_id}}.CloudVPN", + }}, + ) if os.environ.get("CMUX_FAKE_EMBED_INVALID_FRAMEWORK_SHELL") == "1": write_plist( app / "Frameworks" / "Iroh.framework" / "Info.plist", @@ -492,6 +532,8 @@ def setting(prefix): profile_marker = "beta profile" if bundle_id == BETA_BUNDLE_ID else "fake profile" (app / "embedded.mobileprovision").write_text(profile_marker, encoding="utf-8") (extension / "embedded.mobileprovision").write_text("extension profile", encoding="utf-8") + (cloud_vpn / "embedded.mobileprovision").write_text("cloud vpn profile", encoding="utf-8") + write_plist(cloud_vpn / "FakeSignedEntitlements.plist", entitlements_for_bundle(f"{{bundle_id}}.CloudVPN")) # upload-testflight.sh refuses IPAs without Symbols/*.symbols. symbols_root = export_path / "Symbols" symbols_root.mkdir(parents=True, exist_ok=True) @@ -575,6 +617,17 @@ def setting(prefix): profile = profile_for_bundle(BETA_BUNDLE_ID) elif b"beta extension profile" in body: profile = BETA_EXTENSION_PROFILE + elif b"cloud vpn profile" in body: + profile = copy.deepcopy(APPSTORE_CLOUD_VPN_PROFILE) + try: + cloud_vpn_info = source.parent / "Info.plist" + cloud_vpn_bundle_id = plistlib.loads(cloud_vpn_info.read_bytes()).get( + "CFBundleIdentifier", "" + ) + profile["Entitlements"] = dict(profile["Entitlements"]) + profile["Entitlements"]["application-identifier"] = f"{{TEAM_ID}}.{{cloud_vpn_bundle_id}}" + except (OSError, plistlib.InvalidFileException): + pass elif b"extension profile" in body: profile = copy.deepcopy(EXTENSION_PROFILE) try: @@ -650,6 +703,9 @@ def _base_env(tmp: Path, fakebin: Path) -> dict[str, str]: # profile (#12935); the lane refuses to export without the name. env["IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_NAME"] = APPSTORE_EXTENSION_PROFILE_NAME env["IOS_APPSTORE_EXTENSION_PROVISIONING_PROFILE_BASE64"] = base64.b64encode(b"extension profile").decode() + env["IOS_APPSTORE_ENABLE_CLOUD_VPN"] = "1" + env["IOS_APPSTORE_CLOUD_VPN_PROVISIONING_PROFILE_NAME"] = APPSTORE_CLOUD_VPN_PROFILE_NAME + env["IOS_APPSTORE_CLOUD_VPN_PROVISIONING_PROFILE_BASE64"] = base64.b64encode(b"cloud vpn profile").decode() env["IOS_BETA_EXTENSION_PROVISIONING_PROFILE_NAME"] = "cmux Beta Notification Service Distribution" # Profile expiry is validated against this fixed instant, not the real clock. env["IOS_APPSTORE_PROFILE_VALIDATION_TIME"] = PROFILE_VALIDATION_TIME @@ -1294,6 +1350,10 @@ def test_upload_appstore_lane_uses_production_bundle_id(tmp: Path, fakebin: Path profiles.get(APPSTORE_EXTENSION_BUNDLE_ID) == APPSTORE_EXTENSION_PROFILE_NAME, "export options map the notification extension to its App Store profile", ) + _check( + profiles.get(APPSTORE_CLOUD_VPN_BUNDLE_ID) == APPSTORE_CLOUD_VPN_PROFILE_NAME, + "export options map CloudVPN to its packet-tunnel App Store profile", + ) _check("com.cmuxterm.app" not in profiles, "export options do not include the retired app id") ipa_line = next(line for line in result.stdout.splitlines() if line.startswith("IPA_PATH=")) @@ -1314,6 +1374,9 @@ def test_upload_appstore_lane_uses_production_bundle_id(tmp: Path, fakebin: Path in zf.namelist() else {} ) + cloud_vpn_entitlements = plistlib.loads( + zf.read("Payload/cmux.app/PlugIns/CloudVPN.appex/FakeSignedEntitlements.plist") + ) _check(info.get("CFBundleIdentifier") == APPSTORE_BUNDLE_ID, "final signed IPA Info.plist is com.cmux.app") _check( info.get("CMUXKeychainAccessGroup") == APPSTORE_APP_ID, @@ -1327,6 +1390,16 @@ def test_upload_appstore_lane_uses_production_bundle_id(tmp: Path, fakebin: Path extension_entitlements.get("keychain-access-groups") == [APPSTORE_APP_ID], "notification extension signature carries the exact App Store keychain group", ) + _check( + cloud_vpn_entitlements.get("application-identifier") + == f"{TEAM_ID}.{APPSTORE_CLOUD_VPN_BUNDLE_ID}", + "CloudVPN signature carries its exact App Store application identifier", + ) + _check( + cloud_vpn_entitlements.get("com.apple.developer.networking.networkextension") + == ["packet-tunnel-provider"], + "CloudVPN signature carries the packet-tunnel-provider entitlement", + ) _check( info.get("CFBundleShortVersionString") == APPSTORE_MARKETING_VERSION, "final signed IPA keeps the App Store marketing version", @@ -1367,6 +1440,7 @@ def test_upload_appstore_checks_asc_app_bundle_id_before_upload(tmp: Path, fakeb env = _asc_upload_env(tmp, fakebin) env["CMUX_IOS_UPLOAD_DIR"] = str(tmp / "upload") env["CMUX_BUILD_NUMBER_OUT_FILE"] = str(tmp / "build-number.txt") + env["CMUX_TESTFLIGHT_NOTES_REQUEST_FILE"] = str(tmp / "testflight-notes-request.json") result = _run( [ "bash", @@ -1428,8 +1502,12 @@ def test_profile_installer_accepts_production_profile_by_default(tmp: Path, fake "profile installer exports a separate NotificationService profile name", ) _check( - len(list((Path(env["HOME"]) / "Library/MobileDevice/Provisioning Profiles").glob("*.mobileprovision"))) == 2, - "profile installer keeps distinct app and extension profile files", + f"IOS_APPSTORE_CLOUD_VPN_PROVISIONING_PROFILE_NAME={APPSTORE_CLOUD_VPN_PROFILE_NAME}" in github_env, + "profile installer exports a separate CloudVPN profile name", + ) + _check( + len(list((Path(env["HOME"]) / "Library/MobileDevice/Provisioning Profiles").glob("*.mobileprovision"))) == 3, + "profile installer keeps distinct app, notification, and CloudVPN profile files", )