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

364 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).