montana/Android/Внешний-аудит/09-Инвентаризация-кода.md

296 lines
12 KiB
Markdown
Raw Normal View History

2026-05-18 22:11:45 +03:00
# 09. Инвентаризация кода — Montana Android v6.5.0
## Структура проекта
```
Montana/Android/MontanaApp/
├── app/
│ ├── build.gradle.kts (50 строк) — версии, deps, signing
│ ├── src/main/
│ │ ├── AndroidManifest.xml (50 строк) — permissions, service decl
│ │ ├── java/quest/montana/app/
│ │ │ ├── MainActivity.kt (90 строк) — WebView host
│ │ │ └── MontanaVpnService.kt (450 строк) — VPN service + heartbeat
│ │ ├── assets/
│ │ │ ├── app.html (~400 KB, основная масса = base64 images)
│ │ │ ├── bip39-en.txt (13 KB)
│ │ │ ├── geoip.dat, geosite.dat (xray data)
│ │ │ ├── symbol.jpg, juno.jpg, juno-back.jpg
│ │ ├── jniLibs/ (native xray + hev-socks5-tunnel)
│ │ └── res/
│ │ ├── values/strings.xml
│ │ ├── values/themes.xml
│ │ └── mipmap-*/ic_launcher.*
└── settings.gradle.kts
```
## Файл-за-файлом разбор
### MainActivity.kt
**Назначение:** Activity-точка входа. Создаёт WebView, грузит app.html, прокидывает JS bridge `MontanaBridge`.
**Ключевые элементы:**
```kotlin
class MainActivity : AppCompatActivity() {
companion object { @Volatile var instance: MainActivity? = null }
lateinit var web: WebView
private val PREP_VPN = 991
override fun onCreate(savedInstanceState: Bundle?) {
// создаёт WebView, JavaScriptEnabled, добавляет MontanaBridge,
// загружает app.html через loadDataWithBaseURL("https://montana.local/", ...)
// WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
}
override fun onDestroy() {
// tearDown WebView корректно: stopLoading, removeJavascriptInterface,
// detach view, web.destroy(), clear instance
}
fun requestVpnPermissionAndStart(address: String) { ... }
fun startVpnService(address: String) { ... }
fun stopVpnService() { ... }
}
class MontanaBridge(activity: MainActivity) {
@JavascriptInterface fun version(): String
@JavascriptInterface fun bip39(): String // с SHA-256 integrity check
@JavascriptInterface fun platform(): String = "android"
@JavascriptInterface fun isNative(): Boolean = true
@JavascriptInterface fun isVpnRunning(): Boolean
@JavascriptInterface fun getBalance(): Double
@JavascriptInterface fun getSeconds(): Double
@JavascriptInterface fun getStatus(): String
@JavascriptInterface fun getNode(): String
@JavascriptInterface fun isOnline(): Boolean
@JavascriptInterface fun startVPN(address: String): Boolean
@JavascriptInterface fun stopVPN(): Boolean
}
```
**Security-relevant методы:**
- `bip39()` — integrity check SHA-256 (см. `03-Криптография.md` §2)
- `startVPN(address)` — единственная точка инициации VPN, требует Android VPN permission
### MontanaVpnService.kt
**Назначение:** `VpnService` который поднимает TUN-интерфейс, запускает xray + hev-socks5-tunnel, шлёт heartbeats.
**Структура:**
```kotlin
class MontanaVpnService : VpnService() {
companion object {
// constants: TAG, NOTI_ID, CHAN_ID, ACTION_START/STOP, EXTRA_ADDRESS
// SOCKS_HOST = "127.0.0.1", SOCKS_PORT = 10808
// BACKEND_HOST = "montana.quest", BACKEND_PORT = 443
// @Volatile state: isRunning, lastBalance, lastSeconds, lastStatusText, connectedNode, viaMontana
}
private var tun: ParcelFileDescriptor? = null
private var core: CoreController? = null // xray-core
private var hbThread: Thread? = null
private var address: String = ""
private var netCallbackRegistered = false
private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager }
private val defaultNetworkRequest by lazy { NetworkRequest.Builder()... }
private val defaultNetworkCallback = object : NetworkCallback() { ... }
override fun onCreate() { ensureNotiChannel(); Libv2ray.initCoreEnv(...); }
override fun onStartCommand(...) { startVpn() либо stopVpn() }
override fun onDestroy() { stopVpn() }
override fun onRevoke() { stopVpn() }
private fun startVpn() {
// 1. requestNetwork для setUnderlyingNetworks
// 2. VpnService.Builder с addDisallowedApplication(self)
// 3. builder.establish() → tunFd
// 4. libv2ray startLoop с xray config
// 5. hev-socks5-tunnel start
// 6. HeartbeatThread spawn
}
private fun stopVpn() { ... корректный teardown в reverse order ... }
private fun socksConnect(host: String, port: Int, timeoutMs: Int = 10000): Socket {
// SOCKS5 handshake к локальному xray:10808
// 1. TCP connect
// 2. Send 0x05 0x01 0x00 (no auth)
// 3. Receive 0x05 0x00
// 4. Send 0x05 0x01 0x00 0x03 <hostlen> <host> <portHi> <portLo>
// 5. Parse response, return connected Socket
}
private fun sendHeartbeat() {
// socksConnect montana.quest:443
// SSL wrap с ALPN pin http/1.1
// POST /api/vpn/heartbeat
// org.json.JSONObject parse response
// Update Companion state
}
private fun buildXrayConfig(): String { ... возвращает JSON ... }
private fun ensureNotiChannel() { ... }
private fun buildNotification(text: String): Notification { ... }
private fun startForegroundNoti(text: String) { ... }
private fun updateNoti(text: String) { mgr.notify(NOTI_ID, ...) }
}
```
**Security-relevant функции:**
- `startVpn()``addDisallowedApplication(packageName)` критично для разрыва TUN-петли
- `sendHeartbeat()` — через SOCKS5 в локальный xray, ALPN pin = http/1.1, JSON parse через org.json
- `socksConnect()` — ручной SOCKS5 handshake без библиотек (auditable)
### app.html (single-page)
Не приложу полностью (400 KB включая base64 images). Ключевые JS-функции:
```javascript
// Constants
const L_S='m.seed', L_A='m.addr';
const RATE = 12.04; // Ɉ → ₽ visual rate
const isNative = !!window.MontanaApp;
// State
let st = {addr: null, online: false, bal: 0, sec: 0};
let BIP39_WORDS = null;
// Screen navigation
function show(n) { ... }
// BIP39
async function loadBip39() { ... через MontanaApp.bip39() + check size 2048 }
async function entropyToMnemonic(entropy) { ... }
async function mnemonicToEntropy(words) { ... checksum validate }
async function mnemonicToSeed(mnemonic, passphrase) { ... PBKDF2-HMAC-SHA512 }
async function deriveAddr(words) { ... SHA-256("montana-v1:" || seed)[0:20] }
async function generateMnemonic() { ... 256-bit entropy }
// Wallet operations
async function createKey() { ... }
async function recover() { ... }
function showSeed() { ... render 24 numbered words }
function hideSeed() { ... }
function logoutAcc() { ... localStorage.removeItem ... }
// UI updates
function render() { ... bn, bf elements }
function setUI(on, msg) { ... .power class, lbl, coin .on }
function applyAuth() { ... login-btn label }
function onLoginTap() { show('auth') либо show('account') }
// Polling
function pollUI() { ... }
function pollSync() { ... }
function tick() {
// counter +0.001/sec + uptime hh:mm:ss
}
function startPoll() / startTick() { setInterval }
// Boot
(async() => {
let addr = localStorage.getItem(L_A);
if (addr) st.addr = addr;
applyAuth(); show('main');
startPoll(); startTick();
})();
// Auto-VPN если ещё не запущен
if (isNative) setTimeout(() => { if (!MontanaApp.isVpnRunning()) toggle() }, 700);
```
**Security-relevant:**
- `loadBip39()` через native bridge с integrity check
- `mnemonicToEntropy()` — единственное место validate BIP39 checksum
- `mnemonicToSeed()` — WebCrypto PBKDF2 (не custom)
- `deriveAddr()` — детерминирован, проверка checksum включена
### AndroidManifest.xml
```xml
<manifest ...>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application ... usesCleartextTraffic="true">
<activity android:name=".MainActivity" exported="true" ... />
<service android:name=".MontanaVpnService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<intent-filter><action android:name="android.net.VpnService"/></intent-filter>
<property name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" value="vpn"/>
</service>
</application>
</manifest>
```
**Permissions justification:**
- INTERNET — heartbeat + xray outbound
- ACCESS/CHANGE_NETWORK_STATE — `setUnderlyingNetworks` для cellular/wifi switching
- FOREGROUND_SERVICE_SPECIAL_USE — Android 14+ требует для VPN
- POST_NOTIFICATIONS — Android 13+ требует для foreground noti
- WAKE_LOCK — keep VPN active при screen off
- RECEIVE_BOOT_COMPLETED — `usesCleartextTraffic` — нужно для WebView local content (synthetic `https://montana.local/`)
**Не используются:**
- ACCESS_FINE_LOCATION — не нужно
- READ_EXTERNAL_STORAGE — не пишем во внешнее хранилище
- READ_PHONE_STATE — не получаем IMEI / device IDs
### app/build.gradle.kts
Минимальный, см. `08-Воспроизводимая-сборка.md` §2. Версионирование вручную:
```kotlin
defaultConfig {
applicationId = "quest.montana.vpn"
minSdk = 24
targetSdk = 34
versionCode = 60500
versionName = "6.5.0"
buildConfigField("String", "APP_URL", "\"https://montana.quest/vpn/app/\"")
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") }
}
```
## Серверная часть (отдельно от Android)
### /opt/montana-vpn-balance/app.py (Moscow)
~280 строк Flask. Endpoints:
- `GET /api/vpn/balance?address=X` — баланс read-only
- `POST /api/vpn/heartbeat` — основной endpoint начисления
- `GET /api/vpn/check` — диагностика IP/via_montana
- `GET /api/vpn/stats` — агрегаты
- `GET /vpn/sub` — universal VLESS link (base64)
- `POST /api/vpn/admin/purge` — localhost-only cleanup
### Конфиги Helsinki
- `/etc/haproxy/haproxy.cfg` — see `04-Сетевой-слой.md` §3
- `/etc/xray-pinned/{fi,fra,us}.json` — 3 backend xray-pinned
- `/etc/systemd/system/xray-pinned@.service` — systemd template
- `/etc/systemd/system/haproxy.service` — systemd unit
### Конфиги Frankfurt, NewYork
- `/usr/local/etc/xray/config.json` — single inbound, cascade-only receiver
- `/etc/systemd/system/xray.service` — systemd unit
## Точки риска при code review
1. **`MontanaVpnService.startVpn()`** — пропуск `addDisallowedApplication` = TUN петля.
2. **`socksConnect()`** — ручной SOCKS5 protocol, проверить byte-exact handshake.
3. **`sendHeartbeat()`** — ALPN pin критичен для HTTP/1.1 parser.
4. **`deriveAddr()` в app.html** — `domain = "montana-v1:"` префикс должен оставаться immutable, иначе все адреса другие.
5. **`bip39()` в MainActivity** — fail-closed при integrity mismatch.
6. **Helsinki haproxy `stick on src`** — критично для sticky pin.
7. **`with_state_lock()` в backend** — атомарность read-modify-write.