montana/Android/Внешний-аудит/01-Архитектура.md

265 lines
17 KiB
Markdown
Raw Normal View History

2026-05-18 22:11:45 +03:00
# 01. Архитектура — Montana Android v6.5.0
## Высокоуровневая схема
```
┌─────────────────────────────────────────────────────────────────────┐
│ Android-устройство │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ quest.montana.vpn (APK 32 MB) │ │
│ │ ┌──────────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ MainActivity │◀▶│ WebView │ │ │
│ │ │ (Kotlin) │ │ ↳ app.html + JS │ │ │
│ │ │ - JS bridge │ │ ↳ BIP39 derivation │ │ │
│ │ │ - VPN permission │ │ ↳ localStorage (seed) │ │ │
│ │ └──────────────────────┘ └────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ MontanaVpnService (foreground service) │ │ │
│ │ │ - tun0 интерфейс (172.19.0.1/30, default route) │ │ │
│ │ │ - xray-core (vless+reality outbound) │ │ │
│ │ │ - hev-socks5-tunnel (TUN ↔ SOCKS5) │ │ │
│ │ │ - HeartbeatThread (раз в 5 сек, через xray SOCKS5) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ │
│ default route → tun0 │ heartbeat → SOCKS5 → Reality
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Helsinki (91.132.142.42:443) — публичный вход VPN │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ haproxy (TCP L4) │ │
│ │ ↳ stick-table type ip size 200k expire 24h │ │
│ │ ↳ balance leastconn │ │
│ │ ↳ распределяет на один из 3 backend │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ :40443 (fi) :40444 (fra) :40445 (us) │
│ xray-pinned-fi xray-pinned-fra xray-pinned-us │
│ ↓ freedom ↓ cascade ↓ cascade │
│ internet via via Reality via Reality │
│ 91.132.142.42 →89.19.208.158 →86.104.72.12 │
└─────────────────────────────────────────────────────────────────────┘
│ │
│ user traffic exits │ heartbeat exits
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Moscow (176.124.208.93) — координатор балансов (НЕ exit, НЕ VPN) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ nginx (443/8443) │ │
│ │ ↳ /api/vpn/heartbeat → :5008 │ │
│ │ ↳ /vpn/sub → :5008 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ montana-vpn-balance.service (Flask + gunicorn) │ │
│ │ ↳ /var/lib/montana-vpn-balance/balances.json │ │
│ │ ↳ atomic LOCK_EX через balances.json.lock │ │
│ │ ↳ purge inactive >30 days via /api/vpn/admin/purge │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ montana-node.service (TimeChain validator) │ │
│ │ ↳ ПОКА не интегрирован с балансами VPN (см. 07) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Компоненты Android-приложения
### MainActivity.kt
Точка входа. Создаёт `WebView`, загружает `app.html` через `loadDataWithBaseURL("https://montana.local/", ...)` (синтетический base URL, наружу запросы не идут).
Связь с native:
- `MontanaApp.bip39()` — выдаёт wordlist BIP39 EN (с SHA-256 integrity check, см. `03-Криптография.md` §2)
- `MontanaApp.startVPN(address)` — запрашивает разрешение VPN, стартует `MontanaVpnService`
- `MontanaApp.stopVPN()` — останавливает сервис
- `MontanaApp.isVpnRunning()` / `getBalance()` / `getSeconds()` / `getNode()` / `getStatus()` — read-only state
WebView-debug включён только в DEBUG-сборках через `BuildConfig.DEBUG`.
### MontanaVpnService.kt (foreground service)
Реализует Android `VpnService`. При старте:
1. **Запрашивает default network** через `ConnectivityManager.requestNetwork()` — для `setUnderlyingNetworks(...)` при смене WiFi/cellular.
2. **Строит `VpnService.Builder`:**
- MTU 1500
- Адрес `172.19.0.1/30`
- DNS `1.1.1.1`, `8.8.8.8`
- Default route `0.0.0.0/0`
- **`addDisallowedApplication(packageName)`** — критично: само приложение исключается из своего же VPN (иначе TUN-петля, см. `07` F-петля)
3. **Поднимает libv2ray (xray-core)** с конфигом vless+reality к `cdn.montana.quest:443`.
4. **Запускает `HevSocks5TunnelService`** для TUN ↔ SOCKS5 (127.0.0.1:10808).
5. **HeartbeatThread** раз в 5 сек: SOCKS5 → SSL (ALPN=`http/1.1`) → POST `/api/vpn/heartbeat` на montana.quest. Heartbeat идёт **через** Reality cascade — backend видит exit-IP как источник.
### app.html (WebView UI + кошелёк)
Single-page приложение. Три экрана: `s-main`, `s-auth`, `s-recover`, `s-account`.
Логика кошелька — целиком JS:
- `generateMnemonic()` — 256 бит энтропии через `crypto.getRandomValues`, BIP39 24 слова с checksum
- `mnemonicToEntropy(words)` — обратная операция + checksum validation
- `mnemonicToSeed(mnemonic, "")` — PBKDF2-HMAC-SHA512, 2048 итераций, 512 бит вывод
- `deriveAddr(words)``SHA-256("montana-v1:" || seed)[0..20]`
Подробности — `03-Криптография.md` §3.
---
## Компоненты серверной части
### Helsinki — публичный вход VPN
**haproxy** на `0.0.0.0:443` (TCP mode):
- `balance leastconn` — новый клиент попадает на наименее загруженный backend
- `stick-table type ip size 200k expire 24h, stick on src` — закрепление source-IP за backend на 24 часа
Три локальных xray-инстанса:
- `xray-pinned-fi` на `127.0.0.1:40443` — outbound `freedom`, exit с Helsinki
- `xray-pinned-fra` на `127.0.0.1:40444` — outbound vless+reality cascade к `89.19.208.158:443` (Frankfurt)
- `xray-pinned-us` на `127.0.0.1:40445` — outbound vless+reality cascade к `86.104.72.12:443` (NewYork)
Все три inbound используют **identical Reality params** (privateKey, sni, shortId) → клиент не различает на стороне приложения.
### Moscow — координатор балансов
**Roles:** API REST для heartbeat и subscription. **Не выпускает VPN-трафик**, **не является exit-нодой**.
`montana-vpn-balance.service` (Flask + gunicorn 2 workers × 4 threads):
- `POST /api/vpn/heartbeat` — приём heartbeat, начисление 0.001 Ɉ/сек
- `GET /api/vpn/balance` — read-only баланс
- `GET /vpn/sub` — универсальный VLESS URL (base64)
- `POST /api/vpn/admin/purge` — localhost-only cleanup inactive
State: `/var/lib/montana-vpn-balance/balances.json` (JSON-файл).
### Frankfurt, NewYork — exit-узлы каскада
Только принимают cascade traffic из Helsinki через Reality:443, выполняют freedom outbound в открытый интернет. **Не общаются** с Android-клиентом напрямую.
---
## Потоки данных
### Поток 1: Стандартный VPN-трафик
```
Browser/любое приложение Android
Default route (Android system)
▼ (не если приложение исключено через addDisallowedApplication)
tun0 (172.19.0.1)
hev-socks5-tunnel
▼ TCP/UDP → SOCKS5
127.0.0.1:10808 (xray inbound на устройстве)
▼ vless+reality
cdn.montana.quest:443 (= 91.132.142.42)
Helsinki haproxy (stick on src)
xray-pinned-{fi|fra|us}
▼ (для fra/us — vless+reality cascade)
Exit IP открытый интернет
```
### Поток 2: Heartbeat кошелька
```
HeartbeatThread (Kotlin)
▼ socksConnect через 127.0.0.1:10808
▼ Тот же канал что Поток 1
Helsinki → backend exit → internet
▼ POST montana.quest:443/api/vpn/heartbeat
nginx (Moscow:443)
▼ proxy_pass
gunicorn (127.0.0.1:5008)
▼ atomic LOCK_EX read-modify-write
balances.json
▼ HTTP/1.1 200 application/json
{"balance": ..., "node": "newyork", "node_ip": "86.104.72.12", "via_montana": true}
▼ org.json.JSONObject (Kotlin)
MontanaVpnService.lastBalance / lastStatusText / connectedNode
▼ getBalance() / getStatus() bridge
WebView UI
```
### Поток 3: Создание кошелька
```
User: tap "Войти в Монтану" → "Создать ключ"
▼ JS createKey()
crypto.getRandomValues(Uint8Array(32)) ← 256 бит энтропии
▼ entropyToMnemonic(entropy)
│ ↳ SHA-256(entropy) → checksum bits (первые 8 бит)
│ ↳ 264 бита / 11 = 24 слова
│ ↳ Каждое 11-битное число → index в BIP39 EN wordlist
│ (wordlist получен через MontanaApp.bip39() с SHA-256 verify)
▼ 24 слова
deriveAddr(words)
│ ↳ mnemonicToEntropy(words) — validate checksum
│ ↳ PBKDF2-HMAC-SHA512(words, salt="mnemonic", iter=2048) → 64B seed
│ ↳ SHA-256("montana-v1:" || seed) → 32B hash
│ ↳ first 20 bytes → hex → 40-символьный address
▼ (seed_words, address)
localStorage["m.seed"] = words.join(" ")
localStorage["m.addr"] = address ← внимание: оба в plain text!
```
---
## Границы доверия
| Граница | Что доверяем | Что не доверяем |
|---------|--------------|------------------|
| Android OS | Изоляция приложений (data dir 0700), Permission model, KeyStore (не используется!) | Root-доступ, malicious apps с MANAGE_EXTERNAL_STORAGE, forensic image |
| WebView Chromium | SubtleCrypto детерминизм (SHA-256, PBKDF2, NFKD) | Гарантии константного времени, защиту от подмены кода через debug |
| haproxy stick-table | Sticky-pin по source-IP до 24h | Защиту от смены клиентом IP (mobile network → IP меняется) |
| Reality TLS | Active probing resistance, fingerprint disguise | DPI который умеет анализировать SNI Echo, machine learning по timing |
| Moscow backend | Атомарный учёт balances.json | Подпись heartbeat (её сейчас нет — см. `07` F-2) |
| Cascade exit | Изоляция трафика разных пользователей | Логи трафика на exit-нодах (нет policy сейчас) |
---
## Что приложение делает
1. Создаёт / восстанавливает BIP39-кошелёк (24 слова английский wordlist).
2. Запускает VPN через системный `VpnService` API.
3. Поднимает локальный xray (libv2ray), пересылает весь TCP/UDP трафик устройства через Reality на Helsinki.
4. Шлёт heartbeat backend для учёта времени онлайн → начисление 0.001 Ɉ/сек.
5. Показывает баланс, статус, узел подключения, таймер сессии.
## Что приложение НЕ делает
1. Не интегрировано с консенсусом TimeChain (балансы у Moscow в JSON-файле, не у валидаторов).
2. Не подписывает heartbeat криптографически (любой может слать heartbeat от чужого имени с правильного exit-IP).
3. Не шифрует seed в localStorage (plain text 24 слова).
4. Не использует Android KeyStore.
5. Не требует backup confirmation при создании кошелька.
6. Не имеет proper logout flow с предупреждением о необратимой потере.
Все эти ограничения — в `07-Известные-ограничения.md` с severity и closure paths.