From 20f006cf5ec083efbfd060261dbde7410c2e5199 Mon Sep 17 00:00:00 2001 From: filip Date: Fri, 12 Jun 2026 16:08:59 +0200 Subject: [PATCH] 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) --- CHANGES_2026-06-12_client_hardening.md | 116 +++++++++++++++++++++++++ CLAUDE.md | 11 +++ 2 files changed, 127 insertions(+) create mode 100644 CHANGES_2026-06-12_client_hardening.md diff --git a/CHANGES_2026-06-12_client_hardening.md b/CHANGES_2026-06-12_client_hardening.md new file mode 100644 index 0000000..69322a7 --- /dev/null +++ b/CHANGES_2026-06-12_client_hardening.md @@ -0,0 +1,116 @@ +# 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. diff --git a/CLAUDE.md b/CLAUDE.md index 1909492..cd23d85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -743,6 +743,17 @@ Four measures to minimize metadata leakage: - **Cache-First Message Loading** — Při přepnutí konverzace se okamžitě zobrazí zprávy z lokálního cache (disk), server fetch běží na pozadí. `chat_core.get_cached_messages(conv_id)` čte z message_cache bez server callu. `_on_conv_selected()` volá `get_cached_messages()` synchronně → zobrazí → poté `bridge.load_messages()` async doplní nové. Fetch deduplication: `_messages_inflight` set v AsyncBridge zabraňuje duplicitním fetchům stejné konverzace. - **Notification Push Logging** — Server loguje `[PUSH] msg=... conv=... targets=[uid(Nw)]` s počtem writerů per příjemce. `_notify_users_individual()` loguje warning při selhání doručení s user_id a chybou. - **Image/File Transfer Performance Overhaul** — Drastické zrychlení downloadu obrázků a souborů: **(A)** Chunk size zvětšen z 32KB na 256KB (8× méně chunků, méně JSON/base64 overhead). **(B)** `MAX_MESSAGE_BYTES` zvětšen z 64KB na 1MB (nutné pro větší chunky). **(C)** Nový `download_stream` handler na serveru — jedna DB autorizace, pak server streamuje všechny chunky bez čekání na per-chunk request (dříve 2 DB queries × N chunků). Klient sbírá stream chunky přes `asyncio.Queue` v `_background_listener`. **(D)** Fallback na legacy `download_image` pro starší servery. **(E)** `image_download_failed` signál v GUI — `_pending_image_download` se vyčistí při selhání (dříve zůstal navždy a blokoval další downloads). **(F)** Sender cache: obrázek se cachuje lokálně po uploadu (`media_cache/{file_id}.bin`), sender vidí obrázek okamžitě bez server round-trip. +- **Client hardening round (2026-06-12)** — 9 oprav z hloubkové revize klienta (detailní požadavky pro server/iOS/Android: `CHANGES_2026-06-12_client_hardening.md`): + - **X3DH session adoption fix (chat_core.py):** `_process_x3dh_header()` už NEinstaluje novou session do `self.sessions` ani ji neukládá na disk — to dělá volající (`_decrypt_dm`) až po prvním úspěšném dešifrování. Dříve replay/forge zprávy s X3DH hlavičkou trvale přepsal funkční Double Ratchet session. + - **Sync watermark fix (chat_core.py):** `__last_server_ts` se posouvá jen přes prefix zpráv, které jsou "settled" v cache (dešifrované / control / deleted / trvale failnuté). Nedešifrovatelná zpráva (např. sender key ještě nedorazil) se znovu stáhne a zkusí při dalším syncu — max `_MAX_DECRYPT_RETRIES=3` pokusů, pak se v cache označí `_decrypt_failed` a watermark pokračuje. Dříve se watermark posunul vždy → tichá trvalá ztráta zprávy. Watermark se navíc neaktualizuje při `offset > 0` (paginace starších zpráv ho regresovala) a nikdy se neposune zpět. + - **PoW registrace fix (chat_core.py):** `register()` PoW větev používala neexistující `logger` (NameError = pád registrace když server vyžadoval proof-of-work). Opraveno na `self._logger` + `_solve_pow` přesunut do `run_in_executor` (neblokuje event loop). + - **rotate_keys fix (chat_core.py):** nový RSA klíč se ukládá na disk až PO `status == "ok"` od serveru. Dřív se `private.pem` přepsal před odesláním — selhání rotace = trvale zamčený účet. + - **GUI statusBar crash fix:** `_on_image_download_failed` volal `self.statusBar()` (neexistuje, MainWindow je QWidget) → AttributeError při selhání downloadu obrázku. Opraveno na `self.status_bar` + `_clear_status_bar`. + - **Privacy lock bypass fix:** Ctrl+Shift+P (`_toggle_privacy`) při `_privacy_locked=True` odemkl zamčenou session bez hesla. Zkratka je teď při zámku ignorována. + - **Registration confirm threading fix:** potvrzení registračního kódu sahalo na Qt widgety z asyncio vlákna. Nový signál `AsyncBridge.confirm_result(bool, str)` — koroutina jen emituje, widgety obsluhuje `on_confirm_result` na Qt vlákně. + - **Logout/login leak fix:** `MainWindow.closeEvent` odpojí všechny bridge signály (evidované v `self._bridge_connections` přes helper `conn()` v `_connect_signals`), odregistruje theme listener (`tm().remove_listener`) a zastaví `_refresh_timer`. Dříve každý logout/login cyklus leakoval celé okno, které dál zpracovávalo notifikace (duplicitní mark_read, tray toasty). Součást: `bridge.logout()` znovu zapojí `client._key_change_cb` (jinak po logoutu mrtvé MITM varování) a vyčistí `_pending_send_queue` (zprávy z fronty se nesmí odeslat pod jinou identitou). + - **CLI react crash fix (client.py):** `await prompt(...).strip()` — precedence (`.strip()` na koroutině) → AttributeError při reakci na zprávu. Opraveno na `(await prompt(...)).lower()`. + - **Protocol framing fix (protocol.py, sdílené server+klient):** `ProtocolReader` drain oversized zprávy zahazoval celý chunk včetně bajtů ZA newline (= začátek další zprávy) → rozbití framingu spojení. Nový `_leftover` buffer: bajty za delimiterem se uchovají a `read_message()` je obslouží před čtením ze streamu. Ověřeno testem (oversized + 2 pipelined zprávy v jednom chunku). ### 🐛 Známé bugy a problémy - **Sender Key Redistribution (High Priority):** New group member can't decrypt old messages. On `add_member`, existing members should re-create and redistribute sender keys.