Files
Kecalek_python/SECURITY_AUDIT.md
2026-03-11 16:54:14 +01:00

18 KiB
Raw Blame History

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