CHANGES_2026-06-12_client_hardening.md lists deployment steps for the server (shared protocol.py fix) and mirror requirements R1-R7 with acceptance test scenarios for the native iOS/Android clients. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
9.7 KiB
Markdown
117 lines
9.7 KiB
Markdown
# Změnový požadavek: Client hardening round — 2026-06-12
|
|
|
|
**Repo:** `ssh://git@git.facilitygo.com:222/filip/Kecalek_python.git`, větev `master`.
|
|
**Jak stáhnout:** `git pull origin master` (server i Python klienti). iOS/Android mají vlastní repa — tento dokument je pro ně **specifikace změn k zrcadlení** v nativním kódu.
|
|
|
|
**Kompatibilita:** Žádná změna wire protokolu, žádný version bump (`VERSION = 0.8.6` beze změny), žádná DB migrace. Staré a nové klienty/servery lze libovolně kombinovat.
|
|
|
|
---
|
|
|
|
## Souhrn
|
|
|
|
Hloubková revize Python klienta našla a opravila 9 chyb. Jedna oprava je ve **sdíleném** `protocol.py` (týká se i serveru), zbytek je čistě klientský. iOS/Android musí prověřit a případně zrcadlit 6 z nich (viz tabulka níže).
|
|
|
|
| # | Oprava | Soubor | Server | Python klient | iOS/Android |
|
|
|---|--------|--------|:------:|:-------------:|:-----------:|
|
|
| 1 | X3DH session se adoptuje až po prvním úspěšném decryptu | chat_core.py | — | ✅ | **zrcadlit** |
|
|
| 2 | Sync watermark se neposouvá přes nedešifrovatelné zprávy | chat_core.py | — | ✅ | **zrcadlit** |
|
|
| 3 | PoW registrace: NameError + blokování event loopu | chat_core.py | — | ✅ | prověřit |
|
|
| 4 | rotate_keys: nový RSA klíč persistovat až po server OK | chat_core.py | — | ✅ | **zrcadlit** |
|
|
| 5 | Pád GUI při selhání downloadu obrázku (`statusBar()`) | gui_client.py | — | ✅ | n/a (Qt) |
|
|
| 6 | Privacy lock šel obejít klávesovou zkratkou bez hesla | gui_client.py | — | ✅ | **prověřit** |
|
|
| 7 | Qt widgety z asyncio vlákna (registration confirm) | gui_client.py | — | ✅ | n/a (Qt) |
|
|
| 8 | Leak okna + mrtvé key-change varování + fronta zpráv po logoutu | gui_client.py | — | ✅ | **zrcadlit** |
|
|
| 9 | Protocol framing: drain oversized zprávy zahazoval další zprávu | protocol.py | **✅ nasadit** | ✅ | **zrcadlit** |
|
|
|
|
---
|
|
|
|
## Požadavky na server (deployment)
|
|
|
|
Oprava #9 je ve sdíleném `protocol.py`, který běží i na serveru:
|
|
|
|
1. `git pull origin master`
|
|
2. Restart serveru (`python server.py`, resp. rebuild Docker image — `Dockerfile` se nemění).
|
|
3. Žádná DB migrace, žádná změna `.env`.
|
|
|
|
**Co se mění:** `ProtocolReader.read_message()` při zahození příliš velké zprávy (`LimitOverrunError`) dříve zahodil celý poslední chunk včetně bajtů za `\n` — tedy začátek **další** zprávy klienta. Výsledek: rozbitý framing, další zpráva selhala na "Invalid message" a klientovi vytimeoutoval pending request. Nově se bajty za delimiterem uchovají v `_leftover` bufferu a obslouží před dalším čtením ze streamu.
|
|
|
|
---
|
|
|
|
## Požadavky na Python klienta (deployment)
|
|
|
|
`git pull origin master` — všechny opravy jsou v `chat_core.py`, `gui_client.py`, `client.py`, `protocol.py`. Žádná změna lokálního úložiště klíčů, cache formát je zpětně kompatibilní (nový volitelný klíč `_decrypt_failed` v message cache záznamech).
|
|
|
|
---
|
|
|
|
## Požadavky na vývoj iOS / Android (zrcadlení v nativním kódu)
|
|
|
|
### R1. X3DH session adoption — instalovat session až po prvním úspěšném decryptu (KRITICKÉ)
|
|
|
|
**Problém:** Pokud klient při zpracování příchozí zprávy s X3DH hlavičkou vytvoří novou Double Ratchet session a hned ji uloží (do paměti i na disk) **před** ověřením, že první zpráva jde dešifrovat, pak replay/forge zprávy s X3DH hlavičkou (nebo poškozená zpráva) trvale přepíše funkční session. Peer pak nemůže dešifrovat nic dalšího.
|
|
|
|
**Požadavek:**
|
|
- Při příchodu zprávy s X3DH hlavičkou a **existující** session: nejdřív zkusit dešifrovat existující sessionou. Při selhání obnovit zálohu existující session, vytvořit kandidátní session přes X3DH (vč. retry s předchozím SPK v grace period) a **teprve po úspěšném dešifrování** kandidátní session adoptovat (uložit do session mapy + persistovat). Pokud selže i X3DH cesta, musí zůstat platná původní session.
|
|
- Při X3DH bez existující session: kandidátní session persistovat také až po úspěšném prvním decryptu.
|
|
- Souvisí s deferred OPK delete (už implementováno v commitu `750290d`): OPK se maže až po prvním úspěšném decryptu, ne při zpracování hlavičky.
|
|
|
|
**Referenční implementace:** `chat_core.py` → `_process_x3dh_header()` (vrací ratchet, neinstaluje) a `_decrypt_dm()` (adoptuje po úspěchu).
|
|
|
|
### R2. Sync watermark nesmí přeskočit nedešifrovatelné zprávy (KRITICKÉ)
|
|
|
|
**Problém:** Inkrementální sync (`get_messages` s `after_ts`) si ukládá timestamp nejnovější stažené zprávy jako watermark. Pokud se watermark posune i přes zprávu, kterou se nepodařilo dešifrovat (typicky group zpráva, jejíž sender key ještě nedorazil — race mezi distribucí klíče a zprávou), zpráva se už nikdy nestáhne → tichá trvalá ztráta.
|
|
|
|
**Požadavek:**
|
|
- Watermark posouvat pouze přes souvislý prefix (vzestupně dle `created_at`) zpráv, které jsou "vyřešené": úspěšně dešifrované, control message (sender key distribuce), smazané, nebo trvale failnuté.
|
|
- Zprávu, která selže na decrypt, označit v lokální cache počtem pokusů; opakovat dešifrování při dalších syncech, po **3 neúspěšných pokusech** ji označit za trvale nečitelnou (zobrazit "[Decryption failed]") a watermark přes ni pustit (jinak by sync zamrzl navždy).
|
|
- Watermark neaktualizovat při stránkování starších zpráv (offset > 0) a nikdy ho neposouvat zpět.
|
|
|
|
**Referenční implementace:** `chat_core.py` → `get_messages()` (výpočet watermarku po decryptu), `_decrypt_raw_messages()` (`_decrypt_failed` retry counter, `_MAX_DECRYPT_RETRIES = 3`), `_build_from_cache()` (render placeholderu).
|
|
|
|
### R3. Rotace RSA login klíče — persistovat až po potvrzení serverem
|
|
|
|
**Problém:** Pokud klient zapíše nový RSA privátní klíč na disk (přes starý) před odesláním `rotate_keys` a server požadavek odmítne (rate limit, výpadek), na disku je klíč, který server nezná → účet je trvale nepřihlásitelný.
|
|
|
|
**Požadavek:** Vygenerovat keypair v paměti → poslat `rotate_keys` → **až po `status == "ok"`** přepsat klíč v lokálním úložišti (Keychain/Keystore) a v paměti klienta.
|
|
|
|
**Referenční implementace:** `chat_core.py` → `rotate_keys()`.
|
|
|
|
### R4. Logout musí plně odpojit starou identitu
|
|
|
|
**Problém (3 projevy v Python klientovi, prověřit ekvivalenty):**
|
|
1. Fronta zpráv čekajících na odeslání (retry po reconnectu) přežila logout — zprávy napsané pod účtem A se mohly odeslat po přihlášení účtu B.
|
|
2. Callback pro varování o změně identity klíče (TOFU key change) se po logoutu znovu nezapojil na novou client instanci → MITM varování bylo mrtvé.
|
|
3. UI vrstvy zůstaly napojené na notifikace staré session (duplicitní mark_read, duplicitní lokální notifikace, memory leak).
|
|
|
|
**Požadavek:** Při logoutu: vyprázdnit send/retry queue, znovu zapojit všechny callbacky (key change, progress, …) na novou instanci klienta, odpojit/zrušit všechny observery a timery staré UI session.
|
|
|
|
**Referenční implementace:** `gui_client.py` → `AsyncBridge._do_logout()`, `MainWindow.closeEvent()`, `_connect_signals()`.
|
|
|
|
### R5. Protocol framing při oversized zprávě (pokud klient drainuje stream)
|
|
|
|
**Problém:** Newline-delimited JSON — pokud parser při zprávě překračující limit zahazuje data po chunkách "dokud nenajde `\n`", nesmí zahodit bajty **za** nalezeným `\n` (patří další zprávě). Jinak se rozbije framing celého spojení.
|
|
|
|
**Požadavek (iOS):** Prověřit NWConnection receive buffer logiku — pokud se oversized zpráva zahazuje, bajty za delimiterem uchovat a předřadit dalšímu čtení. (Pozn.: pokud iOS klient oversized zprávy nedrainuje a rovnou zavírá spojení, je to také korektní řešení — jen to zdokumentovat.)
|
|
|
|
**Referenční implementace:** `protocol.py` → `ProtocolReader.read_message()` + `_leftover` buffer.
|
|
|
|
### R6. Zámek aplikace nesmí jít obejít UI akcí bez hesla (prověřit)
|
|
|
|
**Problém (PyQt):** Klávesová zkratka pro toggle privacy režimu fungovala i ve stavu "locked" a odemkla aplikaci bez hesla.
|
|
|
|
**Požadavek (iOS/Android):** Pokud má aplikace lock screen (PIN/biometrie/heslo), ověřit, že žádná akce (gesto, zkratka, deep link, notification action, settings toggle) nedokáže lock obejít bez autentizace.
|
|
|
|
### R7. PoW při registraci (prověřit)
|
|
|
|
Server může na `register` odpovědět `status: "pow_required"` (anti-spam pod zátěží). Klient musí: (a) tuto odpověď zpracovat (vyřešit SHA-256 PoW a opakovat registraci s `pow_challenge`/`pow_mac`/`pow_nonce`), (b) výpočet provádět mimo hlavní/síťové vlákno. Python klient měl v této cestě pád (neexistující proměnná) — cesta zjevně nebyla testovaná. **Otestovat i na mobilních klientech** (server: `ENVIRONMENT=dev` + zátěž, nebo dočasné snížení PoW prahu).
|
|
|
|
---
|
|
|
|
## Testovací scénáře (akceptační)
|
|
|
|
1. **R1:** Navázat DM session, vyměnit pár zpráv, pak znovu doručit (replay) první zprávu s X3DH hlavičkou → existující session musí přežít, další zprávy jdou dešifrovat.
|
|
2. **R2:** Do skupiny poslat zprávu novým členem tak, aby sender key dorazil až PO zprávě (simulace: zahodit control message, doručit později) → zpráva se musí zobrazit po dalším syncu, ne ztratit.
|
|
3. **R3:** Zablokovat `rotate_keys` na serveru (rate limit) → klient se po restartu musí pořád přihlásit starým klíčem.
|
|
4. **R4:** Login účet A → napsat zprávu offline (do fronty) → logout → login účet B → zpráva účtu A se NESMÍ odeslat.
|
|
5. **R5:** Poslat zprávu > `MAX_MESSAGE_BYTES` následovanou validní zprávou v jednom TCP segmentu → spojení musí zůstat funkční a validní zpráva se zpracovat.
|
|
6. **R6:** V locked stavu vyzkoušet všechny zkratky/gesta/akce → nic nesmí odemknout bez hesla.
|