montana/Android/Внешний-аудит/09-Инвентаризация-кода.md
2026-05-18 22:11:45 +03:00

12 KiB
Raw Blame History

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.

Ключевые элементы:

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.

Структура:

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-функции:

// 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

<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. Версионирование вручную:

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.htmldomain = "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.