18 KiB
18 KiB
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
.envsouborech (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) seidentity_keyz 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)vchat_core.py— hard-fail při změně identity klíče. _get_or_create_session(): TOFU check přescheck_identity_key()před X3DH initiate. Přichanged/changed_verifiedvyhodí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:
IdentityKeyChangedzachycena v notification loopu (emitujekey_change_warningsigná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: raisepřed generickýmexcept Exception— výjimka se propaguje do notification loopu místo tichého spolknutí.key_change_warningsignál rozšířen o 5. parametrnew_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íč).IdentityKeyChangedoš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):IdentityKeyChangedoš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_headeri jako rawstr/bytesbez JSON schema validace (server.py:1105-1112,server.py:1146-1151). - Při
get_messagesse 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)vserver.py— přijímá pouzedict, 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()obalenotry/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 pron/pn(type(...) is int) —boolje explicitně odmítnut.- Realtime push notifikace nyní čtou data z validovaných
db_recipients(ne zrecipients_raw). Per-recipient hlavičky se dekódují z validovaných bytes zpět dodictpro JSON notifikaci. encrypted_contentanoncev push notifikacích se skládají z validovaných raw bytes a serializují se přesencode_binary()— untrusted hodnoty z raw requestu se do push větve nepropíší.
C3. Plaintext tajemství v .env a zaloha/.env
Evidence
.envobsahujeMYSQL_PASSWORD(.env:4).zaloha/.envobsahujeMYSQL_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í
- Okamžitě rotovat DB heslo.
- Nahradit repozitářové
.envšablonou (.env.example) bez tajemství. - Použít secrets manager / deployment-level secret injection.
HIGH
H1. Chybí TLS mezi aplikací a MySQL
Evidence
MySQLConnectionPoolje bezssl_ca/ssl_verify_certparametrů (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í
- Zapnout MySQL TLS na serveru.
- 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
chmodna file (server.py:1732,server.py:1806,server.py:1909,server.py:1969). - V prostředí auditu:
uploadsauploads/avatarsmají775, soubory typicky664.
Dopad
- Lokální uživatelé na stejném hostu mohou číst citlivá data (včetně avatarů v plaintextu).
Doporučení
- Nastavit adresáře
0700. - Po zápisu každého souboru nastavit
0600. - Upload storage přesunout mimo project tree.
H3. session_reset nemá autorizační vazbu na vztah mezi uživateli ✅ OPRAVENO (2026-03-08)
session_reset nemá autorizační vazbu na vztah mezi uživateliEvidence
- Handler přijme libovolné validní
peer_user_ida 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 1přesconversation_membersJOIN. handle_session_reset: před push notifikací ověřujeshares_conversation(). Pokud uživatelé nesdílí žádnou konverzaci → error response.- Rate limit 5 požadavků/min na
session_resetper 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řeswriter_device_map). Bezpeer_device_idzů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_startvrací explicitněUser not found(server.py:763-766).get_user_infovrací 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íoks platně vypadajícím kódem a session se vytvoří vždy (i pro neexistující email), takžepairing_pollvrací nerozlišitelnéready: false.- Přidán globální cap
PAIRING_MAX_SESSIONS = 100pro omezení počtu současných pairing sessions (DoS hardening). pairing_startrate limit je per-IP (bez email komponenty), aby nešel obcházet rotací emailů.pairing_claimipairing_send: sjednocená chybaInvalid 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 parametrsession(vyžaduje login). Lookupovat lze jen sebe nebo kontakty (ověřeno přesshares_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)
create_conversation / find_conversation / add_member (DoS)Evidence
create_conversationvytváří phantom účty pro neznámé emaily bez dedikovaného rate limitu (server.py:906-920).find_conversationaadd_memberrate 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
_can_create_phantom(addr, user_id)helper kontroluje 3 limity před každýmcreate_phantom_user():- Globální cap:
MAX_PHANTOM_USERS = 500(počet vphantom_user_idssetu) - 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é)
- Globální cap:
create_conversationnově má per-user rate limit 10/min + phantom check před každým členem.find_conversationaadd_member— existující per-addr+email limit zůstává (brání hammering jednoho emailu), přidán_can_create_phantomcheck před vytvořením phantomu.- 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_registrationsje globální in-memory dict bez horního limitu (server.py:56).register_startpoužívá rate limit klíč s emailem (register_start|addr|email), rotace emailů limit obchází (server.py:341,server.py:209-212).register_startukládá novou pending registraci do dict bez capu (server.py:373-382).- Periodický cleanup nevolá
_cleanup_registrations(); expirace se spouští jen přiregister_*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í
- Přidat
REGISTER_MAX_PENDINGcap a odmítnout nové requesty po dosažení limitu. - Změnit rate limit na per-IP (bez emailu) + případně per-subnet.
- 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)
mark_read a confirm_delivery neověřují, že message_ids patří do dané konverzaceEvidence
- Handler validuje členství jen v
conversation_id(server.py:1464-1479,server.py:1516-1531). - DB insert metody pro receipts neváží
message_idna 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()adb.mark_messages_delivered()nahrazeny z per-rowINSERT IGNOREna batchINSERT 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 bezssl.create_default_context()(server.py:290).
Dopad
- Slabší kontrola TLS parametrů/verifikace dle runtime prostředí.
Doporučení
- Použít
server.starttls(context=ssl.create_default_context()). - Přidat
EHLOpř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()wrappinggetpass.getpass()— heslo se nezobrazuje na terminálu. _sanitize_text()helper stripuje control znaky (\x00-\x1fkromě\t/\n/\r) a ANSI escape sekvence. Aplikováno nasender,text,filenamepři výpisu zpráv v_print_messages().- Follow-up:
_sanitize_text()nyní bezpečně přijímá i non-string vstupy (None -> "", jinakstr(...)), čímž se eliminujeTypeErrorpř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)
get_key_bundle umožňuje OPK depletion (availability)Evidence
handle_get_key_bundlenemá 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_idlze získat přesfind_conversationlookup (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
- Per-caller rate limit:
get_key_bundle|{user_id}— 10/min (omezuje celkový počet fetchů jednoho uživatele). - 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). - Autorizace:
shares_conversation()— caller musí sdílet konverzaci s cílem (self-fetch povolen vždy). - Chybová zpráva pro neautorizovaný přístup je neutrální (
”Key bundle not available”) — shodná s neexistujícím uživatelem. - 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_prekeys5/min,ensure_prekeys5/min,rotate_keys3/min,reencrypt10/min - DB-heavy:
get_messages30/min,delete_conv5/min,delete_msg20/min,react20/min,remove_member10/min,rename_conv5/min - File I/O:
update_avatar5/min (sdílený bucket pro user i group avatar)
- Crypto+DB:
M5. upload_image_start nemá anti-DoS cap na in-flight uploady ✅ OPRAVENO (2026-03-08)
upload_image_start nemá anti-DoS cap na in-flight uploadyEvidence
upload_image_startnevynucuje request rate limit ani limit počtu aktivních uploadů na user/IP (server.py:1786-1823).- In-memory
pending_uploadsje 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
- Per-user rate limit:
upload_start|{user_id}— 10/min. - Globální cap:
MAX_UPLOADS_GLOBAL = 200(kontrolalen(pending_uploads)pod_uploads_lock). - Per-user cap:
MAX_UPLOADS_PER_USER = 5(počet záznamů suploader_id == user_id). - Stale threshold snížen z 3600s na
UPLOAD_STALE_SECONDS = 600(10 min). - Periodic cleanup interval snížen z 600s na 120s (2 min).
LOW
L1. decode_binary není strict base64 ✅ OPRAVENO (2026-03-08)
decode_binary není strict base64Evidence
base64.b64decode(data)bezvalidate=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_INSECUREaTLS_AUTOGENjsou blokovány mimoENVIRONMENT=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
- Rotace DB hesla + odstranění tajemství z
.env. Oprava TOFU bypassu v obou X3DH cestách.✅ DONEZablokování nevalidních message headers na vstupu.✅ DONE- Přepnutí upload storage perms na
0700/0600. Omezit phantom creation (rate limit bez emailu + cap).✅ DONE- Zavést cap pro
pending_registrationsa čistit je i v periodickém cleanupu. Přidat cap/rate limit na in-flight uploady.✅ DONE
7 dní
- Zapnout TLS mezi app a MySQL (mTLS nebo minimálně server cert verify).
Opravit autorizaci✅ DONEsession_reset.Opravit vazbu✅ DONEmessage_idsnaconversation_idpro receipts.- Omezit
get_key_bundle(rate limit + policy sdílené konverzace).
30 dní
Anti-enumeration sjednotit napříč endpointy.✅ DONECLI hardening (✅ DONEgetpass, output sanitace, filename sanitace).- Doplnit integrační testy pro bezpečnostní regresi (TOFU, poisoned headers, receipt authz, session_reset device targeting, anti-enumeration, DoS caps).