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

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:

  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.pyget_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_keysaž 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.pyrotate_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.pyAsyncBridge._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.pyProtocolReader.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.