Compare commits

...

4 Commits

Author SHA1 Message Date
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
filip
4d15799b5e GUI/CLI fixes: crash paths, privacy-lock bypass, threading, logout leaks
- _on_image_download_failed called self.statusBar() which does not
  exist on a QWidget -> AttributeError crash; use status_bar label.
- Ctrl+Shift+P no longer disables the privacy overlay while the session
  is password-locked (lock bypass).
- Registration code confirmation no longer touches Qt widgets from the
  asyncio thread; new AsyncBridge.confirm_result signal carries the
  result back to the Qt thread.
- MainWindow.closeEvent now disconnects all bridge signal connections
  (tracked in _bridge_connections), removes the theme listener and
  stops the periodic refresh timer — every logout/login cycle leaked a
  window that kept handling notifications (duplicate mark_read, tray
  toasts).
- AsyncBridge logout rewires _key_change_cb onto the fresh ChatClient
  (key-change MITM warning was dead after logout) and clears
  _pending_send_queue so queued messages cannot be sent under a
  different identity.
- CLI: fix await precedence crash in the react-to-message prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:08:31 +02:00
filip
d499fd8436 Client core hardening: X3DH session adoption, sync watermark, PoW, key rotation
- Adopt a new X3DH session (install into self.sessions + persist) only
  after the first message decrypts successfully. Previously
  _process_x3dh_header saved the candidate session immediately, so a
  replayed/forged X3DH header permanently overwrote a working ratchet.
- Advance the incremental-sync watermark (__last_server_ts) only across
  the prefix of messages settled in the cache. An undecryptable message
  (e.g. sender key not yet received) is re-fetched and retried up to
  _MAX_DECRYPT_RETRIES=3 times instead of being silently lost forever.
  Watermark is no longer touched on offset>0 pages and never regresses.
- Fix NameError in the proof-of-work registration path (logger ->
  self._logger) and run _solve_pow in an executor so it does not block
  the event loop.
- Persist the rotated RSA login key only after the server confirmed
  rotate_keys; writing private.pem first bricked the account when the
  request failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:08:31 +02:00
filip
f0666ea6ac Preserve next-message bytes when draining oversized protocol message
ProtocolReader discarded the entire chunk containing the newline
delimiter while draining an oversized message, including bytes after
the newline that belong to the next pipelined message. This corrupted
framing for the rest of the connection (affects server and client).
Salvaged bytes are now kept in a _leftover buffer that read_message()
consumes before touching the stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:08:12 +02:00
6 changed files with 327 additions and 83 deletions

View File

@@ -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.

View File

@@ -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. - **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. - **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. - **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 ### 🐛 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. - **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.

View File

