From d266ab29d296a2e98058936b7a005218e2414480 Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 29 Jun 2026 17:13:03 +0300 Subject: [PATCH 1/2] Fix App Store auth: native /fast/ endpoint, bump to 2026.6.1 Apple's 26HOTFIX24 (June 2026) broke iTunes Store login (majd/ipatool#486): the bag now advertises the native auth endpoint as .../auth/v1/native (without /fast), and the endpoint only works WITH the /fast/ sub-path AND a trailing slash, otherwise Apple returns 301 + an HTML redirect page that breaks the plist parser. - store.py: read authenticateAccount from the bag root (it moved there) with a fallback to urlBag; normalize the native auth endpoint to always end with /fast/; switch the default fallback to the native /fast/ endpoint. - Bump version to 2026.6.1 (setup.py + docker-hub-publish.yml). Verified live: /fast/ login + download of owned apps work end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/docker-hub-publish.yml | 4 +- .../appstore_client/store.py | 39 +++++++++++++++---- setup.py | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker-hub-publish.yml b/.github/workflows/docker-hub-publish.yml index da51b4b..a068611 100644 --- a/.github/workflows/docker-hub-publish.yml +++ b/.github/workflows/docker-hub-publish.yml @@ -22,7 +22,7 @@ jobs: - name: Build the Docker image - run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.03.02 -t mobilesecurity/mdast_cli:latest + run: docker build . --file Dockerfile -t mobilesecurity/mdast_cli:2026.6.1 -t mobilesecurity/mdast_cli:latest - name: Docker Hub push latest image @@ -30,7 +30,7 @@ jobs: - name: Docker Hub push tagged image - run: docker push mobilesecurity/mdast_cli:2026.03.02 + run: docker push mobilesecurity/mdast_cli:2026.6.1 diff --git a/mdast_cli/distribution_systems/appstore_client/store.py b/mdast_cli/distribution_systems/appstore_client/store.py index ef4348d..519a2ad 100755 --- a/mdast_cli/distribution_systems/appstore_client/store.py +++ b/mdast_cli/distribution_systems/appstore_client/store.py @@ -9,8 +9,28 @@ # Apple "bag" service: returns endpoint definitions (auth URL). Required since ~2025. BAG_URL_TEMPLATE = "https://init.itunes.apple.com/bag.xml?guid=%s" -DEFAULT_AUTH_URL_TEMPLATE = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate?guid=%s" +# Apple's 26HOTFIX24 (June 2026) moved login to the native auth endpoint. The bag now +# advertises ".../auth/v1/native" (no "/fast"); the endpoint only works WITH the "/fast/" +# sub-path AND a trailing slash, otherwise Apple replies 301 + an HTML redirect page that +# breaks the plist parser. See majd/ipatool#486. +AUTH_HOST = "auth.itunes.apple.com" +NATIVE_FAST_PATH = "/auth/v1/native/fast/" +DEFAULT_AUTH_URL = "https://" + AUTH_HOST + NATIVE_FAST_PATH BUY_DOMAIN = "buy.itunes.apple.com" + + +def _normalize_auth_endpoint(endpoint: Optional[str]) -> Optional[str]: + """Ensure the native auth endpoint carries the '/fast/' sub-path with a trailing slash. + + Apple's bag advertises ".../auth/v1/native"; without "/fast/" the request gets a + 301 + HTML redirect that the plist parser chokes on (majd/ipatool#486). + """ + if endpoint and AUTH_HOST in endpoint: + if not (endpoint.endswith("/fast") or endpoint.endswith("/fast/")): + endpoint = endpoint.rstrip("/") + "/fast" + if not endpoint.endswith("/"): + endpoint = endpoint + "/" + return endpoint PURCHASE_PATH = "/WebObjects/MZBuy.woa/wa/buyProduct" DOWNLOAD_PATH = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" @@ -45,9 +65,14 @@ def _parse_bag_response(content: bytes) -> Optional[str]: try: data = plistlib.loads(content) if isinstance(data, dict): - url_bag = data.get("urlBag") or data.get("URLBag") - if isinstance(url_bag, dict): - return url_bag.get("authenticateAccount") or url_bag.get("authenticate") + # 'authenticateAccount' moved to the bag root (majd/ipatool#486); older bags + # keep it under 'urlBag'. Check the root first, then fall back to urlBag. + endpoint = data.get("authenticateAccount") or data.get("authenticate") + if not endpoint: + url_bag = data.get("urlBag") or data.get("URLBag") + if isinstance(url_bag, dict): + endpoint = url_bag.get("authenticateAccount") or url_bag.get("authenticate") + return endpoint return None except Exception: pass @@ -121,13 +146,13 @@ def get_bag(self) -> str: "Bag request failed: status=%s, falling back to default auth URL", r.status_code, ) - return DEFAULT_AUTH_URL_TEMPLATE % self.guid - auth_endpoint = _parse_bag_response(r.content) + return DEFAULT_AUTH_URL + auth_endpoint = _normalize_auth_endpoint(_parse_bag_response(r.content)) if auth_endpoint: logger.debug("Using auth endpoint from bag: %s", auth_endpoint[:60] + "...") return auth_endpoint logger.warning("Could not parse bag response, falling back to default auth URL") - return DEFAULT_AUTH_URL_TEMPLATE % self.guid + return DEFAULT_AUTH_URL def authenticate(self, appleId, password): if not self.guid: diff --git a/setup.py b/setup.py index 10b6bb8..d0fccce 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="mdast_cli", - version='2026.03.02', + version='2026.6.1', python_requires='>=3.12', From 85d1177f8a681f12aeed43228b7a44b0ef76cb5d Mon Sep 17 00:00:00 2001 From: Nikita Pinaev Date: Mon, 29 Jun 2026 17:13:03 +0300 Subject: [PATCH 2/2] Google Play: clearer error when apkeep delivers no artifact Distinguish "no success marker + no artifact" (Google Play refused to deliver the app for the requested device profile - most commonly a CPU ABI mismatch, e.g. requesting an x86_64 emulator profile for an arm64-only app; also region/availability/acquisition) from the rare "success marker present but artifact missing" case, with actionable error messages for each. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../google_play_apkeep.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/mdast_cli/distribution_systems/google_play_apkeep.py b/mdast_cli/distribution_systems/google_play_apkeep.py index dc3e87e..fc68084 100644 --- a/mdast_cli/distribution_systems/google_play_apkeep.py +++ b/mdast_cli/distribution_systems/google_play_apkeep.py @@ -111,7 +111,8 @@ async def download_app( if proc.returncode != 0: raise RuntimeError(sanitized_output_text.strip() or 'apkeep returned non-zero exit code') - if f'{package_name} downloaded successfully!' not in output: + success_marker_found = f'{package_name} downloaded successfully!' in output + if not success_marker_found: logger.warning(f'Google Play - success marker not found in output, continuing artifact check (package: {package_name})') split_dir = os.path.join(download_dir, package_name) @@ -279,8 +280,26 @@ async def download_app( except Exception as ex: logger.debug(f'Google Play - failed to scan directory for candidates: {ex} (package: {package_name})') - logger.error(f'Google Play - artifact not found after successful download (package: {package_name})') - raise RuntimeError('Google Play: download reports success but artifact not found') + if not success_marker_found: + # apkeep exited without printing the "downloaded successfully!" marker and produced no file. + # This is how Google Play behaves when it refuses to deliver the APK for the requested + # device profile: most commonly the app has no native build for the device's CPU ABI + # (e.g. requesting an x86_64 emulator profile for an arm64-only app), but it can also mean + # the app is unavailable for the account's region or is not acquired in its library. + # Not a real download failure on our side. + logger.error(f'Google Play - apkeep produced no success marker and no artifact; ' + f'Google Play did not deliver the app for the requested device profile ' + f'(likely CPU ABI/architecture mismatch, e.g. no x86_64 build for an arm64-only app; ' + f'or region/availability/acquisition) (package: {package_name})') + raise RuntimeError( + 'Google Play: apkeep finished without a success marker and produced no artifact - ' + 'Google Play did not deliver the app for the requested device profile. Most likely the app ' + 'has no native build for the requested CPU ABI (e.g. requesting x86_64 for an arm64-only app); ' + 'also check region/availability and that the app is acquired in the account library' + ) + + logger.error(f'Google Play - success marker present but artifact not found (package: {package_name})') + raise RuntimeError('Google Play: apkeep reported success but the artifact could not be located') def _ensure_dir_exists(path: str) -> None: