montana/Android/Внешний-аудит/05-Состояние-и-хранилище.md
2026-05-18 22:11:45 +03:00

11 KiB
Raw Blame History

05. Состояние и хранилище — Montana Android v6.5.0

§1. Where state lives

Сущность Хранилище Доступ Защита
BIP39 24-слова (seed) localStorage WebView "m.seed" JS app.html Android sandbox 0700
Адрес кошелька localStorage WebView "m.addr" JS app.html Android sandbox 0700
Баланс (last sync) MontanaVpnService.lastBalance (volatile) Kotlin bridge RAM only, теряется при stop
Текущий exit-узел MontanaVpnService.connectedNode Kotlin bridge RAM only
Authoritative баланс /var/lib/montana-vpn-balance/balances.json (Moscow) flask backend single-server, не реплицировано
Reality private keys узлов /usr/local/etc/xray/config.json (по узлам) xray user nobody UNIX 0640, root и xray-user only
haproxy stick-table RAM на Helsinki haproxy process persisten 24h в RAM
Genesis keystore Montana/Android/keystore/montana.keystore сборщик APK у автора, не в git

§2. Android app data dir layout

/data/data/quest.montana.vpn/
├── app_webview/
│   └── Default/
│       ├── Local Storage/
│       │   └── leveldb/  ← здесь "m.seed" и "m.addr"
│       ├── Cookies
│       └── databases/
├── files/
│   ├── geoip.dat       ← xray-data, copied на первом запуске
│   ├── geosite.dat
│   └── hev.yaml        ← runtime config hev-socks5-tunnel
├── cache/
│   └── ...
├── shared_prefs/
│   └── ...
└── lib -> /data/app/.../lib/arm64/
    ├── libgojni.so          ← libv2ray (xray-core)
    ├── libhev-socks5-tunnel.so
    └── libhysteria2.so      ← не используется в 6.5.0 (legacy artifact)

Permissions:

drwx------ owner=u0_a352  /data/data/quest.montana.vpn/

UID u0_a352 — Android per-app UID. Другие приложения видят только свои dir-ы.


§3. localStorage WebView — формат

После создания кошелька через createKey():

localStorage.setItem("m.seed", "ranch basket resource enemy bridge spray holiday thing yellow round army mimic renew head test cradle piece public differ diamond connect leisure wrong ask")
localStorage.setItem("m.addr", "2f8714b236118011647ec51d0ca6ad40d286bec7")

Размер: ~250 байт (24 слова × среднее 7 букв + spaces) + 40 hex address = ~290 байт total.

Persistence: WebView Chromium leveldb. Сохраняется между запусками приложения. Удаляется при:

  • pm clear quest.montana.vpn (ADB или Android Settings → Apps → Clear data)
  • Деинсталляция приложения
  • Vasily logoutAcc() в самом app

Encryption: отсутствует на уровне приложения. Полагаемся только на Android FBE (File-Based Encryption) при выключенном устройстве.


§4. Recovery procedure

recover() в app.html:

async function recover(){
  const t = document.getElementById('seed-in').value.trim().toLowerCase();
  const w = t.split(/\s+/).filter(Boolean);
  const errEl = document.getElementById('err'); errEl.textContent = '';
  try {
    const addr = await deriveAddr(w);  // checks checksum + derives
    localStorage.setItem(L_S, w.join(' '));
    localStorage.setItem(L_A, addr);
    st.addr = addr; applyAuth(); show('main');
  } catch(e) {
    errEl.textContent = e.message;  // "контрольная сумма не совпадает" / "неизвестное слово"
  }
}

Validation:

  • Точно 24 слова (иначе throw)
  • Каждое слово ∈ BIP39 EN wordlist (indexOf returns -1 → throw)
  • Checksum bits совпадают с SHA-256(entropy)[0:8 bits] → throw if mismatch

После всех проверок — детерминированно выводится адрес. Сохраняется в localStorage. Главный экран показывает баланс.


§5. Server-side state: balances.json

Файл: /var/lib/montana-vpn-balance/balances.json (UNIX 0644 owner www-data).

Формат:

{
  "2f8714b236118011647ec51d0ca6ad40d286bec7": {
    "balance": 0.043,
    "seconds": 43.5,
    "last_hb": 1779120000.5,
    "created": 1779119900.2,
    "last_node": "newyork"
  },
  ...
}

Поля:

  • balance — накопленные Ɉ (float, точность 1e-6)
  • seconds — суммарное время online (float)
  • last_hb — UNIX timestamp последнего heartbeat
  • created — UNIX timestamp первого heartbeat
  • last_node — символическое имя узла последнего heartbeat (helsinki/frankfurt/newyork)

Atomic locking через with_state_lock():