@@ -751,6 +751,11 @@ def _load_verified_contacts(email: str, local_key: bytes | None = None) -> dict:
return {} return {}
# How many sync cycles a message that fails to decrypt is retried before it
# is recorded as permanently failed and the sync watermark moves past it.
_MAX_DECRYPT_RETRIES = 3
def _solve_pow(challenge: str, difficulty: int) -> str: def _solve_pow(challenge: str, difficulty: int) -> str:
"""Solve a proof-of-work challenge by finding a nonce with enough leading zero bits.""" """Solve a proof-of-work challenge by finding a nonce with enough leading zero bits."""
target_bytes = difficulty // 8 target_bytes = difficulty // 8
@@ -1233,8 +1238,9 @@ class ChatClient:
challenge = start["data"]["challenge"] challenge = start["data"]["challenge"]
mac = start["data"]["mac"] mac = start["data"]["mac"]
difficulty = start["data"]["difficulty"] difficulty = start["data"]["difficulty"]
logger.info("Server requires proof-of-work (difficulty %d), solving...", difficulty) self._logger.info("Server requires proof-of-work (difficulty %d), solving...", difficulty)
nonce = _solve_pow(challenge, difficulty) nonce = await asyncio.get_running_loop().run_in_executor(
None, _solve_pow, challenge, difficulty)
extra_fields = {"pow_challenge": challenge, "pow_mac": mac, "pow_nonce": nonce} extra_fields = {"pow_challenge": challenge, "pow_mac": mac, "pow_nonce": nonce}
start = await self.send_and_recv( start = await self.send_and_recv(
"register", "register",
@@ -1700,12 +1706,14 @@ class ChatClient:
return False, "Not logged in." return False, "Not logged in."
pwd_bytes = password.encode("utf-8") if password else None pwd_bytes = password.encode("utf-8") if password else None
priv, pub = generate_rsa_keypair() priv, pub = generate_rsa_keypair()
save_keys(self.email, priv, pub, password=pwd_bytes)
self.private_key = priv
self.public_key = pub
pub_pem = serialize_public_key(pub).decode("utf-8") pub_pem = serialize_public_key(pub).decode("utf-8")
# Persist the new key only after the server accepted it — overwriting
# private.pem first would brick the account if rotation fails.
resp = await self.send_and_recv("rotate_keys", public_key=pub_pem) resp = await self.send_and_recv("rotate_keys", public_key=pub_pem)
if resp["status"] == "ok": if resp["status"] == "ok":
save_keys(self.email, priv, pub, password=pwd_bytes)
self.private_key = priv
self.public_key = pub
return True, "RSA login keys rotated." return True, "RSA login keys rotated."
return False, resp["data"]["message"] return False, resp["data"]["message"]
@@ -1872,10 +1880,10 @@ class ChatClient:
ratchet._pending_opk_delete = opk_id if opk_priv else None ratchet._pending_opk_delete = opk_id if opk_priv else None
session_key = f"{sender_id}:{sender_device_id}" if sender_device_id else sender_id # NOTE: the ratchet is intentionally NOT installed into self.sessions
self.sessions[session_key] = ratchet # nor saved to disk here. The caller does that only after the first
_save_session(self.email, sender_id, ratchet, self._local_key, # message decrypts successfully — otherwise a failed/forged X3DH
peer_device_id=sender_device_id) # header would overwrite a working session.
self._user_cache[sender_id] = { self._user_cache[sender_id] = {
"user_id": sender_id, "user_id": sender_id,
@@ -2463,6 +2471,9 @@ class ChatClient:
_save_session(self.email, sender_id, ratchet, self._local_key, _save_session(self.email, sender_id, ratchet, self._local_key,
peer_device_id=sender_device_id) peer_device_id=sender_device_id)
except Exception: except Exception:
# Restore the known-good session before attempting a
# fresh X3DH; if the X3DH path fails too, this restored
# session stays installed (in memory and on disk).
restored = DoubleRatchet.import_state(backup) restored = DoubleRatchet.import_state(backup)
self.sessions[session_key] = restored self.sessions[session_key] = restored
_save_session(self.email, sender_id, restored, self._local_key, _save_session(self.email, sender_id, restored, self._local_key,
@@ -2480,6 +2491,8 @@ class ChatClient:
plaintext = ratchet.decrypt(ratchet_header, ciphertext, nonce) plaintext = ratchet.decrypt(ratchet_header, ciphertext, nonce)
else: else:
raise raise
# First decrypt succeeded — only now adopt the new session
self.sessions[session_key] = ratchet
_save_session(self.email, sender_id, ratchet, self._local_key, _save_session(self.email, sender_id, ratchet, self._local_key,
peer_device_id=sender_device_id) peer_device_id=sender_device_id)
else: else:
@@ -2496,6 +2509,8 @@ class ChatClient:
plaintext = ratchet.decrypt(ratchet_header, ciphertext, nonce) plaintext = ratchet.decrypt(ratchet_header, ciphertext, nonce)
else: else:
raise raise
# First decrypt succeeded — install + persist the session
self.sessions[session_key] = ratchet
_save_session(self.email, sender_id, ratchet, self._local_key, _save_session(self.email, sender_id, ratchet, self._local_key,
peer_device_id=sender_device_id) peer_device_id=sender_device_id)
else: else:
@@ -2613,18 +2628,31 @@ class ChatClient:
raw_messages = resp["data"]["messages"] raw_messages = resp["data"]["messages"]
raw_messages.reverse() # Server returns DESC, reverse to ASC raw_messages.reverse() # Server returns DESC, reverse to ASC
# Save latest server timestamp for next incremental sync # Decrypt new messages from server
if raw_messages: new_decrypted = self._decrypt_raw_messages(raw_messages, cache, conv_id, my_user_id)
# raw_messages are now ASC; last one is newest
newest_ts = raw_messages[-1].get("created_at", "") # Advance the incremental-sync watermark only across the prefix of
if newest_ts: # messages that are settled in the cache (decrypted, control, deleted,
# or failed too many times). Stopping at the first unsettled message
# means a transiently undecryptable message (e.g. sender key not yet
# received) is re-fetched and retried on the next sync instead of
# being skipped forever.
if raw_messages and offset == 0:
newest_ts = ""
for m in raw_messages:
entry = cache.get(m["message_id"])
if entry is None:
break
fails = entry.get("_decrypt_failed", 0)
if fails and fails < _MAX_DECRYPT_RETRIES:
break
newest_ts = m.get("created_at", "") or newest_ts
prev_ts = cache.get("__last_server_ts", {}).get("ts", "")
if newest_ts and newest_ts > prev_ts:
cache["__last_server_ts"] = {"ts": newest_ts} cache["__last_server_ts"] = {"ts": newest_ts}
_save_message_to_cache(self.email, conv_id, "__last_server_ts", _save_message_to_cache(self.email, conv_id, "__last_server_ts",
{"ts": newest_ts}, cache_key=self._cache_key) {"ts": newest_ts}, cache_key=self._cache_key)
# Decrypt new messages from server
new_decrypted = self._decrypt_raw_messages(raw_messages, cache, conv_id, my_user_id)
# All non-critical ops fire-and-forget to avoid blocking message display # All non-critical ops fire-and-forget to avoid blocking message display
# Confirm delivery for messages from others # Confirm delivery for messages from others
deliver_ids = [m["message_id"] for m in new_decrypted deliver_ids = [m["message_id"] for m in new_decrypted
@@ -2674,6 +2702,16 @@ class ChatClient:
for msg_id, p in cache.items(): for msg_id, p in cache.items():
if p.get("_control") or msg_id.startswith("__"): if p.get("_control") or msg_id.startswith("__"):
continue continue
if p.get("_decrypt_failed"):
messages.append({
"message_id": msg_id,
"sender": "???",
"text": "[Decryption failed]",
"created_at": p.get("created_at", ""),
"read_by": [],
"delivered_to": [],
})
continue
entry = dict(p) entry = dict(p)
entry.setdefault("message_id", msg_id) entry.setdefault("message_id", msg_id)
entry.setdefault("read_by", []) entry.setdefault("read_by", [])
@@ -2704,6 +2742,18 @@ class ChatClient:
# Check local cache first (ratchet keys are one-time use) # Check local cache first (ratchet keys are one-time use)
cached = cache.get(msg_id) cached = cache.get(msg_id)
if cached and cached.get("_decrypt_failed"):
if cached["_decrypt_failed"] >= _MAX_DECRYPT_RETRIES:
decrypted.append({
"message_id": msg_id,
"sender": "???",
"text": "[Decryption failed]",
"created_at": m["created_at"],
"read_by": [],
"sender_id": m.get("sender_id", ""),
})
continue
cached = None # retry decryption below
if cached and not cached.get("_control"): if cached and not cached.get("_control"):
cached["read_by"] = m.get("read_by", []) cached["read_by"] = m.get("read_by", [])
cached["delivered_to"] = m.get("delivered_to", []) cached["delivered_to"] = m.get("delivered_to", [])
@@ -2761,12 +2811,20 @@ class ChatClient:
"delivered_to", "sender_id", "deleted")}, "delivered_to", "sender_id", "deleted")},
}) })
except Exception as e: except Exception as e:
# Record the failure (with retry count) so the sync watermark
# stops here and the message is retried on the next fetch.
fails = (cache.get(msg_id) or {}).get("_decrypt_failed", 0) + 1
fail_entry = {"_decrypt_failed": fails, "created_at": m["created_at"]}
cache[msg_id] = fail_entry
_save_message_to_cache(self.email, conv_id, msg_id, fail_entry,
cache_key=self._cache_key)
decrypted.append({ decrypted.append({
"message_id": msg_id, "message_id": msg_id,
"sender": "???", "sender": "???",
"text": f"[Decryption failed: {e}]", "text": f"[Decryption failed: {e}]",
"created_at": m["created_at"], "created_at": m["created_at"],
"read_by": [], "read_by": [],
"sender_id": m.get("sender_id", ""),
}) })
return decrypted return decrypted

