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
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,9 @@ Download iOS applications (.ipa) from the App Store.
- `--appstore_bundle_id <bundle>` - Bundle identifier
- `--appstore_apple_id <email>` - iTunes account email
- `--appstore_password <password>` - iTunes account password
- `--appstore_2FA <code>` - 6-digit 2FA code

**Optional Parameters:**
- `--appstore_2FA <code>` - Fresh 6-digit code when Apple requires verification
- `--appstore_file_name <name>` - Custom filename for downloaded IPA

**Getting App ID:**
Expand All @@ -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
Expand All @@ -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!

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down
33 changes: 33 additions & 0 deletions docs/STG-5356-appstore-download.md
Original file line number Diff line number Diff line change
@@ -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).
26 changes: 23 additions & 3 deletions mdast_cli/distribution_systems/appstore_client/sap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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():
Expand All @@ -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.
Expand All @@ -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:
Expand Down
Loading