Files
Kecalek_python/CHANGES_2026-06-12_client_hardening.md
filip 20f006cf5e Document client hardening round: AS-IS in CLAUDE.md + change requests for server and iOS/Android
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>
2026-06-12 16:08:59 +02:00

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.