View File

@@ -348,7 +348,7 @@ async def interactive_menu(client: ChatClient):
print("[!] Invalid number.") print("[!] Invalid number.")
continue continue
print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown") print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown")
reaction = await prompt("Reaction: ").strip().lower() reaction = (await prompt("Reaction: ")).lower()
if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"): if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"):
print("[!] Invalid reaction.") print("[!] Invalid reaction.")
continue continue

View File

@@ -598,6 +598,7 @@ class AsyncBridge(QThread):
connection_error = pyqtSignal(str) connection_error = pyqtSignal(str)
login_result = pyqtSignal(bool, str) login_result = pyqtSignal(bool, str)
register_result = pyqtSignal(bool, str) register_result = pyqtSignal(bool, str)
confirm_result = pyqtSignal(bool, str) # registration code confirmation
conversations_loaded = pyqtSignal(list) conversations_loaded = pyqtSignal(list)
messages_loaded = pyqtSignal(str, list) # conv_id, messages messages_loaded = pyqtSignal(str, list) # conv_id, messages
older_messages_loaded = pyqtSignal(str, list) # conv_id, older messages older_messages_loaded = pyqtSignal(str, list) # conv_id, older messages
@@ -849,6 +850,11 @@ class AsyncBridge(QThread):
pass pass
self.client = ChatClient() self.client = ChatClient()
self.client._reencrypt_progress_cb = self._emit_reencrypt_status self.client._reencrypt_progress_cb = self._emit_reencrypt_status
self.client._key_change_cb = self._emit_key_change_warning
# Drop messages queued under the previous identity — they must not be
# re-sent from a different account after re-login.
self._pending_send_queue.clear()
self.message_queued.emit(0)
try: try:
await self.client.connect() await self.client.connect()
self.client._listener_task = asyncio.create_task(self.client._background_listener()) self.client._listener_task = asyncio.create_task(self.client._background_listener())
@@ -2738,6 +2744,10 @@ class MainWindow(QWidget):
def _toggle_privacy(self): def _toggle_privacy(self):
"""Toggle privacy overlay on/off (Ctrl+Shift+P).""" """Toggle privacy overlay on/off (Ctrl+Shift+P)."""
if self._privacy_locked:
# Session is password-locked — the shortcut must not bypass the
# lock; unlocking requires the password (_on_unlock_attempt).
return
self._privacy_enabled = not self._privacy_enabled self._privacy_enabled = not self._privacy_enabled
if not self._privacy_enabled: if not self._privacy_enabled:
self._privacy_locked = False self._privacy_locked = False
@@ -3514,56 +3524,65 @@ class MainWindow(QWidget):
main_layout.addLayout(wrapper) main_layout.addLayout(wrapper)
def _connect_signals(self): def _connect_signals(self):
self.bridge.conversations_loaded.connect(self._on_conversations_loaded) # Connections to the (shared, long-lived) bridge are recorded so
self.bridge.messages_loaded.connect(self._on_messages_loaded) # closeEvent can disconnect them — otherwise a closed window keeps
self.bridge.older_messages_loaded.connect(self._on_older_messages_loaded) # processing every signal and leaks after each logout/login cycle.
self.bridge.message_sent.connect(self._on_message_sent) self._bridge_connections = []
self.bridge.message_sent_payload.connect(self._on_message_sent_payload)
self.bridge.new_notification.connect(self._on_notification) def conn(signal, slot):
self.bridge.add_member_result.connect(self._on_add_member_result) signal.connect(slot)
self.bridge.authorize_result.connect(self._on_authorize_result) self._bridge_connections.append((signal, slot))
self.bridge.rotate_result.connect(self._on_rotate_result)
self.bridge.password_changed.connect(self._on_password_changed) conn(self.bridge.conversations_loaded, self._on_conversations_loaded)
self.bridge.username_changed.connect(self._on_username_changed) conn(self.bridge.messages_loaded, self._on_messages_loaded)
self.bridge.reencrypt_status.connect(self._on_reencrypt_status) conn(self.bridge.older_messages_loaded, self._on_older_messages_loaded)
self.bridge.messages_read_notification.connect(self._on_messages_read) conn(self.bridge.message_sent, self._on_message_sent)
self.bridge.message_delivered_notification.connect(self._on_message_delivered) conn(self.bridge.message_sent_payload, self._on_message_sent_payload)
self.bridge.typing_start_notification.connect(self._on_typing_start) conn(self.bridge.new_notification, self._on_notification)
self.bridge.typing_stop_notification.connect(self._on_typing_stop) conn(self.bridge.add_member_result, self._on_add_member_result)
self.bridge.remove_member_result.connect(self._on_remove_member_result) conn(self.bridge.authorize_result, self._on_authorize_result)
self.bridge.message_deleted_notification.connect(self._on_message_deleted) conn(self.bridge.rotate_result, self._on_rotate_result)
self.bridge.delete_message_result.connect(self._on_delete_message_result) conn(self.bridge.password_changed, self._on_password_changed)
self.bridge.image_sent.connect(self._on_image_sent) conn(self.bridge.username_changed, self._on_username_changed)
self.bridge.image_downloaded.connect(self._on_image_downloaded) conn(self.bridge.reencrypt_status, self._on_reencrypt_status)
self.bridge.image_download_failed.connect(self._on_image_download_failed) conn(self.bridge.messages_read_notification, self._on_messages_read)
self.bridge.file_sent.connect(self._on_file_sent) conn(self.bridge.message_delivered_notification, self._on_message_delivered)
self.bridge.file_downloaded.connect(self._on_file_downloaded) conn(self.bridge.typing_start_notification, self._on_typing_start)
self.bridge.conversation_updated.connect(self._on_conversation_updated) conn(self.bridge.typing_stop_notification, self._on_typing_stop)
self.bridge.connection_state_changed.connect(self._on_connection_state_changed) conn(self.bridge.remove_member_result, self._on_remove_member_result)
self.bridge.group_left.connect(self._on_group_left) conn(self.bridge.message_deleted_notification, self._on_message_deleted)
self.bridge.group_renamed.connect(self._on_group_renamed) conn(self.bridge.delete_message_result, self._on_delete_message_result)
self.bridge.conversation_deleted.connect(self._on_conversation_deleted) conn(self.bridge.image_sent, self._on_image_sent)
self.bridge.avatar_loaded.connect(self._on_avatar_for_conv_list) conn(self.bridge.image_downloaded, self._on_image_downloaded)
self.bridge.avatar_fetch_failed.connect(self._on_avatar_fetch_failed) conn(self.bridge.image_download_failed, self._on_image_download_failed)
self.bridge._avatar_changed_signal.connect(self._on_avatar_changed_push) conn(self.bridge.file_sent, self._on_file_sent)
self.bridge.invitations_loaded.connect(self._on_invitations_loaded) conn(self.bridge.file_downloaded, self._on_file_downloaded)
self.bridge.invitation_result.connect(self._on_invitation_result) conn(self.bridge.conversation_updated, self._on_conversation_updated)
self.bridge.invitation_received.connect(self._on_invitation_received) conn(self.bridge.connection_state_changed, self._on_connection_state_changed)
self.bridge.device_added_notification.connect(self._on_device_added) conn(self.bridge.group_left, self._on_group_left)
self.bridge.online_status_changed.connect(self._on_online_status_changed) conn(self.bridge.group_renamed, self._on_group_renamed)
self.bridge.online_users_loaded.connect(self._on_online_users_loaded) conn(self.bridge.conversation_deleted, self._on_conversation_deleted)
self.bridge.group_avatar_loaded.connect(self._on_group_avatar_for_conv_list) conn(self.bridge.avatar_loaded, self._on_avatar_for_conv_list)
self.bridge.group_avatar_fetch_failed.connect( conn(self.bridge.avatar_fetch_failed, self._on_avatar_fetch_failed)
lambda cid: self._group_avatar_requested.discard(cid)) conn(self.bridge._avatar_changed_signal, self._on_avatar_changed_push)
self.bridge.group_avatar_updated.connect(self._on_group_avatar_updated) conn(self.bridge.invitations_loaded, self._on_invitations_loaded)
self.bridge.session_reset_notification.connect(self._on_session_reset) conn(self.bridge.invitation_result, self._on_invitation_result)
self.bridge.reaction_notification.connect(self._on_reaction_notification) conn(self.bridge.invitation_received, self._on_invitation_received)
self.bridge.pin_notification.connect(self._on_pin_notification) conn(self.bridge.device_added_notification, self._on_device_added)
self.bridge.unpin_notification.connect(self._on_unpin_notification) conn(self.bridge.online_status_changed, self._on_online_status_changed)
self.bridge.pinned_messages_loaded.connect(self._on_pinned_messages_loaded) conn(self.bridge.online_users_loaded, self._on_online_users_loaded)
self.bridge.forward_result.connect(self._on_forward_result) conn(self.bridge.group_avatar_loaded, self._on_group_avatar_for_conv_list)
self.bridge.key_change_warning.connect(self._on_key_change_warning) conn(self.bridge.group_avatar_fetch_failed,
self.bridge.message_queued.connect(self._on_message_queued) lambda cid: self._group_avatar_requested.discard(cid))
conn(self.bridge.group_avatar_updated, self._on_group_avatar_updated)
conn(self.bridge.session_reset_notification, self._on_session_reset)
conn(self.bridge.reaction_notification, self._on_reaction_notification)
conn(self.bridge.pin_notification, self._on_pin_notification)
conn(self.bridge.unpin_notification, self._on_unpin_notification)
conn(self.bridge.pinned_messages_loaded, self._on_pinned_messages_loaded)
conn(self.bridge.forward_result, self._on_forward_result)
conn(self.bridge.key_change_warning, self._on_key_change_warning)
conn(self.bridge.message_queued, self._on_message_queued)
self._show_verification_dialog_signal.connect(self._show_verification_dialog) self._show_verification_dialog_signal.connect(self._show_verification_dialog)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -6724,7 +6743,9 @@ class MainWindow(QWidget):
def _on_image_download_failed(self, file_id): def _on_image_download_failed(self, file_id):
if self._pending_image_download and self._pending_image_download["file_id"] == file_id: if self._pending_image_download and self._pending_image_download["file_id"] == file_id:
self._pending_image_download = None self._pending_image_download = None
self.statusBar().showMessage("Image download failed.", 5000) self.status_bar.setText("Image download failed.")
self._status_bar_conv_id = None
QTimer.singleShot(5000, self._clear_status_bar)
def _show_image_dialog(self, image_data, image_info): def _show_image_dialog(self, image_data, image_info):
dlg = QDialog(self) dlg = QDialog(self)
@@ -6872,6 +6893,18 @@ class MainWindow(QWidget):
def closeEvent(self, event): def closeEvent(self, event):
if self._tray_icon: if self._tray_icon:
self._tray_icon.hide() self._tray_icon.hide()
# Detach from shared, long-lived objects (bridge, ThemeManager) so a
# closed window stops handling events and can be garbage-collected —
# otherwise every logout/login leaks a window that keeps reacting to
# notifications (duplicate mark_read, tray toasts, periodic refresh).
self._refresh_timer.stop()
tm().remove_listener(self._apply_theme)
for signal, slot in getattr(self, "_bridge_connections", []):
try:
signal.disconnect(slot)
except Exception:
pass
self._bridge_connections = []
if not self._is_logout: if not self._is_logout:
self.bridge.stop() self.bridge.stop()
self.bridge.wait(2000) self.bridge.wait(2000)
@@ -6920,20 +6953,15 @@ def main():
login_win.show_verification_page(hint) login_win.show_verification_page(hint)
def do_confirm(code): def do_confirm(code):
# Read widget values on the Qt thread; the coroutine below runs
# on the AsyncBridge thread and must not touch Qt widgets.
email = login_win._reg_email_input.text().strip()
username = login_win.username_input.text().strip()
async def _confirm(): async def _confirm():
okc, msgc = await bridge.client.confirm_registration( okc, msgc = await bridge.client.confirm_registration(
login_win._reg_email_input.text().strip(), email, username, code.strip())
login_win.username_input.text().strip(), bridge.confirm_result.emit(okc, msgc)
code.strip(),
)
if okc:
login_win.show_success(msgc)
bridge.do_login(
login_win._reg_email_input.text().strip(),
login_win._reg_password_input.text(),
)
else:
login_win.show_error(msgc)
bridge.schedule(_confirm()) bridge.schedule(_confirm())
login_win._confirm_callback = do_confirm login_win._confirm_callback = do_confirm
@@ -6970,9 +6998,20 @@ def main():
else: else:
login_win.show_error(msg) login_win.show_error(msg)
def on_confirm_result(okc, msgc):
if okc:
login_win.show_success(msgc)
bridge.do_login(
login_win._reg_email_input.text().strip(),
login_win._reg_password_input.text(),
)
else:
login_win.show_error(msgc)
bridge.connected.connect(on_connected) bridge.connected.connect(on_connected)
bridge.connection_error.connect(on_conn_error) bridge.connection_error.connect(on_conn_error)
bridge.register_result.connect(on_register_result) bridge.register_result.connect(on_register_result)
bridge.confirm_result.connect(on_confirm_result)
bridge.login_result.connect(on_login_result) bridge.login_result.connect(on_login_result)
bridge.pairing_code.connect(on_pairing_code) bridge.pairing_code.connect(on_pairing_code)
bridge.pairing_complete.connect(on_pairing_complete) bridge.pairing_complete.connect(on_pairing_complete)