def with_state_lock(mutator):
    LOCK_PATH = DATA.with_suffix(".lock")
    with open(LOCK_PATH, "a+") as lock_f:
        fcntl.flock(lock_f, fcntl.LOCK_EX)
        try:
            with open(DATA, "r") as f:
                data = json.load(f)
            result = mutator(data)
            tmp = DATA.with_suffix(".tmp")
            with open(tmp, "w") as f:
                json.dump(data, f)
            os.replace(tmp, DATA)
            return result
        finally:
            fcntl.flock(lock_f, fcntl.LOCK_UN)

Один LOCK_EX охватывает read+modify+write — concurrent heartbeats сериализуются.


§6. Cleanup policy

/api/vpn/admin/purge (localhost-only):

PURGE_INACTIVE_DAYS = 30

def _purge_inactive(data):
    cutoff = time.time() - PURGE_INACTIVE_DAYS * 86400
    to_remove = []
    for addr, rec in data.items():
        last_hb = rec.get("last_hb", 0)
        balance = rec.get("balance", 0)
        if last_hb < cutoff and balance < 1e-9:
            to_remove.append(addr)
    for addr in to_remove:
        del data[addr]
    return to_remove

Критерий удаления: last_hb старше 30 дней И balance < 1e-9.

Не удаляются:

  • Записи с положительным балансом (даже если давно неактивны) — пользователь может вернуться
  • Записи моложе 30 дней

Manual trigger: на текущий момент cron job не настроен. Cleanup вызывается вручную автором (curl -X POST http://127.0.0.1:5008/api/vpn/admin/purge).

Closure path (Phase 2): cron daily + автоматический purge при каждом 1000-м heartbeat.


§7. Состояние при сбое узла Moscow

Сценарий: Moscow упал, недоступен.

Последствия:

  • Heartbeats клиентов получают TCP timeout / connection refused
  • В Kotlin это catch → Log.w(TAG, "hb err: ${e.message}") → продолжение цикла
  • В JS UI counter продолжает идти оптимистично (+0.001/sec)
  • При восстановлении Moscow → следующий heartbeat прошёл → backend сравнивает client last_hb с now → начисляет gap но не более MAX_GAP_SECONDS = 30 секунд

Что теряется:

  • Heartbeats во время outage пропадают (никогда не доедут до backend)
  • При outage > 30 секунд — клиент теряет credit за время свыше 30s

Closure path:

  • Phase 2: Postgres replica на другой узел
  • Phase 3: TimeChain consensus — Moscow становится один из validator, отказ Moscow не теряет state

§8. Состояние при сбое узла VPN

Сценарий: Frankfurt упал, активные cascade сессии разрываются.

Последствия:

  • haproxy TCP-check за 6 сек detected → backend fra DOWN
  • option redispatch retries 2 — sticked клиенты с этого backend → автоматический реrouting на следующий best (по leastconn)
  • Stick-table entry для этих IP обновляется на новый backend
  • Существующие TCP сессии разрываются — клиентский xray делает reconnect → новое TCP → новый backend

От пользователя UX: короткий disconnect (5-10 секунд), затем восстановление через другой exit.

Empirical verification: требует injection test, не выполнено в этой сессии.


§9. Genesis keystore

Файл: /Users/kh./Python/Ничто/Montana/Android/keystore/montana.keystore + .password рядом.

Метаданные:

  • Alias: montana
  • Owner: CN=Montana VPN, O=Montana Network, L=Genesis, C=RU
  • Valid: 2026-05-06 → 2126-04-12 (100 лет)
  • Подписной алгоритм: SHA384withRSA, 4096-bit
  • SHA-256 fingerprint: 305bc99b40e6106f28c6fcc5dce4772761d2630d5aca9fee076dc0691913ce4d

Security:

  • Не в git (исключён через .gitignore родительского репозитория + hub-sync excludes)
  • Хранится у автора локально
  • Password в .password рядом — не encrypted file system level

Сценарий компрометации keystore:

  • Атакующий может выпустить malicious APK с тем же signature → Android воспринимает его как обновление приложения
  • Пользователь обновляется через Google Play / direct APK → malicious code исполняется в context уже-доверенного приложения
  • Доступ ко всем localStorage данным, перехват heartbeat, кража кошельков

Mitigation:

  • Хранение keystore offline (текущее: на устройстве автора)
  • Регулярное создание backup (текущее: не автоматизировано)
  • Closure path: hardware security module (HSM) для подписи APK при mainnet

§10. Сводка по защищённости состояния

Данные At rest In transit Гарантия persistence
Seed 24 слова plain text localStorage n/a (не покидает устройство) до pm clear
Адрес plain text localStorage в каждом heartbeat до pm clear
Баланс (client) RAM only в JSON ответе от backend теряется при stop service
Баланс (server) JSON-файл с LOCK_EX TLS Reality до 30 дней inactive с пустым balance
Reality privкeys UNIX 0640 n/a пока хостинг провайдер не упадёт
haproxy stick-table RAM Helsinki n/a 24h expire
Genesis keystore plain .keystore file n/a (offline) пока автор хранит

Главная слабость: seed в plain text + single point of failure Moscow для balance. Closure paths указаны в 07-Известные-ограничения.md.