Compare commits
5 Commits
f42ecf5c5b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da7515d1e | ||
|
|
20f006cf5e | ||
|
|
4d15799b5e | ||
|
|
d499fd8436 | ||
|
|
f0666ea6ac |
116
CHANGES_2026-06-12_client_hardening.md
Normal file
116
CHANGES_2026-06-12_client_hardening.md
Normal 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.
|
||||
11
CLAUDE.md
11
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.
|
||||
|
||||
117
chat_core.py
117
chat_core.py
@@ -751,6 +751,11 @@ def _load_verified_contacts(email: str, local_key: bytes | None = None) -> dict:
|
||||
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:
|
||||
"""Solve a proof-of-work challenge by finding a nonce with enough leading zero bits."""
|
||||
target_bytes = difficulty // 8
|
||||
@@ -1233,8 +1238,9 @@ class ChatClient:
|
||||
challenge = start["data"]["challenge"]
|
||||
mac = start["data"]["mac"]
|
||||
difficulty = start["data"]["difficulty"]
|
||||
logger.info("Server requires proof-of-work (difficulty %d), solving...", difficulty)
|
||||
nonce = _solve_pow(challenge, difficulty)
|
||||
self._logger.info("Server requires proof-of-work (difficulty %d), solving...", 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}
|
||||
start = await self.send_and_recv(
|
||||
"register",
|
||||
@@ -1562,6 +1568,23 @@ class ChatClient:
|
||||
self._cache_key = derive_self_encryption_key(ed_priv)
|
||||
self._local_key = derive_local_storage_key(ed_priv)
|
||||
self._load_verification_stores()
|
||||
|
||||
# Import verification state transferred from the authorizing
|
||||
# device (optional — absent when paired from an older client)
|
||||
vc_raw = keys_data.get("verified_contacts")
|
||||
if vc_raw:
|
||||
try:
|
||||
self._verified_contacts = json.loads(vc_raw)
|
||||
_save_verified_contacts(email, self._verified_contacts, self._local_key)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
kik_raw = keys_data.get("known_identity_keys")
|
||||
if kik_raw:
|
||||
try:
|
||||
self._known_identity_keys = json.loads(kik_raw)
|
||||
_save_known_identity_keys(email, self._known_identity_keys, self._local_key)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
self._pairing_temp_private_key = None
|
||||
self._pairing_fingerprint = ""
|
||||
self._pairing_code = ""
|
||||
@@ -1612,6 +1635,14 @@ class ChatClient:
|
||||
"identity_private": serialize_ed25519_private_raw(self.identity_private).hex(),
|
||||
}
|
||||
|
||||
# Carry the TOFU registry + manual verifications so a contact verified on
|
||||
# this device stays verified on the new one (these stores are local and
|
||||
# would otherwise start empty). Receivers ignore unknown fields.
|
||||
if self._verified_contacts:
|
||||
keys_data["verified_contacts"] = json.dumps(self._verified_contacts)
|
||||
if self._known_identity_keys:
|
||||
keys_data["known_identity_keys"] = json.dumps(self._known_identity_keys)
|
||||
|
||||
# Send keys to the new device first. Re-encrypting history can take a
|
||||
# while on large accounts; doing it before pairing_send can make a valid
|
||||
# code expire during authorization.
|
||||
@@ -1700,12 +1731,14 @@ class ChatClient:
|
||||
return False, "Not logged in."
|
||||
pwd_bytes = password.encode("utf-8") if password else None
|
||||
priv, pub = generate_rsa_keypair()
|
||||
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)
|
||||
if resp["status"] == "ok":
|
||||
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")
|
||||
resp = await self.send_and_recv("rotate_keys", public_key=pub_pem)
|
||||
if resp["status"] == "ok":
|
||||
return True, "RSA login keys rotated."
|
||||
return False, resp["data"]["message"]
|
||||
|
||||
@@ -1872,10 +1905,10 @@ class ChatClient:
|
||||
|
||||
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
|
||||
self.sessions[session_key] = ratchet
|
||||
_save_session(self.email, sender_id, ratchet, self._local_key,
|
||||
peer_device_id=sender_device_id)
|
||||
# NOTE: the ratchet is intentionally NOT installed into self.sessions
|
||||
# nor saved to disk here. The caller does that only after the first
|
||||
# message decrypts successfully — otherwise a failed/forged X3DH
|
||||
# header would overwrite a working session.
|
||||
|
||||
self._user_cache[sender_id] = {
|
||||
"user_id": sender_id,
|
||||
@@ -2463,6 +2496,9 @@ class ChatClient:
|
||||
_save_session(self.email, sender_id, ratchet, self._local_key,
|
||||
peer_device_id=sender_device_id)
|
||||
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)
|
||||
self.sessions[session_key] = restored
|
||||
_save_session(self.email, sender_id, restored, self._local_key,
|
||||
@@ -2480,6 +2516,8 @@ class ChatClient:
|
||||
plaintext = ratchet.decrypt(ratchet_header, ciphertext, nonce)
|
||||
else:
|
||||
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,
|
||||
peer_device_id=sender_device_id)
|
||||
else:
|
||||
@@ -2496,6 +2534,8 @@ class ChatClient:
|
||||
plaintext = ratchet.decrypt(ratchet_header, ciphertext, nonce)
|
||||
else:
|
||||
raise
|
||||
# First decrypt succeeded — install + persist the session
|
||||
self.sessions[session_key] = ratchet
|
||||
_save_session(self.email, sender_id, ratchet, self._local_key,
|
||||
peer_device_id=sender_device_id)
|
||||
else:
|
||||
@@ -2613,18 +2653,31 @@ class ChatClient:
|
||||
raw_messages = resp["data"]["messages"]
|
||||
raw_messages.reverse() # Server returns DESC, reverse to ASC
|
||||
|
||||
# Save latest server timestamp for next incremental sync
|
||||
if raw_messages:
|
||||
# raw_messages are now ASC; last one is newest
|
||||
newest_ts = raw_messages[-1].get("created_at", "")
|
||||
if newest_ts:
|
||||
# Decrypt new messages from server
|
||||
new_decrypted = self._decrypt_raw_messages(raw_messages, cache, conv_id, my_user_id)
|
||||
|
||||
# Advance the incremental-sync watermark only across the prefix of
|
||||
# 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}
|
||||
_save_message_to_cache(self.email, conv_id, "__last_server_ts",
|
||||
{"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
|
||||
# Confirm delivery for messages from others
|
||||
deliver_ids = [m["message_id"] for m in new_decrypted
|
||||
@@ -2674,6 +2727,16 @@ class ChatClient:
|
||||
for msg_id, p in cache.items():
|
||||
if p.get("_control") or msg_id.startswith("__"):
|
||||
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.setdefault("message_id", msg_id)
|
||||
entry.setdefault("read_by", [])
|
||||
@@ -2704,6 +2767,18 @@ class ChatClient:
|
||||
|
||||
# Check local cache first (ratchet keys are one-time use)
|
||||
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"):
|
||||
cached["read_by"] = m.get("read_by", [])
|
||||
cached["delivered_to"] = m.get("delivered_to", [])
|
||||
@@ -2761,12 +2836,20 @@ class ChatClient:
|
||||
"delivered_to", "sender_id", "deleted")},
|
||||
})
|
||||
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({
|
||||
"message_id": msg_id,
|
||||
"sender": "???",
|
||||
"text": f"[Decryption failed: {e}]",
|
||||
"created_at": m["created_at"],
|
||||
"read_by": [],
|
||||
"sender_id": m.get("sender_id", ""),
|
||||
})
|
||||
return decrypted
|
||||
|
||||
|
||||
@@ -348,7 +348,7 @@ async def interactive_menu(client: ChatClient):
|
||||
print("[!] Invalid number.")
|
||||
continue
|
||||
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"):
|
||||
print("[!] Invalid reaction.")
|
||||
continue
|
||||
|
||||
163
gui_client.py
163
gui_client.py
@@ -598,6 +598,7 @@ class AsyncBridge(QThread):
|
||||
connection_error = pyqtSignal(str)
|
||||
login_result = pyqtSignal(bool, str)
|
||||
register_result = pyqtSignal(bool, str)
|
||||
confirm_result = pyqtSignal(bool, str) # registration code confirmation
|
||||
conversations_loaded = pyqtSignal(list)
|
||||
messages_loaded = pyqtSignal(str, list) # conv_id, messages
|
||||
older_messages_loaded = pyqtSignal(str, list) # conv_id, older messages
|
||||
@@ -849,6 +850,11 @@ class AsyncBridge(QThread):
|
||||
pass
|
||||
self.client = ChatClient()
|
||||
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:
|
||||
await self.client.connect()
|
||||
self.client._listener_task = asyncio.create_task(self.client._background_listener())
|
||||
@@ -2738,6 +2744,10 @@ class MainWindow(QWidget):
|
||||
|
||||
def _toggle_privacy(self):
|
||||
"""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
|
||||
if not self._privacy_enabled:
|
||||
self._privacy_locked = False
|
||||
@@ -3514,56 +3524,65 @@ class MainWindow(QWidget):
|
||||
main_layout.addLayout(wrapper)
|
||||
|
||||
def _connect_signals(self):
|
||||
self.bridge.conversations_loaded.connect(self._on_conversations_loaded)
|
||||
self.bridge.messages_loaded.connect(self._on_messages_loaded)
|
||||
self.bridge.older_messages_loaded.connect(self._on_older_messages_loaded)
|
||||
self.bridge.message_sent.connect(self._on_message_sent)
|
||||
self.bridge.message_sent_payload.connect(self._on_message_sent_payload)
|
||||
self.bridge.new_notification.connect(self._on_notification)
|
||||
self.bridge.add_member_result.connect(self._on_add_member_result)
|
||||
self.bridge.authorize_result.connect(self._on_authorize_result)
|
||||
self.bridge.rotate_result.connect(self._on_rotate_result)
|
||||
self.bridge.password_changed.connect(self._on_password_changed)
|
||||
self.bridge.username_changed.connect(self._on_username_changed)
|
||||
self.bridge.reencrypt_status.connect(self._on_reencrypt_status)
|
||||
self.bridge.messages_read_notification.connect(self._on_messages_read)
|
||||
self.bridge.message_delivered_notification.connect(self._on_message_delivered)
|
||||
self.bridge.typing_start_notification.connect(self._on_typing_start)
|
||||
self.bridge.typing_stop_notification.connect(self._on_typing_stop)
|
||||
self.bridge.remove_member_result.connect(self._on_remove_member_result)
|
||||
self.bridge.message_deleted_notification.connect(self._on_message_deleted)
|
||||
self.bridge.delete_message_result.connect(self._on_delete_message_result)
|
||||
self.bridge.image_sent.connect(self._on_image_sent)
|
||||
self.bridge.image_downloaded.connect(self._on_image_downloaded)
|
||||
self.bridge.image_download_failed.connect(self._on_image_download_failed)
|
||||
self.bridge.file_sent.connect(self._on_file_sent)
|
||||
self.bridge.file_downloaded.connect(self._on_file_downloaded)
|
||||
self.bridge.conversation_updated.connect(self._on_conversation_updated)
|
||||
self.bridge.connection_state_changed.connect(self._on_connection_state_changed)
|
||||
self.bridge.group_left.connect(self._on_group_left)
|
||||
self.bridge.group_renamed.connect(self._on_group_renamed)
|
||||
self.bridge.conversation_deleted.connect(self._on_conversation_deleted)
|
||||
self.bridge.avatar_loaded.connect(self._on_avatar_for_conv_list)
|
||||
self.bridge.avatar_fetch_failed.connect(self._on_avatar_fetch_failed)
|
||||
self.bridge._avatar_changed_signal.connect(self._on_avatar_changed_push)
|
||||
self.bridge.invitations_loaded.connect(self._on_invitations_loaded)
|
||||
self.bridge.invitation_result.connect(self._on_invitation_result)
|
||||
self.bridge.invitation_received.connect(self._on_invitation_received)
|
||||
self.bridge.device_added_notification.connect(self._on_device_added)
|
||||
self.bridge.online_status_changed.connect(self._on_online_status_changed)
|
||||
self.bridge.online_users_loaded.connect(self._on_online_users_loaded)
|
||||
self.bridge.group_avatar_loaded.connect(self._on_group_avatar_for_conv_list)
|
||||
self.bridge.group_avatar_fetch_failed.connect(
|
||||
# Connections to the (shared, long-lived) bridge are recorded so
|
||||
# closeEvent can disconnect them — otherwise a closed window keeps
|
||||
# processing every signal and leaks after each logout/login cycle.
|
||||
self._bridge_connections = []
|
||||
|
||||
def conn(signal, slot):
|
||||
signal.connect(slot)
|
||||
self._bridge_connections.append((signal, slot))
|
||||
|
||||
conn(self.bridge.conversations_loaded, self._on_conversations_loaded)
|
||||
conn(self.bridge.messages_loaded, self._on_messages_loaded)
|
||||
conn(self.bridge.older_messages_loaded, self._on_older_messages_loaded)
|
||||
conn(self.bridge.message_sent, self._on_message_sent)
|
||||
conn(self.bridge.message_sent_payload, self._on_message_sent_payload)
|
||||
conn(self.bridge.new_notification, self._on_notification)
|
||||
conn(self.bridge.add_member_result, self._on_add_member_result)
|
||||
conn(self.bridge.authorize_result, self._on_authorize_result)
|
||||
conn(self.bridge.rotate_result, self._on_rotate_result)
|
||||
conn(self.bridge.password_changed, self._on_password_changed)
|
||||
conn(self.bridge.username_changed, self._on_username_changed)
|
||||
conn(self.bridge.reencrypt_status, self._on_reencrypt_status)
|
||||
conn(self.bridge.messages_read_notification, self._on_messages_read)
|
||||
conn(self.bridge.message_delivered_notification, self._on_message_delivered)
|
||||
conn(self.bridge.typing_start_notification, self._on_typing_start)
|
||||
conn(self.bridge.typing_stop_notification, self._on_typing_stop)
|
||||
conn(self.bridge.remove_member_result, self._on_remove_member_result)
|
||||
conn(self.bridge.message_deleted_notification, self._on_message_deleted)
|
||||
conn(self.bridge.delete_message_result, self._on_delete_message_result)
|
||||
conn(self.bridge.image_sent, self._on_image_sent)
|
||||
conn(self.bridge.image_downloaded, self._on_image_downloaded)
|
||||
conn(self.bridge.image_download_failed, self._on_image_download_failed)
|
||||
conn(self.bridge.file_sent, self._on_file_sent)
|
||||
conn(self.bridge.file_downloaded, self._on_file_downloaded)
|
||||
conn(self.bridge.conversation_updated, self._on_conversation_updated)
|
||||
conn(self.bridge.connection_state_changed, self._on_connection_state_changed)
|
||||
conn(self.bridge.group_left, self._on_group_left)
|
||||
conn(self.bridge.group_renamed, self._on_group_renamed)
|
||||
conn(self.bridge.conversation_deleted, self._on_conversation_deleted)
|
||||
conn(self.bridge.avatar_loaded, self._on_avatar_for_conv_list)
|
||||
conn(self.bridge.avatar_fetch_failed, self._on_avatar_fetch_failed)
|
||||
conn(self.bridge._avatar_changed_signal, self._on_avatar_changed_push)
|
||||
conn(self.bridge.invitations_loaded, self._on_invitations_loaded)
|
||||
conn(self.bridge.invitation_result, self._on_invitation_result)
|
||||
conn(self.bridge.invitation_received, self._on_invitation_received)
|
||||
conn(self.bridge.device_added_notification, self._on_device_added)
|
||||
conn(self.bridge.online_status_changed, self._on_online_status_changed)
|
||||
conn(self.bridge.online_users_loaded, self._on_online_users_loaded)
|
||||
conn(self.bridge.group_avatar_loaded, self._on_group_avatar_for_conv_list)
|
||||
conn(self.bridge.group_avatar_fetch_failed,
|
||||
lambda cid: self._group_avatar_requested.discard(cid))
|
||||
self.bridge.group_avatar_updated.connect(self._on_group_avatar_updated)
|
||||
self.bridge.session_reset_notification.connect(self._on_session_reset)
|
||||
self.bridge.reaction_notification.connect(self._on_reaction_notification)
|
||||
self.bridge.pin_notification.connect(self._on_pin_notification)
|
||||
self.bridge.unpin_notification.connect(self._on_unpin_notification)
|
||||
self.bridge.pinned_messages_loaded.connect(self._on_pinned_messages_loaded)
|
||||
self.bridge.forward_result.connect(self._on_forward_result)
|
||||
self.bridge.key_change_warning.connect(self._on_key_change_warning)
|
||||
self.bridge.message_queued.connect(self._on_message_queued)
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -6724,7 +6743,9 @@ class MainWindow(QWidget):
|
||||
def _on_image_download_failed(self, file_id):
|
||||
if self._pending_image_download and self._pending_image_download["file_id"] == file_id:
|
||||
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):
|
||||
dlg = QDialog(self)
|
||||
@@ -6872,6 +6893,18 @@ class MainWindow(QWidget):
|
||||
def closeEvent(self, event):
|
||||
if self._tray_icon:
|
||||
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:
|
||||
self.bridge.stop()
|
||||
self.bridge.wait(2000)
|
||||
@@ -6920,20 +6953,15 @@ def main():
|
||||
login_win.show_verification_page(hint)
|
||||
|
||||
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():
|
||||
okc, msgc = await bridge.client.confirm_registration(
|
||||
login_win._reg_email_input.text().strip(),
|
||||
login_win.username_input.text().strip(),
|
||||
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)
|
||||
email, username, code.strip())
|
||||
bridge.confirm_result.emit(okc, msgc)
|
||||
bridge.schedule(_confirm())
|
||||
|
||||
login_win._confirm_callback = do_confirm
|
||||
@@ -6970,9 +6998,20 @@ def main():
|
||||
else:
|
||||
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.connection_error.connect(on_conn_error)
|
||||
bridge.register_result.connect(on_register_result)
|
||||
bridge.confirm_result.connect(on_confirm_result)
|
||||
bridge.login_result.connect(on_login_result)
|
||||
bridge.pairing_code.connect(on_pairing_code)
|
||||
bridge.pairing_complete.connect(on_pairing_complete)
|
||||
|
||||
24
protocol.py
24
protocol.py
@@ -86,9 +86,21 @@ class ProtocolReader:
|
||||
|
||||
def __init__(self, reader: asyncio.StreamReader):
|
||||
self._reader = reader
|
||||
self._leftover = b"" # bytes after the delimiter salvaged from a drain
|
||||
|
||||
async def read_message(self) -> dict | None:
|
||||
"""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:
|
||||
line = await self._reader.readuntil(b"\n")
|
||||
except (asyncio.IncompleteReadError, ConnectionError):
|
||||
@@ -102,11 +114,19 @@ class ProtocolReader:
|
||||
chunk = await self._reader.read(max(remaining, 4096))
|
||||
if not chunk:
|
||||
return None # EOF while draining
|
||||
if b"\n" in chunk:
|
||||
break # found delimiter, oversized message fully drained
|
||||
nl = chunk.find(b"\n")
|
||||
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")
|
||||
if not line:
|
||||
return None
|
||||
line = prefix + line
|
||||
if len(line) > MAX_MESSAGE_BYTES:
|
||||
raise ValueError("Message exceeds maximum size")
|
||||
return parse_message(line.strip())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user