# Security Audit (Encrypted Chat) Aktualizace: 2026-03-08 Scope: `server.py`, `db.py`, `chat_core.py`, `gui_client.py`, `client.py`, `protocol.py`, `schema.sql`, `.env`, markdown dokumentace. Metodika: statický audit kódu + konfigurace. Nebyl proveden aktivní penetrační test ani fuzzing. ## Executive Summary Nejzávažnější aktuálně otevřené nálezy: - Plaintext DB heslo v `.env` souborech (C3). - Chybějící TLS mezi aplikací a MySQL (H1). - Slabá oprávnění upload/avatary souborů na disku (H2). - DoS přes neomezené `pending_registrations` (H6). ## CRITICAL ### ~~C1. TOFU / verifikace identity klíče se obchází při běžném X3DH flow~~ ✅ OPRAVENO (2026-03-08) **Evidence** - TOFU kontrola existuje jen v `_get_user_info()` (`chat_core.py:799-803`). - Při navazování session (`_get_or_create_session`) se `identity_key` z bundle bere přímo bez TOFU kontroly (`chat_core.py:1497-1501`, `chat_core.py:1534-1538`). - U příchozího X3DH (`_process_x3dh_header`) se remote IK také uloží bez TOFU kontroly (`chat_core.py:1551-1553`, `chat_core.py:1580-1584`). **Dopad** - Pokud server nebo MITM podstrčí jiný identity key, klient může navázat session bez varování. - Prakticky to obchází uživatelskou verifikaci kontaktu ve výchozím messaging flow. **Oprava** - Nová výjimka `IdentityKeyChanged(user_id, new_key_bytes, status)` v `chat_core.py` — hard-fail při změně identity klíče. - `_get_or_create_session()`: TOFU check přes `check_identity_key()` před X3DH initiate. Při `changed`/`changed_verified` vyhodí `IdentityKeyChanged` — session se nenaváže. - `_process_x3dh_header()`: TOFU check před X3DH respond. Stejný hard-fail — příchozí zpráva s podvrženým klíčem je odmítnuta. - GUI: `IdentityKeyChanged` zachycena v notification loopu (emituje `key_change_warning` signál místo pádu loopu) a v `_do_send_message` (zobrazí error + warning dialog). - Session je blokována dokud uživatel explicitně neakceptuje key change přes `accept_key_change()`. - `decrypt_notification()`: explicitní `except IdentityKeyChanged: raise` před generickým `except Exception` — výjimka se propaguje do notification loopu místo tichého spolknutí. - `key_change_warning` signál rozšířen o 5. parametr `new_key_bytes: bytes` — "Accept New Key" dialog předává nový klíč přímo z výjimky, ne z cache (která mohla obsahovat starý klíč). - `IdentityKeyChanged` ošetřena ve všech GUI send cestách: `_do_send_image`, `_do_send_file`, `_do_forward_message`, `_do_find_or_create_and_send` — zobrazí warning dialog + error message. - CLI (`client.py`): `IdentityKeyChanged` ošetřena ve všech 6 send cestách (send_message ×3, send_image, send_file, forward_message). --- ### ~~C2. Perzistentní DoS konverzace přes nevalidní message headers~~ ✅ OPRAVENO (2026-03-08) **Evidence** - Server přijímá `ratchet_header` / `x3dh_header` i jako raw `str/bytes` bez JSON schema validace (`server.py:1105-1112`, `server.py:1146-1151`). - Při `get_messages` se hodnoty bez ochrany parsují `json.loads(...)` (`server.py:1266`, `server.py:1274`). **Dopad** - Útočník v konverzaci může uložit “poisoned” hlavičku a rozbít načtení historie ostatním členům (`Internal server error`). - Chyba je perzistentní, dokud je vadná zpráva v historii. **Oprava** - Nový helper `_validate_header(raw, name)` v `server.py` — přijímá pouze `dict`, odmítá `str`/`bytes`, limit 4096 bajtů. - `handle_send_message`: message-level i per-recipient headers procházejí `_validate_header()`. Nevalidní hlavička → error response, zpráva se neuloží. - `handle_get_messages`: `json.loads()` obaleno `try/except` (JSONDecodeError, TypeError, UnicodeDecodeError). Corrupted header → prázdný dict `{}` + warning log, ostatní zprávy se načtou normálně. - `_validate_header()` rozšířena o validaci očekávaných klíčů a typů pro ratchet headers (`dh_pub`: str, `n`: int, `pn`: int) a používá striktní kontrolu typu pro `n/pn` (`type(...) is int`) — `bool` je explicitně odmítnut. - Realtime push notifikace nyní čtou data z validovaných `db_recipients` (ne z `recipients_raw`). Per-recipient hlavičky se dekódují z validovaných bytes zpět do `dict` pro JSON notifikaci. - `encrypted_content` a `nonce` v push notifikacích se skládají z validovaných raw bytes a serializují se přes `encode_binary()` — untrusted hodnoty z raw requestu se do push větve nepropíší. --- ### C3. Plaintext tajemství v `.env` a `zaloha/.env` **Evidence** - `.env` obsahuje `MYSQL_PASSWORD` (`.env:4`). - `zaloha/.env` obsahuje `MYSQL_PASSWORD` (`zaloha/.env:4`). **Dopad** - Únik souboru = okamžitý přístup do DB. - Riziko přes backupy, sdílení projektu, malware, CI artefakty. **Doporučení** 1. Okamžitě rotovat DB heslo. 2. Nahradit repozitářové `.env` šablonou (`.env.example`) bez tajemství. 3. Použít secrets manager / deployment-level secret injection. ## HIGH ### H1. Chybí TLS mezi aplikací a MySQL **Evidence** - `MySQLConnectionPool` je bez `ssl_ca`/`ssl_verify_cert` parametrů (`db.py:35-44`). - Konfigurace používá síťovou DB (`MYSQL_HOST=192.168.1.112`, `.env:1`). **Dopad** - Odposlech nebo MITM na trase app<->DB může odhalit credentials i data. **Doporučení** 1. Zapnout MySQL TLS na serveru. 2. Vynutit TLS verifikaci certifikátu v `db.py`. --- ### H2. Upload/avatary na disku mají slabá oprávnění **Evidence** - Upload soubory jsou vytvářeny bez explicitního `chmod` na file (`server.py:1732`, `server.py:1806`, `server.py:1909`, `server.py:1969`). - V prostředí auditu: `uploads` a `uploads/avatars` mají `775`, soubory typicky `664`. **Dopad** - Lokální uživatelé na stejném hostu mohou číst citlivá data (včetně avatarů v plaintextu). **Doporučení** 1. Nastavit adresáře `0700`. 2. Po zápisu každého souboru nastavit `0600`. 3. Upload storage přesunout mimo project tree. --- ### ~~H3. `session_reset` nemá autorizační vazbu na vztah mezi uživateli~~ ✅ OPRAVENO (2026-03-08) **Evidence** - Handler přijme libovolné validní `peer_user_id` a pošle notifikaci (`server.py:2040-2052`). - Neověřuje, že uživatelé sdílí konverzaci nebo existuje session. **Dopad** - Možnost spam/DoS reset notifikací na cílové uživatele. **Oprava** - Nová DB funkce `db.shares_conversation(user_id_a, user_id_b)` — `SELECT 1 ... LIMIT 1` přes `conversation_members` JOIN. - `handle_session_reset`: před push notifikací ověřuje `shares_conversation()`. Pokud uživatelé nesdílí žádnou konverzaci → error response. - Rate limit 5 požadavků/min na `session_reset` per user (`session_reset|{user_id}`) — IP adresa není součást klíče, takže změnou IP nejde limit obejít. - Pokud je předán `peer_device_id`, reset notifikace se doručí pouze cílovému zařízení (filtr přes `writer_device_map`). Bez `peer_device_id` zůstává broadcast na všechna zařízení peera. --- ### ~~H4. User enumeration přes pairing a user-info endpointy~~ ✅ OPRAVENO (2026-03-08) **Evidence** - `pairing_start` vrací explicitně `User not found` (`server.py:763-766`). - `get_user_info` vrací metadata uživatele při lookupu přes email/user_id (`server.py:551-564`). **Dopad** - Snadné mapování existence účtů. **Oprava** - `handle_pairing_start`: vždy vrací `ok` s platně vypadajícím kódem a session se vytvoří vždy (i pro neexistující email), takže `pairing_poll` vrací nerozlišitelné `ready: false`. - Přidán globální cap `PAIRING_MAX_SESSIONS = 100` pro omezení počtu současných pairing sessions (DoS hardening). - `pairing_start` rate limit je per-IP (bez email komponenty), aby nešel obcházet rotací emailů. - `pairing_claim` i `pairing_send`: sjednocená chyba `Invalid or expired code` (žádné rozlišení "neexistuje" vs "patří jinému účtu"). - V pairing flow se síťové I/O (`send_resp`) volá až po uvolnění `_pairing_lock`. - `handle_get_user_info`: přidán parametr `session` (vyžaduje login). Lookupovat lze jen sebe nebo kontakty (ověřeno přes `shares_conversation()`). Pro neexistující i nepovolené cíle vrací neutrální "User not found". - Doplňuje dřívější anti-enumeration opravy: `register_start` (generická odpověď), `login_start` (fake challenge), `login_finish` (generická chyba). --- ### ~~H5. Phantom user inflation přes `create_conversation` / `find_conversation` / `add_member` (DoS)~~ ✅ OPRAVENO (2026-03-08) **Evidence** - `create_conversation` vytváří phantom účty pro neznámé emaily bez dedikovaného rate limitu (`server.py:906-920`). - `find_conversation` a `add_member` rate limitují přes `_rate_limit_key(..., addr, email)`, takže rotace emailů obchází limit (`server.py:972`, `server.py:1001`, `server.py:209-212`). - `create_phantom_user()` pro každý nový email generuje IK+SPK+OTP a zapisuje více řádků do DB (`db.py:1470-1507`). **Dopad** - Útočník může nafukovat DB a CPU náklady (kryptografická generace + zápisy), případně degradovat výkon serveru. **Oprava** 1. `_can_create_phantom(addr, user_id)` helper kontroluje 3 limity před každým `create_phantom_user()`: - Globální cap: `MAX_PHANTOM_USERS = 500` (počet v `phantom_user_ids` setu) - Per-user rate: `phantom_create|{user_id}` — 10/min (email-nezávislé, neobejitelné rotací) - Per-IP rate: `phantom_create_ip|{addr}` — 10/min (email-nezávislé) 2. `create_conversation` nově má per-user rate limit 10/min + phantom check před každým členem. 3. `find_conversation` a `add_member` — existující per-addr+email limit zůstává (brání hammering jednoho emailu), přidán `_can_create_phantom` check před vytvořením phantomu. 4. Stávající `cleanup_stale_phantoms(30)` v periodic cleanup (10 min) zajišťuje garbage collection. --- ### H6. `pending_registrations` nemá hard cap (memory/SMTP abuse) **Evidence** - `pending_registrations` je globální in-memory dict bez horního limitu (`server.py:56`). - `register_start` používá rate limit klíč s emailem (`register_start|addr|email`), rotace emailů limit obchází (`server.py:341`, `server.py:209-212`). - `register_start` ukládá novou pending registraci do dict bez capu (`server.py:373-382`). - Periodický cleanup nevolá `_cleanup_registrations()`; expirace se spouští jen při `register_*` flow (`server.py:2486-2508`, `server.py:276-281`). **Dopad** - Riziko růstu paměti a SMTP abuse (masivní register_start s různými emaily). **Doporučení** 1. Přidat `REGISTER_MAX_PENDING` cap a odmítnout nové requesty po dosažení limitu. 2. Změnit rate limit na per-IP (bez emailu) + případně per-subnet. 3. Přidat `_cleanup_registrations()` i do periodického cleanup tasku. ## MEDIUM ### ~~M1. `mark_read` a `confirm_delivery` neověřují, že `message_ids` patří do dané konverzace~~ ✅ OPRAVENO (2026-03-08) **Evidence** - Handler validuje členství jen v `conversation_id` (`server.py:1464-1479`, `server.py:1516-1531`). - DB insert metody pro receipts neváží `message_id` na konverzaci (`db.py:1102-1113`, `db.py:1188-1200`). **Dopad** - Možná manipulace read/delivery stavu cizích zpráv (integrita metadat). **Oprava** - `db.mark_messages_read()` a `db.mark_messages_delivered()` nahrazeny z per-row `INSERT IGNORE` na batch `INSERT IGNORE ... SELECT m.id, %s FROM messages m WHERE m.id IN (...) AND m.conversation_id = %s`. - Message IDs, které nepatří do dané konverzace, jsou tiše přeskočeny (SELECT je nevrátí). --- ### M2. SMTP STARTTLS bez explicitního TLS contextu **Evidence** - `server.starttls()` je voláno bez `ssl.create_default_context()` (`server.py:290`). **Dopad** - Slabší kontrola TLS parametrů/verifikace dle runtime prostředí. **Doporučení** 1. Použít `server.starttls(context=ssl.create_default_context())`. 2. Přidat `EHLO` před/po STARTTLS. --- ### ~~M3. CLI klient: několik lokálních hardening mezer~~ ✅ OPRAVENO (2026-03-08) **Evidence** - Heslo se zadává přes `input()` (echo on) (`client.py:730`, `client.py:749`, `client.py:754`). - Zprávy se tisknou bez sanitace escape sekvencí (`client.py:491`). - Default save path při downloadu je převzat z remote `filename` (`client.py:523-530`). **Dopad** - Shoulder-surfing hesla, terminal escape spoofing, riskantní defaultní save path. **Oprava** - Všechny password prompty (register, login, pairing, authorize device, rotate keys) nahrazeny `prompt_password()` wrapping `getpass.getpass()` — heslo se nezobrazuje na terminálu. - `_sanitize_text()` helper stripuje control znaky (`\x00-\x1f` kromě `\t`/`\n`/`\r`) a ANSI escape sekvence. Aplikováno na `sender`, `text`, `filename` při výpisu zpráv v `_print_messages()`. - Follow-up: `_sanitize_text()` nyní bezpečně přijímá i non-string vstupy (`None -> ""`, jinak `str(...)`), čímž se eliminuje `TypeError` při neočekávaném typu z payloadu (`client.py:32-36`). - Follow-up: sanitace rozšířena na zbývající user-controlled CLI výpisy — seznam konverzací (`client.py:63-67`), search výsledky (`client.py:293-300`), seznam pozvánek (`client.py:612-614`), profil (`client.py:637-644`), seznam zařízení (`client.py:709-713`), verify view (`client.py:435`, `client.py:446`) a notifikace včetně reaction hodnoty (`client.py:752-774`). - `_safe_filename()` helper: `os.path.basename()` + odstranění NUL + fallback na `"download"` pro prázdné/tečkové názvy. Aplikováno na default save path při downloadu. --- ### ~~M4. `get_key_bundle` umožňuje OPK depletion (availability)~~ ✅ OPRAVENO (2026-03-08) **Evidence** - `handle_get_key_bundle` nemá rate limit ani authorizační vazbu na vztah mezi uživateli (`server.py:648-660`). - DB vrstva při každém volání spotřebovává one-time prekeys (`get_key_bundles_for_user` — „Consumes one OPK per device atomically”, `db.py:394-450`). - `target_user_id` lze získat přes `find_conversation` lookup (`server.py:966-987`). **Dopad** - Útočník může opakovanými dotazy vyčerpat OPK oběti, zhoršit doručitelnost a vynutit časté doplňování prekeys. **Oprava** 1. Per-caller rate limit: `get_key_bundle|{user_id}` — 10/min (omezuje celkový počet fetchů jednoho uživatele). 2. Per-target rate limit: `get_key_bundle_target|{target_user_id}` — 20/min (omezuje rychlost vyčerpávání OPK konkrétní oběti). Autorizace probíhá před per-target RL (neautorizovaný request nespálí bucket cíle). 3. Autorizace: `shares_conversation()` — caller musí sdílet konverzaci s cílem (self-fetch povolen vždy). 4. Chybová zpráva pro neautorizovaný přístup je neutrální (`”Key bundle not available”`) — shodná s neexistujícím uživatelem. 5. **Doplňující per-user rate limity** na všechny zbývající výpočetně/DB náročné handlery (celkem 29 RL checks): - Crypto+DB: `upload_prekeys` 5/min, `ensure_prekeys` 5/min, `rotate_keys` 3/min, `reencrypt` 10/min - DB-heavy: `get_messages` 30/min, `delete_conv` 5/min, `delete_msg` 20/min, `react` 20/min, `remove_member` 10/min, `rename_conv` 5/min - File I/O: `update_avatar` 5/min (sdílený bucket pro user i group avatar) --- ### ~~M5. `upload_image_start` nemá anti-DoS cap na in-flight uploady~~ ✅ OPRAVENO (2026-03-08) **Evidence** - `upload_image_start` nevynucuje request rate limit ani limit počtu aktivních uploadů na user/IP (`server.py:1786-1823`). - In-memory `pending_uploads` je bez explicitního capu (`server.py:58`, `server.py:1812-1819`). - Cleanup stale uploadů běží periodicky (600s) a DB stale threshold je 3600s (`server.py:2488-2490`, `db.py:1626-1633`). **Dopad** - Útočník může zahájit mnoho uploadů a vytvářet dočasné soubory/záznamy, což zvyšuje memory/disk tlak. **Oprava** 1. Per-user rate limit: `upload_start|{user_id}` — 10/min. 2. Globální cap: `MAX_UPLOADS_GLOBAL = 200` (kontrola `len(pending_uploads)` pod `_uploads_lock`). 3. Per-user cap: `MAX_UPLOADS_PER_USER = 5` (počet záznamů s `uploader_id == user_id`). 4. Stale threshold snížen z 3600s na `UPLOAD_STALE_SECONDS = 600` (10 min). 5. Periodic cleanup interval snížen z 600s na 120s (2 min). ## LOW ### ~~L1. `decode_binary` není strict base64~~ ✅ OPRAVENO (2026-03-08) **Evidence** - `base64.b64decode(data)` bez `validate=True` (`protocol.py:18`). **Dopad** - Méně striktní input parsing (robustnost), ne přímý průnik. **Oprava** - `decode_binary()` nyní volá `base64.b64decode(data, validate=True)` — odmítá neplatné base64 znaky (whitespace, non-alphabet). ## Positive Findings - Dev-only guardy: `TLS_INSECURE` a `TLS_AUTOGEN` jsou blokovány mimo `ENVIRONMENT=dev`. - Server používá UUID validace v řadě handlerů. - Upload/download ověřuje členství v konverzaci. - Klientské private keys/storage používají PBKDF2 + AES-GCM a restriktivní perms (`0700`/`0600`) v key storage. - Přítomný client-side lockout na opakované chybné login pokusy. ## Prioritní plán oprav ### 0-48 hodin 1. Rotace DB hesla + odstranění tajemství z `.env`. 2. ~~Oprava TOFU bypassu v obou X3DH cestách.~~ ✅ DONE 3. ~~Zablokování nevalidních message headers na vstupu.~~ ✅ DONE 4. Přepnutí upload storage perms na `0700/0600`. 5. ~~Omezit phantom creation (rate limit bez emailu + cap).~~ ✅ DONE 6. Zavést cap pro `pending_registrations` a čistit je i v periodickém cleanupu. 7. ~~Přidat cap/rate limit na in-flight uploady.~~ ✅ DONE ### 7 dní 1. Zapnout TLS mezi app a MySQL (mTLS nebo minimálně server cert verify). 2. ~~Opravit autorizaci `session_reset`.~~ ✅ DONE 3. ~~Opravit vazbu `message_ids` na `conversation_id` pro receipts.~~ ✅ DONE 4. Omezit `get_key_bundle` (rate limit + policy sdílené konverzace). ### 30 dní 1. ~~Anti-enumeration sjednotit napříč endpointy.~~ ✅ DONE 2. ~~CLI hardening (`getpass`, output sanitace, filename sanitace).~~ ✅ DONE 3. Doplnit integrační testy pro bezpečnostní regresi (TOFU, poisoned headers, receipt authz, session_reset device targeting, anti-enumeration, DoS caps).