11 KiB
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 (
indexOfreturns -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 последнего heartbeatcreated— UNIX timestamp первого heartbeatlast_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
fraDOWN 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.