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