diff --git a/MANIFEST.in b/MANIFEST.in index 68721df..7b438b1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include tools/sap/main.go tools/sap/build.py tools/sap/upstream.json +include tools/sap/cache.go tools/sap/cache_test.go +include docs/STG-5356-appstore-download.md specs/appstore-download-20261005/spec.md include docs/STG-5076-appstore-sap.md recursive-include mdast_cli/distribution_systems/appstore_client/bin mdast-sap-* include mdast_cli/distribution_systems/appstore_client/IPATOOL-LICENSE diff --git a/README.md b/README.md index f8cd442..88a8c10 100644 --- a/README.md +++ b/README.md @@ -404,9 +404,9 @@ Download iOS applications (.ipa) from the App Store. - `--appstore_bundle_id ` - Bundle identifier - `--appstore_apple_id ` - iTunes account email - `--appstore_password ` - iTunes account password -- `--appstore_2FA ` - 6-digit 2FA code **Optional Parameters:** +- `--appstore_2FA ` - Fresh 6-digit code when Apple requires verification - `--appstore_file_name ` - Custom filename for downloaded IPA **Getting App ID:** @@ -418,10 +418,10 @@ Download iOS applications (.ipa) from the App Store. 1. Run the script with email and password 2. You'll receive a 2FA code on your device 3. Use the code with `--appstore_2FA` -4. **Save the combined password+2FA** format for 6 months: `password2FA` (e.g., `P@ssword742877`) +4. A successful login is cached in `appstore_sessions` under the current working directory. Reuse that session from the same directory; an old one-time code is not a reusable credential. **Deprecated Parameter:** -- `--appstore_password2FA` - Will be removed on 01.05.2023. Use separate `--appstore_password` and `--appstore_2FA` instead. +- `--appstore_password2FA` - Retained for compatibility. Prefer separate `--appstore_password` and optional `--appstore_2FA`. **Example:** ```bash @@ -441,8 +441,9 @@ mdast_cli \ **Troubleshooting:** - **"Wrong Apple ID" error**: Contact support to coordinate the Apple ID for AppStore integration -- **Session expired**: Re-authenticate and save the new 2FA code +- **Session expired**: Re-authenticate with a fresh code if Apple requests it - **Login errors**: Ensure 2FA is enabled and code is current (6-digit format) +- **HTTP 204/403/404/429/5xx without an account response**: The CLI retries transient responses at most three times with backoff and respects `Retry-After`. A malformed redirect or an exhausted retry budget remains a download failure; these statuses alone do not establish that the password is incorrect. **Note:** This integration uses [ipatool](https://github.com/majd/ipatool) - thanks to all contributors! @@ -976,9 +977,9 @@ mdast_cli -d \ **Solutions:** 1. Ensure 2FA is enabled on the Apple ID 2. Use the 6-digit 2FA code (not the longer backup code) -3. Format password+2FA correctly: `password2FA` (e.g., `P@ssword742877`) +3. Pass the password via `--appstore_password` and a fresh code via `--appstore_2FA` when requested 4. Contact support if Apple ID needs to be whitelisted -5. Re-authenticate if session expired (sessions last ~6 months) +5. Re-authenticate if the saved session expires; its lifetime is controlled by Apple #### Firebase Service Account Errors @@ -1184,6 +1185,17 @@ The runtime cache is under the OS user cache directory in `ipatool/sap` and user cache directory is required. Alpine/musl is supported through its system loader. Offline first login is not supported; warm runtime assets avoid repeating these downloads. Account session caching continues to use `appstore_sessions`. +The validated authentication URL from Apple’s bag uses the known +`/WebObjects/MZFinance.woa/wa/authenticate/` path with a trailing slash. +Both path forms are accepted for validated Store redirects; the bag host and +query are preserved, and no alternate endpoint is guessed after a failure. + +`MDAST_SAP_CACHE_DIR` optionally selects an absolute directory for these verified +public runtime assets. It does not change account-session storage or `HOME`. +On Apple Silicon, `MDAST_APPSTORE_SAP_ARCH=amd64` explicitly selects the bundled +Intel helper through installed Rosetta. The default remains the native helper; +no automatic architecture fallback occurs. This setting affects SAP signing +only, not the downloaded application's ABI or Google Play. For development from a source checkout, install Go 1.27.1 and run `python tools/sap/build.py --target host`. Release builds use `--target all` diff --git a/docs/STG-5356-appstore-download.md b/docs/STG-5356-appstore-download.md new file mode 100644 index 0000000..6a400ca --- /dev/null +++ b/docs/STG-5356-appstore-download.md @@ -0,0 +1,33 @@ +# STG-5356: локальное скачивание App Store восстановлено + +## Результат + +05.10.2026 установленный кандидат CLI скачал три разные валидные IPA обычным entrypoint, без подмены исходников и удалённого signer. Первый процесс выполнил свежую авторизацию (новая сессия) с ранее присланным кодом по явному разрешению пользователя. Следующие процессы использовали сохранённую сессию без auth POST. Независимый вход из другого чистого каталога без 2FA тоже завершился успешно. Значения credentials и OTP в отчёт не включены. + +| Приложение | Размер IPA, байт | Время, с | Авторизация | Проверка | +|---|---:|---:|---|---| +| Исходное приложение | 105 082 550 | 11,28 | 302 → 200 | CRC, Payload/Info.plist, executable | +| Контрольное приложение 2 | 160 140 189 | 7,89 | сохранённая сессия, 0 auth POST | CRC, Payload/Info.plist, executable | +| Контрольное приложение 3 | 151 109 984 | 5,73 | сохранённая сессия, 0 auth POST | CRC, Payload/Info.plist, executable | +| Исходное приложение, независимый вход без кода | 105 082 552 | 10,49 | 302 → 200 | CRC, Payload/Info.plist, executable | + +Все процессы завершились exit0. Полученные файлы сохранены локально в закрытом рабочем каталоге. 375 тестов и отдельный security gate20 успешны. Wheel/sdist собраны; все шесть SAP helpers сохранены. Между этим wheel и предыдущим локальным кандидатом изменился только sap.py, четыре apkeep binaries и manifest прежние; runtime-зависимости не менялись. Предыдущая проверка Google Play обычным CLI скачала два разных крупных приложения с x86_64, корректными CRC/AndroidManifest и 4/3APK. + +## Подтверждённое отличие auth URL + +Первый контроль штатного signer с прежним путём `/authenticate` завершился301 без Location,204,404 за34,67с. В issues [598](https://github.com/majd/ipatool/issues/598) и [607](https://github.com/majd/ipatool/issues/607) есть такие же отказы на текущем upstream и сообщения об успехе с [C++ fork Sorvigolova](https://github.com/Sorvigolova/ipatool). + +В исходниках fork (commit7fae0e2) адрес authenticateAccount из bag дополняется trailing slash. Upstream [PR603](https://github.com/majd/ipatool/pull/603), смёрженный01.10, разрешает оба известных пути. В нашей CLI валидатор допускал только вариант без slash. Изолированный контроль с единственным изменением пути на `/authenticate/` успешно прошёл302→200 и скачал IPA105 082 553байта за12,72с. Затем этот вариант реализован в обычном пакете и повторно проверен, результаты выше. + +Это установленное различие и проверенный рабочий вариант. Из-за непостоянных ответов Apple нельзя объявлять trailing slash единственной внутренней причиной всех прошлых отказов. В comments issue550/598 есть как успехи после смены сети, так и отказы после обновления/смены сети. Неверный пароль либо OTP из старых HTTP-ответов не следовали. В последнем независимом входе код вообще не понадобился. + +## Изменения CLI + +- После validation адреса из Apple bag используется известный auth path с одним trailing slash. Host и query сохраняются. Валидатор допускает строго оба известных пути; похожие, двойные, чужие и небезопасные адреса отклоняются до отправки credentials. Валидный redirect используется точно по Location, endpoint при отказе не подбирается. +- Исправлена потеря пароля при пустом/отсутствующем `--appstore_2FA`. Пароль без кода допустим для начала входа и сохранённой сессии. Старый `--appstore_password2FA` совместим. При соответствующем account response CLI требует свежий код. +- Подписанные повторы временных ответов ограничены тремя запросами, с паузами10/20с и Retry-After до30с. Account errors и ошибки сертификата не повторяются. Подписываются именно отправляемые байты. Без unsigned auth и без отключения TLS при авторизации. +- Необязательный `MDAST_SAP_CACHE_DIR` выбирает абсолютный доступный cache runtime assets. На Apple Silicon явный `MDAST_APPSTORE_SAP_ARCH=amd64` запускает packaged Intel helper через установленную Rosetta. Default/native selection прежний, автоматического fallback нет. Runtime assets/checksums, HOME и sandbox не меняются. + +Продуктовый scope ограничен CLI. БД, конфигурация и общие сессии стендов не менялись. Это локально проверенный кандидат, опубликованная CLI2026.10.2 этих новых изменений ещё не содержит. Release/tag/deploy в этой проверке не выполнялись. + +Спецификация: [spec.md](../specs/appstore-download-20261005/spec.md). Задача: [STG-5356](https://tracker.yandex.ru/STG-5356). PR: [157](https://github.com/Dynamic-Mobile-Security/mdast-cli/pull/157). diff --git a/mdast_cli/distribution_systems/appstore_client/sap.py b/mdast_cli/distribution_systems/appstore_client/sap.py index 39b8a37..c5331ed 100644 --- a/mdast_cli/distribution_systems/appstore_client/sap.py +++ b/mdast_cli/distribution_systems/appstore_client/sap.py @@ -2,12 +2,13 @@ import base64 import json import logging +import os import platform import queue import subprocess import threading from pathlib import Path -from urllib.parse import urlsplit +from urllib.parse import urlsplit, urlunsplit logger = logging.getLogger(__name__) AUTH_PATH = '/WebObjects/MZFinance.woa/wa/authenticate' @@ -28,7 +29,7 @@ def validate_endpoint(value, authentication=False): and not url.username and not url.password and not url.fragment and (host.endswith('.apple.com') or host == 's.mzstatic.com')) if authentication: - valid = (valid and url.path == AUTH_PATH + valid = (valid and url.path in (AUTH_PATH, AUTH_PATH + '/') and (host == 'buy.itunes.apple.com' or host.endswith('-buy.itunes.apple.com'))) if not valid: @@ -46,8 +47,13 @@ def parse_bag(data): raise SAPError('Apple bag has no SAP configuration') if str(values.get('sign-sap-version')) != '200': raise SAPError('Apple bag has missing or unsupported SAP version') + auth_url = validate_endpoint(values.get('authenticateAccount'), True) + # Match the working Store request path, without changing the bag host/query + # or guessing another endpoint after a refusal. Both path forms are valid + # in upstream ipatool PR603; the C++ client uses the trailing-slash form. + auth_url = urlunsplit(urlsplit(auth_url)._replace(path=AUTH_PATH + '/')) return { - 'auth_url': validate_endpoint(values.get('authenticateAccount'), True), + 'auth_url': auth_url, 'setup_url': validate_endpoint(values.get('sign-sap-setup')), 'certificate_url': validate_endpoint(values.get('sign-sap-setup-cert')), 'version': 200, @@ -58,6 +64,13 @@ def helper_path(): system = platform.system().lower() arch = {'x86_64': 'amd64', 'aarch64': 'arm64', 'arm64': 'arm64', 'amd64': 'amd64'}.get(platform.machine().lower()) + requested = os.environ.get('MDAST_APPSTORE_SAP_ARCH') + if requested: + if system != 'darwin' or requested not in ('amd64', 'arm64'): + raise SAPError('MDAST_APPSTORE_SAP_ARCH supports amd64 or arm64 on macOS only') + if arch == 'amd64' and requested != arch: + raise SAPError('An Intel Mac cannot run the ARM64 SAP helper') + arch = requested suffix = '.exe' if system == 'windows' else '' path = Path(__file__).with_name('bin') / f'mdast-sap-{system}-{arch}{suffix}' if arch is None or not path.is_file(): @@ -67,6 +80,10 @@ def helper_path(): def helper_command(): binary = helper_path() + if (platform.system() == 'Darwin' + and platform.machine().lower() in ('aarch64', 'arm64') + and os.environ.get('MDAST_APPSTORE_SAP_ARCH') == 'amd64'): + return ['arch', '-x86_64', str(binary)] # Go/purego embeds the glibc interpreter in its Linux binary. On musl, # invoke the installed system loader explicitly; upstream selects the # corresponding checksum-pinned musllinux Unicorn runtime itself. @@ -83,6 +100,9 @@ def helper_command(): class SAPSigner: def __init__(self, config, guid): self.process = None + cache = os.environ.get('MDAST_SAP_CACHE_DIR') + if cache and not Path(cache).is_absolute(): + raise SAPError('MDAST_SAP_CACHE_DIR must be an absolute directory') try: hardware = bytes.fromhex(guid) if len(hardware) != 6: diff --git a/mdast_cli/distribution_systems/appstore_client/store.py b/mdast_cli/distribution_systems/appstore_client/store.py index bf5f7ff..754ffa8 100755 --- a/mdast_cli/distribution_systems/appstore_client/store.py +++ b/mdast_cli/distribution_systems/appstore_client/store.py @@ -4,6 +4,8 @@ import plistlib import re import time +from email.utils import parsedate_to_datetime +from urllib.parse import urljoin import requests from requests.adapters import HTTPAdapter from mdast_cli.distribution_systems.appstore_client.sap import SAPError, SAPSigner, parse_bag, validate_endpoint @@ -13,6 +15,9 @@ BAG_URL_TEMPLATE = "https://init.itunes.apple.com/bag.xml?guid=%s" LEGACY_AUTH_URL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" AUTH_MAX_REDIRECTS = 4 +AUTH_MAX_REQUEST_ATTEMPTS = 3 +AUTH_RETRY_DELAY = 10 +AUTH_MAX_RETRY_DELAY = 30 BUY_DOMAIN = "buy.itunes.apple.com" @@ -143,31 +148,71 @@ def _post_authenticate(self, url, appleId, password, attempt, signer): data=body, allow_redirects=False, verify=True, timeout=60, ) + def _auth_retry_delay(self, response, retry): + delay = min(AUTH_RETRY_DELAY * 2 ** retry, AUTH_MAX_RETRY_DELAY) + value = response.headers.get('Retry-After', '').strip() if response is not None else '' + if not value: + return delay + try: + requested = int(value) if value.isdigit() else parsedate_to_datetime(value).timestamp() - time.time() + except (ValueError, TypeError, OverflowError): + return delay + if requested > AUTH_MAX_RETRY_DELAY: + raise StoreException('authenticate', 'Apple requested a longer wait; try again later') + return max(1, requested) + + def _auth_request(self, url, appleId, password, attempt, signer): + outcomes = [] + for retry in range(AUTH_MAX_REQUEST_ATTEMPTS): + response = None + try: + response = self._post_authenticate(url, appleId, password, attempt, signer) + except requests.exceptions.SSLError: + raise StoreException('authenticate', 'Apple TLS verification failed') from None + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError): + outcomes.append('transport error') + else: + status = response.status_code + logger.info('App Store signed authentication response: HTTP %s', status) + try: + data = plistlib.loads(response.content) + if not isinstance(data, dict): + raise ValueError + except (ValueError, TypeError, plistlib.InvalidFileException): + data = None + # Populated Apple account responses and redirects are protocol + # outcomes, never network retries. Re-sign exactly the same body + # for each transient request without changing protocol attempt. + html = ('text/html' in response.headers.get('Content-Type', '').lower() + or response.content.lstrip().lower().startswith((b'= 500 + or (status == 403 and html) + or (status == 301 and html and not response.headers.get('Location'))) + if data is not None or not transient: + return response, data + outcomes.append('HTTP %s' % status) + if retry + 1 == AUTH_MAX_REQUEST_ATTEMPTS: + raise StoreException('authenticate', 'Apple authentication failed after %s requests (%s)' % ( + AUTH_MAX_REQUEST_ATTEMPTS, ', '.join(outcomes))) from None + time.sleep(self._auth_retry_delay(response, retry)) + def _authenticate_at(self, auth_url, appleId, password, signer): - url, attempt, redirects, transient = auth_url, 1, 0, 0 + url, attempt, redirects = auth_url, 1, 0 while True: - response = self._post_authenticate(url, appleId, password, attempt, signer) + response, data = self._auth_request(url, appleId, password, attempt, signer) status = response.status_code - logger.info("App Store signed authentication response: HTTP %s", status) if status in (301, 302, 303, 307, 308): location = response.headers.get('Location') - if not location: + if not location or not location.strip(): raise StoreException('authenticate', 'Apple redirect has no Location (HTTP %s)' % status) + location = urljoin(url, location.strip()) validate_endpoint(location, authentication=True) redirects += 1 if redirects > AUTH_MAX_REDIRECTS: raise StoreException('authenticate', 'Too many Apple authentication redirects') url = location continue - try: - data = plistlib.loads(response.content) - if not isinstance(data, dict): - raise ValueError - except (ValueError, TypeError, plistlib.InvalidFileException): - transient += 1 - if (status in (204, 404) or status >= 500) and transient < 3: - time.sleep(0.25 * transient) - continue + if data is None: raise StoreException('authenticate', 'Apple returned a non-plist authentication response (HTTP %s)' % status) from None resp = StoreAuthenticateResp.from_dict(data) # Both response shapes are in use: older clients read download-queue-info. @@ -178,8 +223,9 @@ def _authenticate_at(self, auth_url, appleId, password, signer): return response, resp if attempt == 1 and str(resp.failureType) == '-5000': attempt = 2 - transient = 0 continue + if not resp.failureType and resp.customerMessage == 'MZFinance.BadLogin.Configurator_message': + raise StoreException('authenticate', 'Apple requires verification; provide a fresh --appstore_2FA code (MZFinance.BadLogin.Configurator_message)') raise StoreException('authenticate', resp.customerMessage or 'Apple rejected authentication', resp.failureType) def authenticate(self, appleId, password): diff --git a/mdast_cli/mdast_scan.py b/mdast_cli/mdast_scan.py index b301635..0a3e8de 100644 --- a/mdast_cli/mdast_scan.py +++ b/mdast_cli/mdast_scan.py @@ -176,7 +176,7 @@ def parse_args(): 'It is recommended to use environment variables for security.') appstore_group.add_argument('--appstore_2FA', type=str, help='Two-factor authentication code (6 digits). ' - 'Required parameter when --distribution_system is set to "appstore". ' + 'Optional; provide a fresh code if Apple requests verification. ' 'Code is sent to trusted Apple devices. ' 'Example: 123456') appstore_group.add_argument('--appstore_file_name', type=str, @@ -445,9 +445,9 @@ def parse_args(): elif args.distribution_system == 'appstore' and ( (args.appstore_app_id is None and args.appstore_bundle_id is None) or args.appstore_apple_id is None or - (args.appstore_password is None or args.appstore_2FA is None) and args.appstore_password2FA is None): + args.appstore_password is None and args.appstore_password2FA is None): parser.error('"--distribution_system appstore" requires either "--appstore_app_id" or "--appstore_bundle_id", ' - '"--appstore_apple_id" and ("--appstore_password" + "--appstore_2FA")/' + '"--appstore_apple_id" and "--appstore_password" (optional "--appstore_2FA")/' '(deprecated "--appstore_password2FA") arguments to be set') elif args.distribution_system == 'google_play': @@ -843,8 +843,8 @@ def main(): arguments.firebase_file_extension) elif distribution_system == 'appstore': - if arguments.appstore_password and arguments.appstore_2FA: - password2FA = arguments.appstore_password + arguments.appstore_2FA + if arguments.appstore_password is not None: + password2FA = arguments.appstore_password + (arguments.appstore_2FA or '').replace(' ', '') else: password2FA = arguments.appstore_password2FA appstore = AppStore(arguments.appstore_apple_id, diff --git a/setup.py b/setup.py index 9645af0..3bc0b73 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="mdast_cli", - version='2026.10.2', + version='2026.10.3', python_requires='>=3.12', diff --git a/specs/appstore-download-20261005/spec.md b/specs/appstore-download-20261005/spec.md new file mode 100644 index 0000000..b070a5b --- /dev/null +++ b/specs/appstore-download-20261005/spec.md @@ -0,0 +1,58 @@ +# STG-5356: восстановить скачивание App Store через mdast_cli + +## Проблема и цель + +Пользователь попросил восстановить получение IPA именно через обычный интерфейс mdast_cli. Свежая авторизация сейчас завершается до запроса приложения: HTTP 204/404/403 либо redirect без Location. Старые успешные загрузки подтверждены; конкретная внутренняя причина текущих отказов Apple пока не установлена. На локальном ARM64 SAP helper дополнительно падает в Unicorn при запрещённом sandbox-запросе sysctl. Это отдельное ограничение среды проверки. + +## Границы + +Только App Store-клиент CLI и необходимые воспроизводимые проверки. Google Play, crypto-зависимости, API стендов, общие сессии, БД, инфраструктура и конфигурация prod/dev не меняются. Не отключать проверку TLS, не отправлять credentials на неподтверждённый endpoint, не использовать unsigned auth; ранее присланный одноразовый код допускается только для ограниченного контроля по явному разрешению пользователя. Не публиковать исправление как работающее без фактической IPA. + +## План + +1. Сравнить подписанный запрос CLI с официальным ipatool, включая идентичность устройства, транспорт, заголовки и тело. Контроли выполнять в изолированном временном каталоге. +2. Проверить ограниченную обработку временных отказов по актуальному upstream: задержки, Retry-After, HTML 403/429, transport failures. Настоящие ошибки credentials и небезопасные redirect не повторять. + Проверить передачу credentials через CLI: пустой или отсутствующий 2FA не должен превращать непустой пароль в None. Пароль без кода допустим для начала входа и использования сохранённой сессии; если Apple требует код, завершить вход с понятным сообщением. + Наблюдаемый HTML 301 без Location допускает только ограниченный повтор на том же уже проверенном auth URL. Это не redirect: новый адрес не подставляется. Любой присутствующий некорректный Location отклоняется сразу. +3. Реализовать только подтверждённые изменения; добавить проверки количества повторов, точной подписи, redirect allowlist, остановки при credential errors и защиты секретов. +4. Проверить штатный вход и загрузку через CLI, затем повтор из другого процесса с сохранённой сессией. Не считать диагностическую подмену транспорта или удалённый signer штатной проверкой локальной версии. +5. Прогнать существующие регрессионные проверки, собрать пакет и подготовить PR только с честным результатом приёмки. + +## Критерии приёмки + +- Из чистой сессии CLI получает валидную IPA исходного запрошенного приложения и нескольких других бесплатных приложений. ZIP CRC, Payload/Info.plist и исполняемый файл проверены. +- Второй процесс использует сохранённую сессию и повторно скачивает IPA без нового auth POST. +- Повторы ограничены; Retry-After соблюдается либо запрос завершается, если ожидание превышает бюджет. Отказы аккаунта не маскируются под временную сетевую ошибку. +- Нет отправки пароля на чужой host, TLS обхода, утечек credentials/подписи и регрессии Google Play. +- Пароль корректно передаётся через обычные аргументы с кодом, без кода и с пустым кодом; старый аргумент password2FA остаётся совместимым. +- Недоступность локального native signer в sandbox отмечается отдельно. Пока настоящий end-to-end результат не подтверждён, публикация и доставка не выполняются. + +## Локальное восстановление, 05.10.2026 + +Пользователь отдельно попросил добиться работающего скачивания локально. Проверить локальный Docker как диагностический контроль штатной Linux CLI и при необходимости штатный native CommerceKit signer macOS. Все credentials идут только в Apple и через stdin локального процесса. Docker не используется для доступа к запрещённым файлам: mounts ограничены копией CLI и отдельным рабочим каталогом внутри разрешённого workspace/tmp. Без привилегированного контейнера, host networking, Docker socket mount и изменений общих стендов. Ограничения sandbox не обходить. + +Если native CommerceKit подтвердит исправление, оформить отдельную реализацию signer с прежними проверками endpoint и точной подписью, тестами и совместимостью Linux. Не добавлять неподтверждённый native backend и не считать Docker-проверку подтверждением исправленного native ARM64. + +Дополнительный локальный контроль: собрать штатный darwin-amd64 helper с явно заданным временным каталогом runtime cache вместо недоступного Library/Caches. Использовать Rosetta как поддерживаемый способ запуска x86_64 программы, без изменения sysctl, sandbox, runtime dylib и их checksum. Сначала проверить init/sign на публичном синтетическом payload без credentials. После успешного smoke допускается кандидат настройки helper для проверки обычного CLI. Публикация допускается только после успешного end-to-end результата. Исходники upstream остаются pinned; любые изменения выполняются в отдельной копии при воспроизводимой сборке. + +Проверенный smoke: darwin-amd64 через Rosetta с временным cache успешно инициализирован и вернул подпись 501 байт за 6,59 секунды. Реализовать явные необязательные настройки `MDAST_SAP_CACHE_DIR` (абсолютный каталог для проверенных runtime assets) и `MDAST_APPSTORE_SAP_ARCH=amd64` (только выбор штатного helper macOS, без изменения ABI приложений). Без настройки сохраняются прежние пути/архитектура. Некорректные значения отклонять до запуска helper. Cross-arch macOS запускается через `arch -x86_64`, автоматического fallback нет. Не менять HOME, security policy, Apple assets, версии crypto и Google Play. Добавить проверки выбора helper и cache, затем собрать helpers с прежним pin, установить кандидат в отдельное local venv и проверить настоящую CLI с credentials пользователя. + +05.10.2026 пользователь явно разрешил один контроль новой локальной сессии с прежним кодом, поскольку свежего кода нет. Код не сохранять. Этот контроль не заменяет end-to-end критерии и не разрешает публикацию без IPA. + +## Поддержка authenticate с trailing slash + +В новых issues598/607 сохраняются отказы auth актуального ipatool. C++ forkSorvigolova добавляет trailing slash к authenticateAccount; upstream PR603 разрешает оба пути. Изолированный контроль05.10 с единственным изменением auth path успешно прошёл302→200 и скачал валидную IPA105 082 553байта. Это подтверждает работоспособный вариант запроса, но из-за непостоянных отказов Apple не доказывает единственную внутреннюю причину. + +Реализация: validator разрешает строго AUTH_PATH и AUTH_PATH+'/', сохраняя HTTPS, host/port/userinfo/fragment restrictions. После validation адреса из Apple bag использовать путь с одним trailing slash; сохранить host и query. Redirect обрабатывать точно по присланному валидному Location, не подбирать endpoint при отказе. Не менять credentials, GUID, signer, transport, crypto или Google Play. + +Проверки: bag без/со slash и query, pod redirects с обоими путями, отклонение похожих/двойных/чужих путей до отправки credentials. Затем собрать и установить wheel в отдельный local venv. Обычная CLI без source monkeypatch должна скачать исходную IPA и несколько других приложений, второй процесс из сохранённой сессии должен иметь0auth POST. Повторный cold login проверить отдельно. Публикация и доставка по-прежнему gated реальной приёмкой. + +Приёмка05.10: установленный wheel без source monkeypatch скачал три разные IPA (105 082 550/160 140 189/151 109 984байта); первый вход302→200, два отдельных процесса из сохранённой сессии0auth POST. Независимый чистый вход без кода302→200 и IPA105 082 552байта. CRC/Payload/Info.plist/executable проверены;375tests+20security успешны. Runtime-зависимости и Google binaries прежние. Секреты в evidence отсутствуют. Это локальная приёмка, отдельной публикации/доставки в текущем шаге нет. + +## Согласованная доставка 05.10.2026 + +После локальной приёмки пользователь разрешил доставку в Markea и dev монолита. Выпустить CLI 2026.10.3 через существующий tag workflow, проверить опубликованный wheel и его штатное появление в Nexus. Обновить монолит до этой точной версии. Markea получает helpers из wheel по SHA256 и имеет собственный Apple-клиент: перенести подтверждённую нормализацию authenticateAccount в эту копию и выпустить 0.2.84. Сохранить остальные зависимости и lock-файлы. + +Доставка только через новые PR/MR, обязательные проверки и штатные dev pipelines. Отклонённую правку extra_hosts исключить из нового diff монолита с сохранением истории. Не менять prod, Harbor, Wizard, секреты, сети и инфраструктуру. Перед выкладкой проверить активные сканы. + +Приёмка: публикация CLI и установка той же версии из Nexus подтверждены; обязательные CI успешны; на обоих dev подтверждены новый образ и штатное получение настоящих APK и IPA с CRC/служебной структурой. Apple соединяется напрямую, Google использует существующие настройки provider. Если сканы запускаются дополнительно, соблюдать отдельные правила выборки и остановки, не подменять проверку загрузок запуском пустого скана. diff --git a/tests/test_appstore_auth.py b/tests/test_appstore_auth.py index deb26f3..8dd5118 100644 --- a/tests/test_appstore_auth.py +++ b/tests/test_appstore_auth.py @@ -3,6 +3,7 @@ import plistlib import pytest +import requests from mdast_cli.distribution_systems.appstore_client import store as store_mod from mdast_cli.distribution_systems.appstore_client.store import LEGACY_AUTH_URL, StoreClient, StoreException @@ -63,16 +64,18 @@ def _signed_bag(monkeypatch): monkeypatch.setattr(store_mod, 'SAPSigner', FakeSigner) -def test_pod_redirect_reposts_body_with_attempt_one(): +@pytest.mark.parametrize('slash', ['', '/']) +def test_pod_redirect_reposts_body_with_attempt_one(slash): + pod_url = POD_URL.replace('authenticate?', 'authenticate' + slash + '?') client = _client([ - FakeResponse(302, headers={'Location': POD_URL}), - FakeResponse(200, plistlib.dumps(SUCCESS_PLIST), headers={'pod': '7'}, url=POD_URL), + FakeResponse(302, headers={'Location': pod_url}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST), headers={'pod': '7'}, url=pod_url), ]) response = client.authenticate('user@example.com', 'secret123456') assert response.passwordToken == 'token-123' assert client.account_name == 'Test User' assert client.pod == '7' - assert [c['url'] for c in client.sess.calls] == [LEGACY_AUTH_URL, POD_URL] + assert [c['url'] for c in client.sess.calls] == [LEGACY_AUTH_URL, pod_url] assert [c['body']['attempt'] for c in client.sess.calls] == ['1', '1'] assert FakeSigner.last.payloads == [c['data'] for c in client.sess.calls] assert len(set(FakeSigner.last.payloads)) == 1 @@ -120,6 +123,144 @@ def test_transient_response_budget_is_three(): assert len(client.sess.calls) == 3 +def test_html_forbidden_is_retried_with_backoff(monkeypatch): + waits = [] + monkeypatch.setattr(store_mod.time, 'sleep', waits.append) + client = _client([ + FakeResponse(403, b'Forbidden', {'Content-Type': 'text/html'}), + FakeResponse(404), FakeResponse(200, plistlib.dumps(SUCCESS_PLIST)), + ]) + client.authenticate('user@example.com', 'secret') + assert waits == [10, 20] + assert len(set(FakeSigner.last.payloads)) == 1 + assert [c['body']['attempt'] for c in client.sess.calls] == ['1'] * 3 + + +def test_html_301_without_location_retries_only_original_endpoint(): + client = _client([FakeResponse(301, b'Moved', {'Content-Type': 'text/html'}), + FakeResponse(302, headers={'Location': POD_URL}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert [c['url'] for c in client.sess.calls] == [LEGACY_AUTH_URL, LEGACY_AUTH_URL, POD_URL] + assert len(set(FakeSigner.last.payloads)) == 1 + + +def test_html_301_without_location_remains_bounded(): + client = _client([FakeResponse(301, b'Moved') for _ in range(3)]) + with pytest.raises(StoreException, match='3 requests'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 3 + + +def test_html_301_with_unsafe_location_fails_without_retry(): + client = _client([FakeResponse(301, b'Moved', {'Location': 'https://evil.example/'})]) + with pytest.raises(StoreException, match='endpoint'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +@pytest.mark.parametrize('status', [403, 429, 503]) +def test_populated_account_refusal_is_not_retried(status): + failure = {'failureType': '1234', 'customerMessage': 'Incorrect password'} + client = _client([FakeResponse(status, plistlib.dumps(failure))]) + with pytest.raises(StoreException, match='Incorrect password'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +def test_retry_after_takes_precedence(monkeypatch): + waits = [] + monkeypatch.setattr(store_mod.time, 'sleep', waits.append) + client = _client([FakeResponse(429, headers={'Retry-After': '15'}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert waits == [15] + + +def test_retry_after_date_is_respected(monkeypatch): + from email.utils import formatdate + monkeypatch.setattr(store_mod.time, 'time', lambda: 1000) + waits = [] + monkeypatch.setattr(store_mod.time, 'sleep', waits.append) + client = _client([FakeResponse(429, headers={'Retry-After': formatdate(1025, usegmt=True)}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert waits == [25] + + +@pytest.mark.parametrize('value', ['31', '99999999999999999999999999999999999']) +def test_long_retry_after_stops_without_early_retry(value): + client = _client([FakeResponse(429, headers={'Retry-After': value})]) + with pytest.raises(StoreException, match='longer wait'): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +@pytest.mark.parametrize('error', [requests.exceptions.Timeout, requests.exceptions.ConnectionError]) +def test_transport_retry_is_bounded_and_sanitized(monkeypatch, error): + calls = [] + def failed(*args, **kwargs): + calls.append(args) + raise error('private password signature') + client = _client([]) + monkeypatch.setattr(client.sess, 'post', failed) + with pytest.raises(StoreException, match='3 requests') as result: + client.authenticate('user@example.com', 'secret') + assert 'private' not in str(result.value) + assert len(calls) == 3 + assert FakeSigner.last.closed + + +def test_certificate_error_is_not_retried_or_exposed(monkeypatch): + calls = [] + def failed(*args, **kwargs): + calls.append(args) + raise requests.exceptions.SSLError('private request') + client = _client([]) + monkeypatch.setattr(client.sess, 'post', failed) + with pytest.raises(StoreException, match='TLS verification') as result: + client.authenticate('user@example.com', 'secret') + assert 'private' not in str(result.value) + assert len(calls) == 1 + assert FakeSigner.last.closed + + +def test_relative_pod_redirect_is_validated(): + client = _client([FakeResponse(302, headers={'Location': '//p7-buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate?Pod=7'}), + FakeResponse(200, plistlib.dumps(SUCCESS_PLIST))]) + client.authenticate('user@example.com', 'secret') + assert client.sess.calls[1]['url'] == POD_URL.split('&PRH=7')[0] + + +@pytest.mark.parametrize('location', [' ', '//evil.example/WebObjects/MZFinance.woa/wa/authenticate']) +def test_invalid_relative_redirect_never_receives_credentials(location): + client = _client([FakeResponse(302, headers={'Location': location})]) + with pytest.raises(StoreException): + client.authenticate('user@example.com', 'secret') + assert len(client.sess.calls) == 1 + + +@pytest.mark.parametrize('flags,password', [ + (['--appstore_password', 'secret'], 'secret'), + (['--appstore_password', 'secret', '--appstore_2FA', ''], 'secret'), + (['--appstore_password', 'secret', '--appstore_2FA', '123 456'], 'secret123456'), + (['--appstore_password2FA', 'secret123456'], 'secret123456'), +]) +def test_cli_passes_password_to_real_download_boundary(monkeypatch, tmp_path, flags, password): + from tests.conftest import run_main + captured = [] + def download(self, directory, *args): + captured.append(self.pass2FA) + artifact = tmp_path / 'application.ipa' + artifact.write_bytes(b'ipa download fixture') + return str(artifact), 'md5' + monkeypatch.setattr('mdast_cli.mdast_scan.AppStore.download_app', download) + assert run_main(monkeypatch, ['--distribution_system', 'appstore', '--download_only', + '--appstore_app_id', '1234', '--appstore_apple_id', 'user@example.com', + '--download_path', str(tmp_path)] + flags) == 0 + assert captured == [password] + + @pytest.mark.parametrize('status', [301, 302]) def test_redirect_without_location_is_rejected(status): client = _client([FakeResponse(status)]) diff --git a/tests/test_appstore_sap.py b/tests/test_appstore_sap.py index c3bb9f2..fb7eb29 100644 --- a/tests/test_appstore_sap.py +++ b/tests/test_appstore_sap.py @@ -25,6 +25,30 @@ def test_bag_accepts_apple_certificate_cdn(wrapper): assert sap.parse_bag(wrapper(BAG))['certificate_url'] == BAG['sign-sap-setup-cert'] +@pytest.mark.parametrize('suffix', ['', '/']) +@pytest.mark.parametrize('host', ['buy.itunes.apple.com', 'p12-buy.itunes.apple.com']) +def test_bag_selects_trailing_slash_without_losing_pod_query(host, suffix): + endpoint = f'https://{host}{sap.AUTH_PATH}{suffix}?Pod=12&PRH=12' + config = sap.parse_bag(dict(BAG, authenticateAccount=endpoint)) + assert config['auth_url'] == f'https://{host}{sap.AUTH_PATH}/?Pod=12&PRH=12' + + +@pytest.mark.parametrize('endpoint', [ + f'https://buy.itunes.apple.com{sap.AUTH_PATH}//', + f'https://buy.itunes.apple.com{sap.AUTH_PATH}/extra', + f'https://buy.itunes.apple.com{sap.AUTH_PATH}%2f', + f'https://evil.example{sap.AUTH_PATH}/', + f'https://buy.itunes.apple.com.evil.example{sap.AUTH_PATH}/', + f'http://buy.itunes.apple.com{sap.AUTH_PATH}/', + f'https://user@buy.itunes.apple.com{sap.AUTH_PATH}/', + f'https://buy.itunes.apple.com:444{sap.AUTH_PATH}/', + f'https://buy.itunes.apple.com{sap.AUTH_PATH}/#fragment', +]) +def test_bag_does_not_repair_unsafe_endpoint_by_normalizing_path(endpoint): + with pytest.raises(sap.SAPError): + sap.parse_bag(dict(BAG, authenticateAccount=endpoint)) + + @pytest.mark.parametrize('field,value', [ ('sign-sap-version', None), ('sign-sap-version', '201'), ('authenticateAccount', 'https://evil.example/'), @@ -137,3 +161,42 @@ def test_musl_uses_system_loader(monkeypatch, machine, loader): monkeypatch.setattr(sap.platform, 'machine', lambda: machine) monkeypatch.setattr(sap.Path, 'is_file', lambda self: str(self) == loader) assert sap.helper_command() == [loader, '/test/mdast-sap'] + + +def test_explicit_intel_helper_on_apple_silicon_uses_rosetta(monkeypatch): + monkeypatch.setattr(sap.platform, 'system', lambda: 'Darwin') + monkeypatch.setattr(sap.platform, 'machine', lambda: 'arm64') + monkeypatch.setattr(sap.Path, 'is_file', lambda self: True) + monkeypatch.setenv('MDAST_APPSTORE_SAP_ARCH', 'amd64') + command = sap.helper_command() + assert command[:2] == ['arch', '-x86_64'] + assert command[2].endswith('mdast-sap-darwin-amd64') + + +def test_default_apple_silicon_helper_is_unchanged(monkeypatch): + monkeypatch.setattr(sap.platform, 'system', lambda: 'Darwin') + monkeypatch.setattr(sap.platform, 'machine', lambda: 'arm64') + monkeypatch.setattr(sap.Path, 'is_file', lambda self: True) + monkeypatch.delenv('MDAST_APPSTORE_SAP_ARCH', raising=False) + assert sap.helper_command()[0].endswith('mdast-sap-darwin-arm64') + + +@pytest.mark.parametrize('system,machine,requested', [ + ('Darwin', 'arm64', '../other'), ('Darwin', 'x86_64', 'arm64'), + ('Linux', 'aarch64', 'amd64'), ('Windows', 'AMD64', 'amd64'), +]) +def test_unsupported_helper_override_fails_closed(monkeypatch, system, machine, requested): + monkeypatch.setattr(sap.platform, 'system', lambda: system) + monkeypatch.setattr(sap.platform, 'machine', lambda: machine) + monkeypatch.setenv('MDAST_APPSTORE_SAP_ARCH', requested) + with pytest.raises(sap.SAPError): + sap.helper_command() + + +def test_relative_cache_rejected_before_helper_start(monkeypatch): + monkeypatch.setenv('MDAST_SAP_CACHE_DIR', 'relative/cache') + def forbidden(*args, **kwargs): + pytest.fail('invalid cache reached subprocess') + monkeypatch.setattr(sap.subprocess, 'Popen', forbidden) + with pytest.raises(sap.SAPError, match='absolute'): + sap.SAPSigner(sap.parse_bag(BAG), 'AABBCCDDEEFF') diff --git a/tools/sap/build.py b/tools/sap/build.py index bbc1b60..b9b0138 100644 --- a/tools/sap/build.py +++ b/tools/sap/build.py @@ -14,6 +14,21 @@ 'windows-amd64', 'windows-arm64') +def configure_runtime_cache(source): + """Apply one guarded cache-location hook to verified upstream sources.""" + for name in ('internal/sap/assets/assets.go', 'internal/sap/unicorn/cache.go'): + path = source / name + content = path.read_text() + if content.count('os.UserCacheDir()') != 1 or content.count('import (') != 1: + raise SystemExit('Pinned SAP cache hook no longer matches upstream') + content = content.replace('import (', 'import (\n\t"github.com/majd/ipatool/v2/internal/sap/cachepath"', 1) + path.write_text(content.replace('os.UserCacheDir()', 'cachepath.Directory()')) + directory = source / 'internal/sap/cachepath' + directory.mkdir() + for name in ('cache.go', 'cache_test.go'): + shutil.copy2(ROOT / 'tools/sap' / name, directory / name) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--go', default='go') @@ -37,6 +52,9 @@ def main(): # Go module cache files are read-only; leave the cache untouched. for path in source.rglob('*'): path.chmod(0o755 if path.is_dir() else 0o644) + configure_runtime_cache(source) + subprocess.run([args.go, 'test', '-mod=readonly', './internal/sap/cachepath'], + cwd=source, check=True) command = source / 'cmd/mdast-sap' command.mkdir() shutil.copy2(ROOT / 'tools/sap/main.go', command / 'main.go') diff --git a/tools/sap/cache.go b/tools/sap/cache.go new file mode 100644 index 0000000..021f169 --- /dev/null +++ b/tools/sap/cache.go @@ -0,0 +1,18 @@ +// Local optional runtime cache for checksum-pinned public SAP assets. +package cachepath + +import ( + "fmt" + "os" + "path/filepath" +) + +func Directory() (string, error) { + if directory := os.Getenv("MDAST_SAP_CACHE_DIR"); directory != "" { + if !filepath.IsAbs(directory) { + return "", fmt.Errorf("SAP runtime cache must be an absolute directory") + } + return directory, nil + } + return os.UserCacheDir() +} diff --git a/tools/sap/cache_test.go b/tools/sap/cache_test.go new file mode 100644 index 0000000..dca9984 --- /dev/null +++ b/tools/sap/cache_test.go @@ -0,0 +1,31 @@ +package cachepath + +import ( + "os" + "testing" +) + +func TestExplicitCache(t *testing.T) { + directory := t.TempDir() + t.Setenv("MDAST_SAP_CACHE_DIR", directory) + actual, err := Directory() + if err != nil || actual != directory { + t.Fatalf("explicit cache was not selected") + } +} + +func TestRelativeCacheRejected(t *testing.T) { + t.Setenv("MDAST_SAP_CACHE_DIR", "relative/cache") + if _, err := Directory(); err == nil { + t.Fatal("relative cache accepted") + } +} + +func TestDefaultCacheUnchanged(t *testing.T) { + t.Setenv("MDAST_SAP_CACHE_DIR", "") + want, wantErr := os.UserCacheDir() + actual, err := Directory() + if (err != nil) != (wantErr != nil) || actual != want { + t.Fatal("default user cache changed") + } +}