255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
|
|
# 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()`:
|
|||
|
|
```javascript
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
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).
|
|||
|
|
|
|||
|
|
**Формат:**
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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()`:
|
|||
|
|
```python
|
|||
|
|
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):
|
|||
|
|
```python
|
|||
|
|
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`.
|