View File

@@ -86,9 +86,21 @@ class ProtocolReader:
def __init__(self, reader: asyncio.StreamReader): def __init__(self, reader: asyncio.StreamReader):
self._reader = reader self._reader = reader
self._leftover = b"" # bytes after the delimiter salvaged from a drain
async def read_message(self) -> dict | None: async def read_message(self) -> dict | None:
"""Read and parse one message. Returns None on EOF.""" """Read and parse one message. Returns None on EOF."""
prefix = b""
if self._leftover:
nl = self._leftover.find(b"\n")
if nl != -1:
line = self._leftover[:nl + 1]
self._leftover = self._leftover[nl + 1:]
if len(line) > MAX_MESSAGE_BYTES:
raise ValueError("Message exceeds maximum size")
return parse_message(line.strip())
prefix = self._leftover
self._leftover = b""
try: try:
line = await self._reader.readuntil(b"\n") line = await self._reader.readuntil(b"\n")
except (asyncio.IncompleteReadError, ConnectionError): except (asyncio.IncompleteReadError, ConnectionError):
@@ -102,11 +114,19 @@ class ProtocolReader:
chunk = await self._reader.read(max(remaining, 4096)) chunk = await self._reader.read(max(remaining, 4096))
if not chunk: if not chunk:
return None # EOF while draining return None # EOF while draining
if b"\n" in chunk: nl = chunk.find(b"\n")
break # found delimiter, oversized message fully drained if nl != -1:
# Bytes after the delimiter belong to the NEXT message —
# discarding them would corrupt framing for the rest of
# the connection.
self._leftover = chunk[nl + 1:]
break
raise ValueError("Message exceeds maximum size") raise ValueError("Message exceeds maximum size")
if not line: if not line:
return None return None
line = prefix + line
if len(line) > MAX_MESSAGE_BYTES:
raise ValueError("Message exceeds maximum size")
return parse_message(line.strip()) return parse_message(line.strip())