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>
9.7 KiB
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:
git pull origin master- Restart serveru (
python server.py, resp. rebuild Docker image —Dockerfilese nemění). - Žá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):
- 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.
- 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é.
- 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í)
- 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.
- 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.
- R3: Zablokovat
rotate_keysna serveru (rate limit) → klient se po restartu musí pořád přihlásit starým klíčem. - R4: Login účet A → napsat zprávu offline (do fronty) → logout → login účet B → zpráva účtu A se NESMÍ odeslat.
- R5: Poslat zprávu >
MAX_MESSAGE_BYTESnásledovanou validní zprávou v jednom TCP segmentu → spojení musí zůstat funkční a validní zpráva se zpracovat. - R6: V locked stavu vyzkoušet všechny zkratky/gesta/akce → nic nesmí odemknout bez hesla.