# Encrypted Chat — Project Context

End-to-end encrypted chat with forward secrecy (X3DH + Double Ratchet, Signal Protocol).
Server stores and relays opaque blobs — never sees plaintext. RSA retained for login only.

## Files

| File | Lines | Purpose |
|------|-------|---------|
| `schema.sql` | ~172 | MySQL schema (users, devices, signed_prekeys, one_time_prekeys, conversations, conversation_members, group_invitations, messages, message_recipients, message_reactions, group_sender_keys, message_reads, image_uploads, user_profiles) |
| `db.py` | ~1500 | MySQL CRUD — one connection per call, `dictionary=True` cursors, returns dicts. Includes profile CRUD, `get_user_contacts()`, `update_conversation_creator()`, `get_conversation()`. Phantom user CRUD + `upgrade_phantom_user()`. Invitation CRUD. Group avatar. Device CRUD. Per-device prekey/session management. Reactions CRUD (`add_reaction`, `remove_reaction`, `get_reactions`). Pins CRUD (`pin_message`, `unpin_message`, `get_pinned_messages`). |
| `server.py` | ~2200 | Asyncio TCP server, handler dispatch, rate limiting, real-time notifications via `connected_clients` dict. Profile + avatar handlers. Online/offline status push. Leave group, delete conversation, group invitations, group avatar handlers. Phantom user support. Graceful shutdown. 4 asyncio.Lock guards (H4 fix). Device registration + per-device key bundles + per-device notifications. SPK age reporting in `get_prekey_count`. Reactions, pins, pinned messages handlers. |
| `protocol.py` | ~114 | Newline-delimited JSON protocol, `ProtocolReader`/`ProtocolWriter`, `encode_binary`/`decode_binary` (base64). Constants: `VERSION`, `MAX_MESSAGE_BYTES`, `MAX_IMAGE_BYTES`, `MAX_FILE_BYTES`, `IMAGE_CHUNK_SIZE`. |
| `crypto_utils.py` | ~950 | Ed25519, X25519, AES-256-GCM, HKDF, PBKDF2, X3DH, `DoubleRatchet` (with state snapshot/rollback), `SenderKeyState` (with state snapshot/rollback). RSA for login only. ECP1 password-based key encryption format (600k PBKDF2 iterations). Contact key verification: `compute_fingerprint()`, `format_fingerprint()`, `compute_safety_number()`, `encode_verification_qr()`, `decode_verification_qr()`. Message padding: `pad_plaintext()`, `unpad_plaintext()`. |
| `chat_core.py` | ~2850 | `ChatClient` class — session management, X3DH/ratchet encryption, local key storage, reconnect, profiles, file sharing, leave group, delete conversation, invitations, group avatar. Multi-device: per-device sessions, device_id persistence, device bundle cache. SPK rotation (7-day) with grace period. Reactions, pins, forwarding methods. Contact key verification: TOFU registry, explicit verification, safety numbers, QR code verify. Used by CLI + GUI |
| `client.py` | ~520 | Interactive CLI client — reactions, pin, forward, pinned messages, verify contact, show fingerprint commands |
| `gui_client.py` | ~3600 | PyQt6 GUI — `AsyncBridge` QThread bridges asyncio <-> Qt signals, `MainWindow`, `UserProfileDialog`, `VerificationDialog`, connection indicator + auto-reconnect, online status, file sharing, leave group, unread badges, circular avatars in conv list, online green dot overlay, group invitations UI, delete conversation, group avatar support, message reactions (emoji badges), forwarding with dialog, pin/unpin with indicator, pinned messages list, @mentions autocomplete, contact verification indicators (conv list green checkmark, E2E label status, key change warning dialog) |
| `ios_client/` | ~6200 | Native iOS client (Swift/SwiftUI) — wire-compatible with Python server. 47 Swift files: CryptoKit crypto (AES-GCM, HKDF, Ed25519, X25519), pure Swift GF(2^255-19) field arithmetic for Ed→X conversion, Security.framework RSA-4096, Network.framework TCP+TLS, Signal Protocol (X3DH + Double Ratchet + Sender Keys), SwiftUI views. Uses `project.yml` (XcodeGen). |

## Architecture & Data Flow

### Encryption: X3DH + Double Ratchet (Signal Protocol)

**Keys per user:**
- **RSA-4096** — Login challenge-response only (server stores public key). Password-encrypted with ECP1 format (PBKDF2 600k iterations + AES-256-GCM).
- **Identity Key (IK)** — Ed25519 (signing) + converted to X25519 (for DH in X3DH). Password-encrypted with ECP1 format.
- **Signed Pre-Key (SPK)** — X25519, signed by IK, uploaded to server. **Rotates every 7 days** (M4). Previous SPK kept for grace period (in-flight X3DH).
- **One-Time Pre-Keys (OPK)** — X25519, consumed on X3DH initiation, auto-replenished when count < 20

**DM flow:**
1. Alice fetches Bob's per-device key bundles (IK, SPK per device, OPK per device) -> X3DH per device -> shared secret per device
2. Double Ratchet initialized from shared secret — one session per (user, device) pair
3. Each message: symmetric ratchet (HMAC chain) -> message key -> AES-256-GCM
4. Each reply direction change: DH ratchet (new X25519 keypair) -> new root + chain keys
5. Per-device ciphertext — each recipient device gets individually encrypted blob
6. Self-encrypted copy uses SELF_DEVICE_ID sentinel, readable by all own devices

**Group flow (Sender Keys):**
1. Each sender has own SenderKeyState per group
2. Sender key distributed to members via pairwise Double Ratchet (as control DM with `_sender_key` field)
3. Group messages: symmetric ratchet on sender key -> AES-256-GCM
4. Same ciphertext replicated to all recipients (efficient)

### Contact Key Verification (Out-of-Band Trust)

**Problem:** Server stores identity keys but could MITM new sessions by substituting keys. Users need a way to verify keys out-of-band.

**Design:** Entirely client-side — zero server changes. Server already stores identity keys in `users.identity_key` (32B Ed25519) and returns them via `get_user_info`/`get_key_bundle`. Verification state is local-only (privacy by design — server never learns who verified whom).

**Trust model:**
```
First contact → TOFU (Trust On First Use) → "trusted" (key recorded)
                                           ↓
                              Out-of-band verify → "verified" (explicit confirmation)
                                           ↓
                              Key change detected → "changed" / "changed_verified" (WARNING)
                                           ↓
                              Accept new key → "trusted" (verification reset)
```

**Verification methods:**
1. **Safety numbers** — 60-digit number (12 groups × 5 digits), deterministic for each pair (lower user_id's fingerprint first). Both users see the same number — compare in person or over trusted channel.
2. **QR codes** — Encode `0x01 + uid_len + uid + identity_key` (70 bytes). One user shows QR, other scans → automatic verification.
3. **Fingerprints** — Per-user 30-digit number (6 groups × 5 digits). Visual comparison.

**Algorithm (Signal NumericFingerprint compatible):**
- Fingerprint: SHA-512 iterated 5200× on `version(2B) + identity_key(32B) + user_id(UTF-8)`, truncated to 32 bytes
- Display: `int(bytes[i*5:(i+1)*5], big-endian) % 100000`, zero-padded to 5 digits

**Local storage** (encrypted with `_local_key`, AES-256-GCM):
- `known_identity_keys.bin` — TOFU registry: `{user_id → {identity_key hex, first_seen, last_seen}}`
- `verified_contacts.bin` — Explicit verification: `{user_id → {identity_key hex, verified_at, method}}`

**Tamper resistance:** Oba soubory jsou šifrovány AES-256-GCM (klíč odvozen z identity key přes HKDF). Na rozdíl od session/sender key souborů (které mají plaintext migration fallback kvůli zpětné kompatibilitě) verifikační soubory **nemají žádný plaintext fallback** — pokud dešifrování selže, vrátí se prázdný dict. Útočník s přístupem k disku (ale bez znalosti hesla/identity key) tedy nemůže:
- Podvrhnout falešný "verified" status (injekce do `verified_contacts.bin`)
- Potlačit TOFU key-change warning (injekce do `known_identity_keys.bin`)
- Nejhorší případ při manipulaci = verifikace se resetuje (prázdný stav), nikdy se nepřijme podvržená hodnota

**Threat model:**
- **DNS únos / podvržený server (existující kontakt):** TOFU detekuje key change → warning dialog. Útočník nemůže tiše podvrhnout klíč.
- **DNS únos / podvržený server (první kontakt):** TOFU věří prvnímu klíči — MITM úspěšný. Obrana: out-of-band verifikace safety number přes jiný kanál (telefon, osobně). Pokud se čísla neshodnou → odhaleno.
- **Kompromitovaný reálný server:** Server podmění klíč v `get_key_bundle` → TOFU detekuje změnu (stejné jako DNS únos). Server neví kdo je verified (stav je lokální) → nemůže cíleně obejít.
- **Disk access bez hesla:** Verifikační soubory šifrovány AES-256-GCM, žádný plaintext fallback → nelze podvrhnout. Viz tamper resistance výše.
- **TLS je první linie obrany:** S platným certifikátem DNS únos nestačí. Bez TLS / s `TLS_INSECURE` je MITM triviální. Verifikace kontaktů je druhá linie — chrání i při kompromitovaném serveru.

**Data flow:**
1. `_get_user_info()` calls `check_identity_key()` → records TOFU or detects change
2. Key change → `_key_change_cb` fires → GUI shows warning dialog
3. User opens VerificationDialog → sees safety number + QR → marks verified
4. `_rebuild_conv_list()` queries `get_verification_status()` → shows green checkmark for verified DMs
5. E2E label in chat header shows "Verified" (green) or "Encrypted" (muted)

**QR code encoding detail:**
- Raw binary payload (`0x01 + uid_len + uid + identity_key`) je před vložením do QR zakódován jako **base64** (ASCII-safe)
- Důvod: QR čtečky (pyzbar/zbar) re-kódují binární data přes UTF-8 → byty > 127 se zkomolí
- Při skenování se base64 dekóduje zpět na raw bytes → `decode_verification_qr()`
- iOS implementace musí použít stejný base64 wrapper (viz iOS spec níže)

**Self-verification exclusion:**
- Vlastní user_id se nikdy nezobrazuje jako "unverified" — TOFU registr neobsahuje vlastní klíč (ten je na disku)
- GUI: security section v UserProfileDialog se nezobrazuje pro vlastní profil, verified badge v group info přeskakuje vlastní uid

### Protocol

Newline-delimited JSON over TCP (optional TLS). Fields: `type`, `status`, `data`, `request_id`.
Binary data encoded as base64 via `encode_binary()`/`decode_binary()`.

**Request/response pattern:** Client sends `{"type": "...", "request_id": "uuid", ...}`, server responds with same `request_id`. Notifications (push) have no `request_id`.

### Server notifications (push to connected clients)
- `new_message` — per-recipient ciphertext included
- `messages_read` — conversation_id + user_id + message_ids
- `message_deleted` — message_id + conversation_id
- `conversation_created` — conversation_id, name, created_by, members[] (pushed to added members)
- `member_added` — conversation_id, user_id, username, email (pushed to all members except requester)
- `member_removed` — conversation_id, user_id (pushed to removed member + remaining members)
- `group_invitation` — conversation_id, conversation_name, invited_by, invited_by_username (pushed to invited user)
- `conversation_renamed` — conversation_id, name, renamed_by (pushed to all members except renamer)
- `session_reset` — from_user_id, from_device_id (pushed to peer when session reset requested)
- `message_reacted` — message_id, conversation_id, user_id, username, reaction, action (pushed to members)
- `message_pinned` — message_id, conversation_id, user_id, username (pushed to members)
- `message_unpinned` — message_id, conversation_id, user_id, username (pushed to members)
- `user_online` — user_id (pushed to contacts when user connects)
- `user_offline` — user_id (pushed to contacts when user's last connection drops)
- `online_users` — user_ids[] (sent to user on login — list of currently online contacts)

## DB Schema (schema.sql)

```
users: id, username, email (UNIQUE), rsa_public_key (TEXT), identity_key (BLOB 32B Ed25519), created_at
devices: id, user_id FK, device_name (nullable), created_at, last_seen_at
signed_prekeys: id, user_id FK, device_id (nullable), public_key (BLOB 32B), signature (BLOB 64B), created_at
one_time_prekeys: id, user_id FK, device_id (nullable), public_key (BLOB 32B)
conversations: id, created_at, name (nullable), created_by (nullable), avatar_file (nullable)
conversation_members: conversation_id + user_id (composite PK), joined_at
group_invitations: id, conversation_id FK, user_id FK, invited_by FK, created_at, UNIQUE(conversation_id, user_id)
messages: id, conversation_id FK, sender_id FK, sender_device_id (nullable), ratchet_header (BLOB JSON),
          x3dh_header (BLOB JSON nullable), sender_chain_id (BLOB nullable), sender_chain_n (INT nullable),
          created_at, deleted_at, image_file_id, pinned_at (nullable), pinned_by (nullable)
message_recipients: message_id + user_id + device_id (composite PK), encrypted_content (BLOB), nonce (BLOB),
                    ratchet_header (BLOB nullable), x3dh_header (BLOB nullable)
message_reactions: id, message_id FK, user_id FK, reaction VARCHAR(32), created_at, UNIQUE(message_id, user_id, reaction)
group_sender_keys: conversation_id + sender_id + device_id (composite PK), chain_id (BLOB 32B), created_at
message_reads: message_id + user_id (composite PK), read_at
image_uploads: file_id (PK), conversation_id FK, uploader_id FK, file_size, completed, created_at
user_profiles: user_id (PK FK), phone, phone_visible, email_visible, location, location_visible, avatar_file, updated_at
```

Constant: `SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000"` — sentinel for self-encrypted copies and legacy rows.

Index: `messages(conversation_id, created_at)` for query performance.

## Server Protocol — All Message Types

### Pre-login (no session required)
| Type | Handler | Purpose |
|------|---------|---------|
| `register` | `handle_register_start` | Start registration (username, email, public_key, identity_key) |
| `register_confirm` | `handle_register_confirm` | Confirm with 6-digit code |
| `login_start` | `handle_login_start` | Get RSA challenge |
| `login_finish` | `handle_login_finish` | Respond with RSA signature -> session. Client sends `client_version`, server returns `server_version` in response. Also sends `online_users` and `user_online` notifications. |
| `get_user_info` | `handle_get_user_info` | Get user info + identity_key (by email or user_id) |
| `pairing_start` | `handle_pairing_start` | New device starts pairing (gets 8-digit code) |
| `pairing_poll` | `handle_pairing_poll` | New device polls for key payload |

### Post-login (session required)
| Type | Handler | Purpose |
|------|---------|---------|
| `upload_prekeys` | `handle_upload_prekeys` | Upload SPK + batch of OPKs (server verifies SPK signature) |
| `get_key_bundle` | `handle_get_key_bundle` | Fetch key bundle for X3DH (consumes one OPK) |
| `get_prekey_count` | `handle_get_prekey_count` | Check remaining OPK count + SPK age (`spk_created_at`) for rotation |
| `ensure_prekeys` | `handle_ensure_prekeys` | Combined get_prekey_count + upload_prekeys in single round-trip. Returns count + spk_created_at + upload status. |
| `create_conversation` | `handle_create_conversation` | Create conversation — DMs auto-add both; groups add creator only + create invitations for others |
| `find_conversation` | `handle_find_conversation` | Find existing DM by email |
| `add_member` | `handle_add_member` | Create invitation for user to join group (was: direct add) |
| `remove_member` | `handle_remove_member` | Remove member (creator only) |
| `leave_group` | `handle_leave_group` | Voluntarily leave a group (transfers creator if needed, blocks DM leave) |
| `rename_conversation` | `handle_rename_conversation` | Rename group conversation (creator only, max 100 chars), pushes `conversation_renamed` to members |
| `delete_conversation` | `handle_delete_conversation` | Delete conversation — DMs: remove self; groups: creator-only, removes all members + files |
| `accept_invitation` | `handle_accept_invitation` | Accept pending group invitation → add to members, notify others |
| `decline_invitation` | `handle_decline_invitation` | Decline pending group invitation |
| `list_invitations` | `handle_list_invitations` | List user's pending invitations (with conv name + inviter username) |
| `list_conversations` | `handle_list_conversations` | List all user's conversations (includes avatar_file) |
| `send_message` | `handle_send_message` | Send encrypted message (ratchet_header + recipients[]) |
| `get_messages` | `handle_get_messages` | Get messages (returns per-user ciphertext, JOINs message_recipients). Supports `after_ts` for incremental sync. ROW_NUMBER dedup when both device-specific and SELF_DEVICE_ID rows exist. |
| `mark_read` | `handle_mark_read` | Mark messages as read |
| `delete_message` | `handle_delete_message` | Soft-delete message (sender only) |
| `rotate_keys` | `handle_rotate_keys` | Rotate RSA login key, disconnect other sessions |
| `pairing_claim` | `handle_pairing_claim` | Authorized device claims pairing code |
| `pairing_send` | `handle_pairing_send` | Authorized device sends encrypted key payload |
| `upload_image_start/chunk/end` | Image/file upload | Chunked encrypted upload (32KB chunks). `file_type` param: `"image"` (5MB limit) or `"file"` (50MB limit). |
| `download_image` | Image/file download | Chunked download with offset |
| `get_profile` | `handle_get_profile` | Get user profile (respects visibility for other users) |
| `update_profile` | `handle_update_profile` | Update own profile (phone, location, visibility toggles) |
| `update_avatar` | `handle_update_avatar` | Upload user avatar (base64, max 2MB, JPEG/PNG) |
| `get_avatar` | `handle_get_avatar` | Download user's avatar |
| `update_group_avatar` | `handle_update_group_avatar` | Upload group avatar (base64, max 2MB, JPEG/PNG, creator only) |
| `get_group_avatar` | `handle_get_group_avatar` | Download group avatar |
| `get_deleted_since` | `handle_get_deleted_since` | Get message IDs deleted since a given timestamp (for incremental sync) |
| `reencrypt_messages` | `handle_reencrypt_messages` | Batch upsert message history with self-key (max 500/request, for device pairing + received msg self-encryption) |
| `list_devices` | `handle_list_devices` | List all devices for current user |
| `remove_device` | `handle_remove_device` | Remove a device (not current device) |
| `session_reset` | `handle_session_reset` | Notify peer to reset corrupted Double Ratchet session (push `session_reset` to peer) |
| `react_message` | `handle_react_message` | Add/remove emoji reaction on a message. Push `message_reacted` to members. |
| `pin_message` | `handle_pin_message` | Pin/unpin a message. Push `message_pinned`/`message_unpinned` to members. |
| `get_pinned_messages` | `handle_get_pinned_messages` | Get list of pinned messages for a conversation. |

## Key Classes & Functions

### crypto_utils.py

**Password-based key encryption (ECP1 format):**
- `PBKDF2_ITERATIONS = 600_000` — OWASP 2023 compliant
- `_encrypt_private_key(raw_bytes, password) -> bytes` — PBKDF2-HMAC-SHA256 + AES-256-GCM. Format: `_ECP1_MAGIC(4) + salt(16) + nonce(12) + ciphertext_with_tag`
- `_decrypt_private_key(data, password) -> bytes` — Detects ECP1 magic prefix, derives key, decrypts

**RSA (login only):** `generate_rsa_keypair()`, `serialize_private_key()` (ECP1 with password, PEM without), `serialize_public_key()`, `load_private_key()` (auto-detects ECP1 vs legacy PEM), `load_public_key()`, `rsa_sign()`, `rsa_verify()`

**AES-256-GCM:** `aes_encrypt(plaintext, key=None) -> (key, nonce, ct, tag)`, `aes_decrypt(key, nonce, ct, tag) -> plaintext`

**Ed25519:** `generate_identity_keypair()`, `serialize_ed25519_private()` (ECP1 with password, 32-byte raw without), `serialize_ed25519_private_raw()`, `serialize_ed25519_public()`, `load_ed25519_private()` (auto-detects ECP1 vs legacy PEM vs raw), `load_ed25519_public()`, `ed25519_sign()`, `ed25519_verify()`

**X25519:** `generate_x25519_keypair()`, `serialize_x25519_private()`, `serialize_x25519_public()`, `load_x25519_private()`, `load_x25519_public()`, `x25519_dh()`

**Key conversion:** `ed25519_private_to_x25519()` (SHA-512 + clamp), `ed25519_public_to_x25519()` (Montgomery u-coordinate)

**HKDF:** `hkdf_derive()`, `kdf_rk(root_key, dh_output) -> (new_root_key, chain_key)`, `kdf_ck(chain_key) -> (new_chain_key, message_key)`

**X3DH:** `generate_signed_prekey(identity_private) -> {private, public, signature, id}`, `generate_one_time_prekeys(count=50) -> [{private, public, id}]`, `x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?) -> (shared_secret, ek_priv, ek_pub)`, `x3dh_respond(ik_private_ed, spk_private, ik_remote_ed, ek_remote, opk_private?) -> shared_secret`

**DoubleRatchet class:**
- `init_alice(shared_secret, bob_spk_pub)` — initiator, performs first DH ratchet
- `init_bob(shared_secret, spk_pair)` — responder, waits for first message
- `encrypt(plaintext) -> {header: {dh_pub, n, pn}, ciphertext, nonce}` — AAD = serialized header
- `decrypt(header_dict, ciphertext, nonce)` — handles DH ratchet step if new dh_pub, skipped messages. **State snapshot/rollback on failure (M9):** `_snapshot()` captures all mutable state before modifications, `_restore()` rolls back on any exception.
- `_snapshot() -> dict` / `_restore(snap)` — Snapshot: dh_pair, dh_remote, root_key, send/recv chain keys, counters, skipped dict. Used internally by `decrypt()`.
- `export_state() -> bytes` / `import_state(data) -> DoubleRatchet` — JSON serialization

**SenderKeyState class:**
- `__init__(sender_key=None)` — generates random 32B key if None
- `encrypt(plaintext) -> {chain_id, n, ciphertext, nonce}` — AAD = chain_id + message number
- `decrypt(chain_id_hex, n, ciphertext, nonce)` — fast-forwards chain if needed. **State snapshot/rollback on failure (M9):** snapshots chain_key, n, _known_keys before fast-forward, restores on exception.
- `export_key() -> bytes` — for distribution to group members
- `from_key(exported_key) -> SenderKeyState` — receiver initializes from exported key
- `export_state() / import_state()` — full state persistence

**Contact Key Verification:**
- `FINGERPRINT_VERSION = 0` — version byte for fingerprint algorithm
- `compute_fingerprint(user_id, identity_key_bytes, iterations=5200) -> bytes` — iterated SHA-512, truncated to 32 bytes. Matches Signal's NumericFingerprint.
- `format_fingerprint(fp_bytes) -> str` — 32 bytes → 6 groups of 5 digits (30 digits), 2 lines
- `compute_safety_number(my_uid, my_ik, their_uid, their_ik) -> str` — 60 digits (12 groups of 5), deterministic ordering (lower uid first), 3 lines of 4 groups
- `encode_verification_qr(user_id, identity_key_bytes) -> bytes` — `0x01 + uid_len(1B) + uid(UTF-8) + ik(32B)`
- `decode_verification_qr(data) -> (user_id, identity_key_bytes)` — inverse of encode

**Message Padding:**
- `_PAD_MAGIC = b"\x01"` — prefix byte distinguishing padded from legacy unpadded messages
- `_PAD_BUCKETS = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]` — target sizes
- `pad_plaintext(plaintext) -> bytes` — Pad to nearest bucket. Format: `0x01 + plaintext + random_padding + pad_length(4B big-endian)`.
- `unpad_plaintext(data) -> bytes` — Remove padding. Legacy unpadded messages (starting with `{`) returned unchanged.

### chat_core.py

**Local key storage** (`~/.encrypted_chat/{email}/`):
```
private.pem / public.pem                              — RSA (login, ECP1 format when password-encrypted)
identity_private.bin / identity_public.bin             — Ed25519 (ECP1 format when password-encrypted, 32B raw otherwise)
device_id.txt                                          — This device's UUID
spk_private.bin / spk_id.txt                           — Current signed prekey (AES-256-GCM via local_key)
prev_spk_private.bin / prev_spk_id.txt                 — Previous SPK for grace period (AES-256-GCM via local_key)
opk_private/{opk_id}.bin                               — One-time prekeys (AES-256-GCM via local_key)
login_lockout.json                                     — Brute-force lockout state (failed_attempts, locked_until)
sessions/{user_id}_{device_id}.bin                     — Double Ratchet states (per peer device)
sender_keys/{conv_id}.bin                              — Own sender key states
sender_keys_recv/{conv_id}_{sender_id}_{device_id}.bin — Received sender keys (per sender device)
known_identity_keys.bin                                — TOFU registry: {user_id -> {identity_key hex, first_seen, last_seen}}
verified_contacts.bin                                  — Explicit verification: {user_id -> {identity_key hex, verified_at, method}}
```

Storage functions: `save_keys()`, `load_keys()`, `_save_identity_keys()`, `_load_identity_keys()`, `_save_spk(local_key=)`, `_load_spk(local_key=)`, `_save_prev_spk(local_key=)`, `_load_prev_spk(local_key=)`, `_save_opk_private(local_key=)`, `_load_opk_private(local_key=)`, `_delete_opk_private()`, `_save_session()`, `_load_session()`, `_save_sender_key_state()`, `_load_sender_key_state()`, `_save_recv_sender_key()`, `_load_recv_sender_key()`, `_save_known_identity_keys()`, `_load_known_identity_keys()`, `_save_verified_contacts()`, `_load_verified_contacts()`

Lockout functions: `_check_lockout(email) -> float`, `_record_failed_attempt(email)`, `_clear_lockout(email)`. Constants: `_LOCKOUT_BASE_SECONDS=2`, `_LOCKOUT_MAX_SECONDS=300`.

**ChatClient attributes:**
- `private_key` / `public_key` — RSA (login)
- `identity_private` / `identity_public` — Ed25519
- `spk_private` / `spk_id` — Current SPK
- `_prev_spk_private` / `_prev_spk_id` — Previous SPK for grace period (M4)
- `opk_privates: dict[str, X25519PrivateKey]` — OPK private keys by ID
- `device_id: str | None` — this device's UUID (persisted to disk)
- `sessions: dict[str, DoubleRatchet]` — "user_id:device_id" -> ratchet (per peer device)
- `sender_key_states: dict[str, SenderKeyState]` — conv_id -> own sender key
- `recv_sender_keys: dict[str, SenderKeyState]` — "conv_id:sender_id:device_id" -> their key
- `_device_bundle_cache: dict[str, tuple[float, list]]` — user_id -> (timestamp, device_bundles) with 5-min TTL
- `_pending_self_encrypt: list[dict]` — queue of received messages to self-encrypt for multi-device access
- `_user_cache: dict[str, dict]` — user_id -> {identity_key, username, email, identity_key_status}
- `connected: bool` — current connection state
- `_known_identity_keys: dict` — TOFU registry (user_id -> {identity_key hex, first_seen, last_seen})
- `_verified_contacts: dict` — explicit verification (user_id -> {identity_key hex, verified_at, method})
- `_key_change_cb: Callable | None` — callback fired on identity key change (GUI wires to warning dialog)

**Key methods:**
- `register()` — Generates RSA + Ed25519, sends to server
- `confirm_registration()` — Confirms code, uploads prekeys (SPK + 50 OPKs)
- `login()` — Loads keys from disk (including prev_spk for grace period), RSA challenge-response, auto `_ensure_prekeys()`
- `_ensure_prekeys()` — Checks OPK count AND SPK age. Replenishes OPKs if < 20, **rotates SPK if >= 7 days old** (M4). Saves old SPK as grace period before generating new one.
- `_get_device_bundles(peer_user_id)` — Fetches per-device key bundles with 5-min TTL cache
- `_get_or_create_session(peer_user_id, peer_device_id, bundle)` — Loads from memory/disk or creates via X3DH, keyed by "user:device"
- `_process_x3dh_header(sender_id, x3dh_header, sender_device_id, spk_override?)` — Bob side of X3DH. `spk_override` param allows using previous SPK for grace period (M4).
- `send_message(conv_id, text, members, reply_to?)` — Routes to `_send_dm` or `_send_group_message`
- `_send_dm()` — Per-device Double Ratchet (encrypts for each of recipient's devices), self-encrypted copy with SELF_DEVICE_ID
- `_send_group_message()` — Sender Keys, distributes key if new (per-device)
- `_distribute_sender_key()` — Sends sender key as control message via per-device pairwise ratchet, includes sender_device_id
- `_decrypt_dm()` — Handles X3DH header for new sessions, returns None for control messages. On X3DH decrypt failure, retries with previous SPK (M4 grace period).
- `_decrypt_group()` — Uses received sender key chain
- `decrypt_notification()` — Returns None for control messages (sender key distribution)
- `get_messages()` — Cache-first with incremental sync (`after_ts`). Decrypts new messages, self-encrypts received messages for multi-device, syncs deletions via `get_deleted_since`. Returns merged cache + new messages.
- `_build_messages_from_cache()` — Builds sorted message list from cache dict
- `_queue_self_encrypt()` / `_flush_self_encrypt()` — Queue and upload self-encrypted copies of received messages
- `authorize_device()` — Exports RSA + Ed25519 only (simplified for multi-device — no session/sender key transfer)
- `pairing_wait()` — Imports RSA + identity key from paired device (new device generates own SPK + OPKs on login)
- `reconnect()` — Closes connection, re-establishes TCP + RSA login using in-memory keys
- `get_profile(user_id?)` — Gets user profile from server
- `update_profile(**fields)` — Updates own profile (phone, location, visibility)
- `update_avatar(image_data)` — Uploads avatar
- `get_avatar(user_id)` — Downloads avatar bytes
- `send_file(conv_id, file_path, members, reply_to?)` — Encrypt + chunked upload + send message with `file` payload
- `download_file(file_id, file_info)` — Chunked download + AES-GCM decrypt
- `leave_group(conv_id)` — Leave group, clean up local sender keys
- `rename_conversation(conv_id, name)` — Rename group (creator only)
- `delete_conversation(conv_id)` — Delete conversation, clean up local sender keys
- `accept_invitation(conv_id)` — Accept group invitation
- `decline_invitation(conv_id)` — Decline group invitation
- `list_invitations()` — Fetch pending invitations
- `update_group_avatar(conv_id, image_data)` — Upload group avatar
- `get_group_avatar(conv_id)` — Download group avatar
- `search_messages(conv_id, query)` — Search decrypted message cache (client-side only)
- `reset_session(peer_user_id, peer_device_id?)` — Delete local session + notify peer via server
- `handle_session_reset_notification(from_user_id, from_device_id?)` — Handle incoming session reset
- `_load_verification_stores()` — Load TOFU + verified contacts from disk (called on login/registration/pairing)
- `check_identity_key(user_id, ik_bytes) -> str` — TOFU check, returns "new"/"trusted"/"verified"/"changed"/"changed_verified"
- `verify_contact(user_id, ik_bytes, method)` — Mark contact as explicitly verified
- `unverify_contact(user_id)` — Remove explicit verification
- `accept_key_change(user_id, new_ik_bytes)` — Accept changed key, remove old verification
- `get_verification_status(user_id) -> str` — Returns "verified"/"trusted"/"unverified"
- `get_safety_number(peer_user_id) -> str` — Formatted 60-digit safety number
- `get_my_fingerprint() -> str` — Formatted 30-digit own fingerprint
- `get_peer_fingerprint(peer_user_id) -> str` — Formatted 30-digit peer fingerprint
- `get_verification_qr_data() -> bytes` — QR code payload for own identity
- `verify_qr_code(qr_data) -> (ok, user_id, message)` — Decode + verify scanned QR code

### gui_client.py

**AsyncBridge (QThread):** Runs asyncio event loop, `schedule(coro)` queues coroutines, pyqtSignals emit results back to Qt main thread.

**Key signals:** `login_result`, `conversations_loaded`, `messages_loaded`, `message_sent`, `new_notification`, `messages_read_notification`, `message_deleted_notification`, `conversation_updated`, `connection_state_changed`, `profile_loaded`, `profile_updated`, `avatar_loaded`, `online_status_changed`, `online_users_loaded`, `file_sent`, `file_downloaded`, `group_left`, `conversation_deleted`, `invitations_loaded`, `invitation_result`, `invitation_received`, `group_avatar_loaded`, `group_avatar_updated`, `session_reset_notification`, `key_change_warning`

**MainWindow:** Dark theme (Catppuccin Mocha), conversation list with circular avatars + online green dot overlay + unread count badges + verification checkmark, message bubbles with colored left border, context menu (reply, delete, view image, download file), image thumbnails via QTextDocument resources (`thumb://{file_id}`), file cards with download links (`file://{file_id}`), connection indicator dot (green/red/orange), profile button, attach menu (Image/File), Leave Group button in group info, delete conversation button (trash icon in header), group avatar display + change in group info dialog, invitation list (amber border) above conversation list with right-click accept/decline. E2E label clickable → opens VerificationDialog. Key change warning dialog on identity key change.

**UserProfileDialog:** View (read-only) and edit (own profile) modes. Fields: avatar (circular crop), username, email, phone, location, visibility toggles. Avatar upload/download. Security section (viewing others): verification status + fingerprint. Opened from "My Profile" button or user info button in group info dialog.

**VerificationDialog:** Frameless dialog for contact verification. Shows: peer name, verification status, safety number (monospace), QR code image, both fingerprints. Buttons: "Mark as Verified" / "Remove Verification" / "Scan QR Code" (via pyzbar). QR generation via `qrcode` library.

**Avatar system in conversation list:**
- `_avatar_cache: dict[str, QPixmap]` — user avatars by user_id
- `_group_avatar_cache: dict[str, QPixmap]` — group avatars by conv_id
- `_avatar_requested: set[str]` / `_group_avatar_requested: set[str]` — dedup download requests
- `_make_circular_avatar(pixmap, size=32)` — QPainter circular crop
- `_make_default_avatar(username, size=32)` — colored circle with initial letter (deterministic color from username hash)
- `_add_online_dot(avatar)` — green dot overlay bottom-right
- `_get_conv_avatar(conv)` — returns QIcon (DM: user avatar + online dot; group: group avatar or default)
- Periodic refresh every 2 minutes via `_refresh_timer` / `_on_periodic_refresh()`

## Important Implementation Details

### X3DH Header Caching
When `_get_or_create_session()` creates a new session via X3DH, it attaches the X3DH header as `ratchet._x3dh_header`. The next `_send_dm()` reads and deletes it. This ensures the X3DH header is only sent with the first message.

### Self-Encryption for DMs
Sender uses `derive_self_encryption_key(identity_private)` to encrypt their own copy of sent messages with a static AES key. Uses `SELF_DEVICE_ID` sentinel so all own devices can read it. This allows reading own sent messages when fetching history from any device. **Received messages** are also self-encrypted after decryption (via `_pending_self_encrypt` queue + `_flush_self_encrypt()`), creating SELF_DEVICE_ID copies so other devices of the same user can read them. `batch_reencrypt_messages()` uses INSERT ON DUPLICATE KEY UPDATE (upsert) to handle both cases.

### Sender Key Distribution as Control Messages
Sender keys are distributed via normal `send_message` protocol (per-device pairwise ratchet). The payload contains `_sender_key: {conv_id, key, sender_device_id}` field. On decryption, `_decrypt_dm()` detects this field, stores the sender key keyed by `"conv_id:sender_id:sender_device_id"`, and returns `None` (not shown to user).

### Group Messages: Dummy Ratchet Header
Group messages use `{"dh_pub": "00"*32, "n": 0, "pn": 0}` as ratchet_header because the server requires it, but groups use sender keys instead of Double Ratchet.

### Multi-Device Architecture
Each device has independent Double Ratchet sessions. Sessions are keyed by `"peer_user_id:peer_device_id"`. When sending a DM, the client fetches per-device key bundles via `_get_device_bundles()` and encrypts separately for each device. The server registers devices at login (`handle_login_finish`), assigns device IDs, and routes notifications with `device_entries` arrays (one entry per recipient device). Device IDs are persisted to `~/.encrypted_chat/{email}/device_id.txt`. Old session files (`{user_id}.bin`) are automatically migrated to `{user_id}_{device_id}.bin` on first load.

### Server Session Model
`connected_clients: dict[str, list[ProtocolWriter]]` — one user can have multiple connections (multi-device). `writer_device_map: dict[int, str]` maps `id(writer)` to `device_id`. Notifications are pushed to all connections except the sender's current one.

### Device Registration
On `login_finish`, server checks for `device_id` in the request. If present and valid (belongs to user), reuses it. Otherwise creates a new device. Device ID returned in response and stored on client disk. `list_devices` and `remove_device` handlers for device management.

### Simplified Pairing (Multi-Device)
`authorize_device()` only exports RSA + identity key (no sessions/sender keys). New device generates its own SPK + OPKs on first login, creates independent sessions via X3DH. Old messages readable via self-encryption (shared identity key). `reencrypt_history()` still runs to ensure all messages have self-encrypted copies.

### Real-time Conversation Notifications
`handle_create_conversation`, `handle_add_member`, `handle_remove_member`, `handle_leave_group`, `handle_delete_conversation`, `handle_accept_invitation` push notifications to affected members via `connected_clients`. Types: `conversation_created`, `member_added`, `member_removed`, `group_invitation`. GUI handles these via `conversation_updated` signal -> refreshes conversation list.

### Connection State + Auto-Reconnect
`ChatClient.connected` flag tracks TCP connection state. `_background_listener` sets `connected = False` when server closes connection and **fails all pending futures** with `ConnectionError` (prevents `send_and_recv` from hanging forever). `send_and_recv` has a 30s timeout via `asyncio.wait_for` and catches `ConnectionError`/`TimeoutError`. `reconnect()` re-establishes TCP + RSA challenge-response using in-memory keys (no password needed, includes `device_id`). GUI `_notification_loop` detects listener death -> triggers `_auto_reconnect` with exponential backoff (1s->2s->4s->...->30s). Connection indicator dot: green (connected), red (disconnected), orange (reconnecting).

### Server Per-Message Error Handling
Server dispatch loop wraps each handler call in individual try/except. Handler crashes return "Internal server error" response instead of killing the entire connection. Errors logged with `exc_info=True` for full tracebacks. GUI `_do_send_message`/`_do_find_or_create_and_send` catch exceptions and emit error signal (prevents silent hang when send fails).

### Online/Offline Status
- `db.get_user_contacts(user_id)` returns all user IDs sharing at least one conversation
- On login (`handle_login_finish`): server sends `online_users` list to new user + `user_online` to all contacts (only if user was fully offline before)
- On disconnect (`handle_client` finally block): if last connection drops, server sends `user_offline` to all contacts
- `_background_listener` routes `user_online`, `user_offline`, `online_users` to notification queue
- GUI: `_online_users: set[str]` tracks online users, green dot overlay on circular avatar in DM conversation list + green circle emoji in group info member list

### Leave Group
- `handle_leave_group` in server.py: validates membership, blocks DM leave (len<=2 and no name), transfers creator to first remaining member if creator leaves, removes member, notifies remaining via `member_removed`
- `ChatClient.leave_group()`: sends request, cleans up local sender key states on success
- GUI: red "Leave Group" button in group info dialog, confirmation dialog, resets view on success

### Delete Conversation
- **DMs:** Any member can delete. Only removes the deleting user from `conversation_members`. If both users delete, 0 members remain → conversation + files cleaned up.
- **Groups:** Only the creator (admin) can delete. Removes ALL members, cleans up `.enc` files from disk, deletes conversation via CASCADE.
- Server notifies remaining members via `member_removed` push.
- GUI: trash icon button in conversation header. Visible for DMs always, for groups only when user is creator.
- `chat_core.py`: cleans up local sender key states after successful delete.

### Group Invitations
- **Flow:** `create_conversation` (group) or `add_member` → creates invitation → pushes `group_invitation` notification → invitee sees in invitation list → Accept (adds to members, notifies) / Decline (deletes invitation)
- **DMs are unaffected:** `create_conversation` for DMs still auto-adds both members
- **DB:** `group_invitations` table with UNIQUE(conversation_id, user_id) to prevent duplicates
- **Server:** `handle_accept_invitation` verifies invitation exists, adds member, deletes invitation, notifies existing members via `member_added`. `handle_decline_invitation` just deletes.
- **GUI:** `inv_list` QListWidget (max 120px, amber border) above `conv_list`. Right-click → Accept/Decline. `invitation_received` signal triggers refresh + notification banner.
- **Routing fix (IMPORTANT):** `group_invitation` must be in the notification types list in `chat_core.py:_background_listener` (~line 304). Without it, invitations get routed to `_response_queue` and the GUI never sees them.

### Group Avatars
- Stored as files in `UPLOAD_DIR/avatars/group_{conv_id}.{ext}` (PNG or JPEG detected from magic bytes)
- `conversations.avatar_file` column stores the filename
- `list_conversations` response includes `avatar_file` so GUI knows which groups have avatars
- GUI: `_group_avatar_cache` dict, `_get_conv_avatar()` returns group avatar icon or default letter circle
- Group Info dialog shows 64px circular avatar + "Change Avatar" button (creator only)
- Periodic refresh every 2 minutes re-downloads all known group avatars

### File Sharing
- Reuses image upload/download infrastructure (`upload_image_start/chunk/end`, `download_image`)
- `upload_image_start` accepts optional `file_type` param: `"image"` (MAX_IMAGE_BYTES=5MB) or `"file"` (MAX_FILE_BYTES=50MB)
- `ChatClient.send_file()`: reads raw file, AES-256-GCM encrypts, chunked upload, sends message with `file` field in payload (`{file_id, aes_key, iv, filename, size, mime_type}`)
- `ChatClient.download_file()`: identical to `download_image()` — chunked download + AES-GCM decrypt
- GUI: attach button is dropdown menu (Image / File), file messages render as styled cards with paperclip icon (transparent background, border) and clickable download link (`file://{file_id}`), context menu "Download file" option
- Files stored as `.enc` in UPLOAD_DIR, same as images

### Unread Count Badges
- `_unread_counts: dict[str, int]` replaces old `_unread_convs: set`
- `_on_notification()` increments count per conversation
- `_on_conv_selected()` clears count for selected conversation
- Display: `(3) Username` with bold font, instead of old `● Username`

### User Profiles
`user_profiles` table separated from `users` (clean separation, users = auth only). Default profile created on registration (`db.create_default_profile`). Visibility rules applied server-side in `db.get_user_profile(viewer_id)`. Avatars stored as files in `UPLOAD_DIR/avatars/{user_id}.{ext}` (not in DB). Format detection from magic bytes (PNG header vs default JPEG). UserProfileDialog shows circular cropped avatar (QPainter).

### Prekey Replenishment + SPK Rotation
After login, `_ensure_prekeys()` is called as a background task. Checks two things:
1. **OPK count** — if < 20, generates and uploads a new batch of 50
2. **SPK age** — server returns `spk_created_at` in `get_prekey_count` response. If SPK is >= 7 days old (`SPK_ROTATION_DAYS`), triggers rotation: saves current SPK as `prev_spk_private.bin`/`prev_spk_id.txt` (grace period), generates new SPK, uploads to server.

### Password-Based Key Encryption (ECP1 Format) — M3
Private keys (RSA, Ed25519) are encrypted with a custom envelope instead of `BestAvailableEncryption`:
- **Key derivation:** PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP 2023 compliant)
- **Encryption:** AES-256-GCM with the derived key, magic bytes as AAD
- **Format:** `_ECP1_MAGIC("ECP1", 4B) + salt(16B) + nonce(12B) + ciphertext_with_tag(N+16B)`
- **Backward compatibility:** `load_private_key()` and `load_ed25519_private()` detect ECP1 magic prefix. If absent, fall back to legacy PEM parsing (old `BestAvailableEncryption` format). On next save, files are re-encrypted in ECP1 format.
- **Functions:** `_encrypt_private_key()`, `_decrypt_private_key()` in `crypto_utils.py`
- **Applied to:** `serialize_private_key()` (RSA), `serialize_ed25519_private()` (Ed25519)

### SPK Rotation (7-Day Cycle) — M4
Signed Pre-Keys rotate periodically to limit exposure from a compromised SPK:
- **Rotation interval:** `SPK_ROTATION_DAYS = 7` (constant in `chat_core.py`)
- **Trigger:** `_ensure_prekeys()` checks `spk_created_at` from `get_prekey_count` response. If age >= 7 days, calls `_generate_and_upload_prekeys()`.
- **Grace period:** Before generating a new SPK, the current one is saved as `prev_spk_private.bin` / `prev_spk_id.txt`. Loaded on login into `_prev_spk_private` / `_prev_spk_id`.
- **Fallback on decrypt:** When `_decrypt_dm()` processes an X3DH header and decryption fails with the current SPK, it retries with the previous SPK via `_process_x3dh_header(..., spk_override=self._prev_spk_private)`. This handles in-flight X3DH initiated before rotation.
- **Server side:** `get_signed_prekey()` in `db.py` returns `created_at` column. `handle_get_prekey_count` includes `spk_created_at` (ISO format) in response.
- **Server SPK replacement:** `store_signed_prekey()` deletes old SPK and inserts new one — only one active SPK per device on server.

### Ratchet State Rollback on Decrypt Failure — M9
Both `DoubleRatchet.decrypt()` and `SenderKeyState.decrypt()` modify internal state (chain keys, counters, DH keys) before attempting AES-GCM decryption. If decryption fails (corrupted data, wrong key, AAD mismatch), the state would be permanently corrupted.

**DoubleRatchet fix:**
- `_snapshot()` captures all mutable fields: `dh_pair`, `dh_remote`, `root_key`, `send_chain_key`, `recv_chain_key`, `send_n`, `recv_n`, `prev_send_n`, `skipped` dict (shallow copy)
- `decrypt()` takes snapshot before any state modification, wraps the entire DH ratchet + chain advance + AES-GCM decrypt in try/except, calls `_restore()` on failure
- Special case: skipped message decryption (no state modification needed) — if AES-GCM fails, the popped key is restored to `skipped` dict

**SenderKeyState fix:**
- Before fast-forwarding the chain, snapshots `chain_key`, `n`, `_known_keys` (shallow copy)
- On any exception during fast-forward or AES-GCM decrypt, all three are restored

### Message Reactions
- `ALLOWED_REACTIONS = {"thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"}` in `db.py`
- `message_reactions` table with UNIQUE(message_id, user_id, reaction) — one reaction type per user per message
- `handle_react_message`: validates UUID, reaction, membership. Adds/removes reaction. Pushes `message_reacted` to all members.
- `get_messages` response includes `reactions: [{user_id, reaction, created_at}]` per message (batch query via `db.get_reactions()`)
- GUI: React submenu in context menu with toggle (add if not present, remove if already reacted). Badges below message text showing emoji + count, highlighted border if own reaction. Real-time update via `_on_reaction_notification`.
- Forwarding uses `forwarded_from` metadata in plaintext payload — no new server protocol needed, just client convention.

### Pinned Messages
- `messages.pinned_at` (DATETIME) and `messages.pinned_by` (CHAR(36)) columns
- `handle_pin_message`: validates UUID, membership, pins/unpins. Pushes `message_pinned`/`message_unpinned`.
- `handle_get_pinned_messages`: returns pinned message metadata for a conversation
- `get_messages` response includes `pinned_at` and `pinned_by` per message
- GUI: Pin/Unpin in context menu, pin emoji in message header, "Pinned" button in chat header opens dialog with scrollable list (double-click scrolls to message in chat)

### @Mentions
- Client-side only — no server-side handling or special notifications
- GUI `MentionCompleter`: popup `QListWidget` shown when `@` detected at cursor, filters conversation members by prefix, inserts `@username ` on selection
- Rendering: `re.sub(r'@(\w+)', ...)` highlights mentions in blue bold (`#89b4fa`)
- Triggered from `_on_input_changed()` -> `_check_mention_trigger()`

### Contact Key Verification (Safety Numbers / Fingerprints / QR Codes)
- **Zero server changes** — entirely client-side. Server already stores identity keys in `users.identity_key`.
- **TOFU (Trust On First Use):** `known_identity_keys.bin` stores first-seen identity key per user. Encrypted with `_local_key` (AES-256-GCM). Loaded on login/registration/pairing.
- **Explicit verification:** `verified_contacts.bin` stores verified contacts with method + timestamp. Encrypted with `_local_key`.
- **Fingerprint algorithm:** Iterated SHA-512 (5200 iterations), seed = `version(2B) + identity_key(32B) + user_id(UTF-8)`. Each iteration: `SHA-512(prev + identity_key)`. Output: first 32 bytes → 6 groups of 5 zero-padded digits.
- **Safety number:** Both users' fingerprints concatenated (lower user_id first → deterministic ordering). 64 bytes → 12 groups of 5 digits, displayed as 3 lines of 4 groups. Both sides see the same number.
- **QR code format:** `0x01 + uid_len(1B) + uid(UTF-8) + identity_key(32B)`. Generated via `qrcode` library, decoded via `pyzbar` (optional).
- **Identity key status:** `check_identity_key()` returns `"new"` | `"trusted"` | `"verified"` | `"changed"` | `"changed_verified"`. Called from `_get_user_info()`, result stored in `_user_cache` as `identity_key_status`.
- **Key change callback:** `_key_change_cb(user_id, username, old_key_hex, was_verified)` fires on key change. GUI wires to `key_change_warning` signal → warning dialog.
- **GUI indicators:** Green checkmark badge in conversation list for verified DMs (`ROLE_VERIFIED` data role, painted by `ConversationDelegate`). E2E label in chat header shows "Verified" (green) or "Encrypted" (muted) — clickable → opens `VerificationDialog`. Green checkmark next to verified members in group info. Security section in `UserProfileDialog` showing fingerprint + status.
- **VerificationDialog:** Frameless dialog showing safety number (monospace), QR code, both fingerprints, verification status. Buttons: "Mark as Verified" / "Remove Verification" / "Scan QR Code".
- **CLI:** Option 20 (Verify contact) — show safety number + mark verified. Option 21 (Show my fingerprint).
- **Tamper resistance:** `_load_known_identity_keys()` a `_load_verified_contacts()` nemají plaintext migration fallback (na rozdíl od sessions/sender keys). Soubory nikdy neexistovaly v nešifrované podobě — feature přidána po implementaci lokálního šifrování. Pokud útočník s přístupem k disku nahradí šifrovaný soubor plaintextovým JSONem, `_decrypt_local()` selže a load vrátí `{}` (prázdný stav). Útočník nemůže podvrhnout falešný verified status ani potlačit key-change warning.
- **iOS implementation spec (pro implementaci v ios_client/):**

| Položka | Specifikace |
|---------|-------------|
| **Fingerprint algoritmus** | SHA-512 iterated 5200×. Seed = `version(2B big-endian, hodnota 0) + identity_key(32B) + user_id(UTF-8)`. Každá iterace: `SHA-512(prev_result + identity_key)`. Výstup: prvních 32 bytes. |
| **Fingerprint display** | 6 skupin × 5 číslic: `UInt64(bytes[i*5..<i*5+5], bigEndian) % 100000`, zero-padded. Formát: `"XXXXX XXXXX XXXXX\nXXXXX XXXXX XXXXX"`. |
| **Safety number** | Fingerprint obou stran. Nižší `user_id` (String comparison) → jeho fingerprint první. Concatenate 32B + 32B = 64B. Display: 12 skupin × 5 číslic, `"XXXXX XXXXX XXXXX XXXXX\nXXXXX XXXXX XXXXX XXXXX\nXXXXX XXXXX XXXXX XXXXX"`. |
| **QR binary payload** | `0x01 + uid_len(1B) + uid(UTF-8) + identity_key(32B)`. Celkem ~70 bytes. |
| **QR encoding** | Binary payload → **base64 encode** → vložit jako string do QR. Důvod: QR čtečky zkomolí raw binary (UTF-8 re-encoding). |
| **QR decoding** | Přečtený string z QR → **base64 decode** → `decode_verification_qr()` parsuje binary payload. |
| **QR generování** | `CoreImage.CIFilter(name: "CIQRCodeGenerator")` s base64 stringem. |
| **QR skenování** | `AVCaptureMetadataOutput` s `metadataObjectTypes: [.qr]`. Výsledek: string → base64 decode → verify. |
| **TOFU storage** | Keychain nebo šifrovaný soubor. JSON schema: `{"version": 1, "keys": {"<user_id>": {"identity_key": "<hex>", "first_seen": "ISO8601", "last_seen": "ISO8601"}}}` |
| **Verified storage** | Keychain nebo šifrovaný soubor. JSON schema: `{"version": 1, "contacts": {"<user_id>": {"identity_key": "<hex>", "verified_at": "ISO8601", "method": "safety_number\|qr_code\|manual"}}}` |
| **Šifrování storage** | AES-256-GCM klíčem z HKDF(identity_key, salt="local_storage_key", info="encrypted_chat_local"). **Žádný plaintext fallback** — pokud decrypt selže, vrátit prázdný dict. |
| **Identity key status** | `checkIdentityKey(userId, ikBytes) → "new" \| "trusted" \| "verified" \| "changed" \| "changed_verified"`. Volat při každém `getUserInfo()`. |
| **Self exclusion** | Vlastní user_id nikdy nezobrazovat jako unverified — přeskočit v conv listu i group info. |
| **UI: Conversation list** | Zelená fajfka (SF Symbol `checkmark.seal.fill`) vedle jména pro verified DM kontakty. |
| **UI: Chat header** | "Verified" (zelená) nebo "Encrypted" (šedá) pod jménem. Tap → otevře VerificationView. |
| **UI: VerificationView** | Safety number (monospace), QR kód (CIQRCodeGenerator), oba fingerprints, status. Tlačítka: "Mark as Verified" / "Remove Verification" / "Scan QR Code". |
| **UI: Key change alert** | `.alert()` s textem "Identity key for [name] has changed!" + "Accept" / "View Details". |
| **UI: Group info** | Zelená fajfka vedle verified členů (ne u sebe). |
| **Cross-platform test** | Python klient a iOS klient musí pro stejný pár (user_id_A, ik_A, user_id_B, ik_B) vypočítat identický safety number. QR vygenerovaný na jedné platformě musí být čitelný na druhé. |

### Rate Limits
- Per-IP+email window (60s): register 3/min, login 10/min, send_message 20/min
- Per-connection: 20 req/s
- Per-IP: max 10 connections, global max 200
- Pairing: TTL 120s, max 90 poll attempts, pairing_start 10/min, pairing_poll 120/min, client polls every 2s

### GUI Font Handling (IMPORTANT)
All widget stylesheet `font-size` declarations use `pt` (not `px`). Using `px` in Qt stylesheets sets `pixelSize` and leaves `pointSize=-1`, which causes `QFont::setPointSize: Point size <= 0` warnings on Windows. Conversion: `pt ~= px * 0.75` at 96 DPI. HTML styles inside QTextBrowser (`_render_single_message_html`) still use `px` — that's fine, QTextBrowser uses its own HTML renderer. Bold fonts for list items use `_bold_font()` helper + `item.setData(FontRole)` to avoid the same issue.

### Phantom Users (Anti User-Enumeration)
- When a user creates a conversation with an unregistered email, the server creates a "phantom" user with `rsa_public_key = 'PHANTOM'` marker
- Phantom users have real crypto keys (Ed25519 IK, X25519 SPK + 5 OPKs) so X3DH works on the client side
- `handle_find_conversation` and `handle_create_conversation` create phantoms instead of returning "User not found"
- `handle_send_message` skips phantom recipients when storing `message_recipients` — only sender's self-encrypted copy is saved
- `phantom_user_ids: set[str]` in-memory cache loaded at startup from DB, updated on create/delete
- On registration (`handle_register_confirm`): if email belongs to a phantom, the phantom is **upgraded in-place** via `db.upgrade_phantom_user()` — preserves user_id and all FK references (invitations, conversation_members). Phantom's server-generated prekeys are deleted (real user uploads own).
- `handle_create_conversation` (groups) and `handle_add_member` create invitations for phantom users too. Push notifications only sent to non-phantom users. When phantom registers and logs in, they see pending invitations.
- Messages sent to phantom users are NOT stored and NOT recoverable after registration — this is by design (prevents user enumeration, sender sees own messages via self-encryption)
- DB functions: `db.create_phantom_user(email)`, `db.is_phantom_user(user_id)`, `db.delete_phantom_user(user_id)`, `db.upgrade_phantom_user(phantom_id, username, rsa_public_key_pem, identity_key)`, `db.get_all_phantom_user_ids()`

### Logout/Login Fix
- `_is_logout` flag in MainWindow prevents `closeEvent()` from calling `bridge.stop()` which killed the asyncio loop
- On logout: set `_is_logout = True`, call `bridge.logout()`, then `close()`
- `closeEvent()` only calls `bridge.stop()` if `not self._is_logout`
- This allows `main()` to re-create the login/main windows after logout

### Server Graceful Shutdown
- SIGINT handler force-closes all writers in `connected_clients` before the asyncio server context manager exits
- Without this, `async with server:` waited forever for `handle_client` loops to finish

### Version Negotiation
- `VERSION = "0.8.4"` constant in `protocol.py` (shared between client and server)
- `MIN_CLIENT_VERSION = "0.8.3"` — server rejects clients older than this
- Client sends `client_version` in `login_finish` request (both `login()` and `reconnect()`)
- Server logs `client_version`, returns `server_version` in `login_finish` response
- Server startup log includes version: `"Encrypted chat server v0.8.4 listening on ..."`
- iOS client (0.8.3) stays compatible — new notification types (`message_reacted`, `message_pinned`, `message_unpinned`) and payload fields (`reactions`, `pinned_at`, `forwarded_from`) are ignored by older clients

### Privacy Overlay (Lock Screen)
Anti-forensic privacy feature for PyQt GUI client:
- **Immediate overlay:** On `QEvent.Type.ActivationChange` (window loses focus), dark overlay (`rgba(30,30,46,245)`) with lock icon covers entire window. Protects against Alt+Tab thumbnails, screen sharing, shoulder surfing.
- **Timed lock:** After `_LOCK_TIMEOUT_MS` (30s) unfocused, overlay transitions to locked state — requires password to dismiss.
- **Password verification:** `_on_unlock_attempt()` reads `identity_private.bin` from disk and calls `_decrypt_private_key(data, password)` (ECP1 format: PBKDF2-600k + AES-256-GCM). Successful decryption = correct password.
- **Lock capability detection:** `_lock_capable` flag checks if identity key file starts with `b"ECP1"`. If key is not password-encrypted (legacy/no-password), lock timer never fires (overlay still works as visual privacy screen).
- **Toggle:** Ctrl+Shift+P enables/disables the feature (default: enabled).
- **Notification handling during lock:** `_show_tray_notification()` checks `self._privacy_locked` — tray toasts continue while locked. `_on_notification()` increments unread counts and skips `mark_read` while locked.
- **Components:** `_privacy_overlay` (QWidget), `_lock_input` (QLineEdit password), `_lock_error` (QLabel), `_lock_timer` (QTimer single-shot), `_lock_hint` (QLabel status text).

### Secure Deletion (Anti-Forensic Wipe)
`_secure_delete(path)` helper in both `chat_core.py` and `server.py`:
- Opens file with `r+b`, overwrites entire content with `os.urandom(size)`, calls `f.flush()` + `os.fsync(f.fileno())`, then `p.unlink()`.
- Fallback: if overwrite fails (permissions, etc.), falls back to standard `p.unlink(missing_ok=True)`.
- **Applied to (chat_core.py):** `_delete_opk_private()`, `_delete_session_file()`, session migration cleanup, sender key migration cleanup, message cache migration (plaintext JSON → encrypted).
- **Applied to (server.py):** Conversation delete (all `.enc`+`.tmp`), message delete (`.enc`), oversized upload chunk cleanup (`.tmp`), incomplete/invalid upload end (`.tmp`), stale upload periodic cleanup (`.enc`+`.tmp`).

### Metadata Privacy
Four measures to minimize metadata leakage:
- **Message Padding:** `pad_plaintext()`/`unpad_plaintext()` in `crypto_utils.py`. Plaintext padded to nearest bucket size (64B..64KB) before encryption. Format: `0x01 + plaintext + random + length(4B)`. Legacy unpadded messages (prefix `{`) auto-detected by `unpad_plaintext()`. Applied on all 6 send paths + 2 decrypt paths in `chat_core.py`.
- **Log Sanitization:** `_who(session)` returns `u=XXXXXXXX d=YYYYYYYY` (truncated user_id + device_id). Group names, recipient counts, emails, and usernames removed from all server log lines (register, login, DM create, invite, rename, send_message).
- **Metadata Retention:** `db.cleanup_old_reads(days)` and `db.cleanup_old_reactions(days)` delete old interaction data in batches. Default `METADATA_RETENTION_DAYS=90`. Runs every ~1 hour (every 30th cycle of `_periodic_cleanup`). `cleanup_old_reads` joins on `messages.created_at` to only delete reads for old messages. `get_unread_counts(max_age_days)` excludes messages older than retention window — prevents phantom unreads after read cleanup. Indexes: `idx_reads_read_at` on `message_reads.read_at`, `idx_reactions_created_at` on `message_reactions.created_at`.
- **Sender Chain Minimization:** For new group messages, `sender_chain_id`/`sender_chain_n` stored in per-recipient `message_recipients.ratchet_header` instead of `messages` table. Removes persistent sender correlation from message-level DB rows. Server verifies group-only context (dummy `dh_pub` all-zeros) before extracting chain data from per-recipient header — prevents DM injection. Self-copy entries (sender's `user_id == session["user_id"]`) are skipped during chain_meta injection. `_validate_header` now accepts `{"self": true}` for per-recipient ratchet_header (fixes self-copy storage in DB). `handle_get_messages` extracts from both locations (backward compat). Push notifications still include chain data for live decrypt.
- **Architectural limitation (known):** Server still stores `messages.sender_id` and `message_recipients.user_id` — the communication graph (who talks to whom, when) remains visible to the server. Full metadata hiding (e.g. Signal's Sealed Sender, onion routing) is a fundamentally different architecture requiring sender anonymity at the protocol level. Current metadata privacy measures reduce *unnecessary* metadata exposure (message length, log PII, interaction history retention, group chain correlation) but do not hide the communication graph itself.

## Conventions

- Server handlers: `handle_<type>(msg, session, writer)` — registered in dispatch table in `handle_client()`
- DB functions: one `get_connection()` per call, `cursor(dictionary=True)`, returns dicts
- Binary data: always base64 in protocol (`encode_binary`/`decode_binary`)
- GUI signals: bridge emits `pyqtSignal`, MainWindow connects in `_connect_signals()`
- Error responses: `{"status": "error", "data": {"message": "..."}}`
- Notification decrypt returning `None` = control message, skip silently
- GUI stylesheet font sizes: always `pt`, never `px` (see Font Handling section above)
- File sharing reuses image upload infrastructure with `file_type` parameter
- Avatar files stored in `UPLOAD_DIR/avatars/` — user: `{user_id}.{ext}`, group: `group_{conv_id}.{ext}`

## Aktuální stav práce

### ✅ Dokončeno (tato session)
- **iOS client (Swift/SwiftUI)** — Full native iOS port in `ios_client/` (47 files, ~6200 lines). Wire-compatible with Python server. Crypto layer: CryptoKit (AES-GCM, HKDF, Ed25519, X25519), pure Swift GF(2^255-19) for Ed→X conversion, Security.framework RSA-4096, ECP1 key encryption (PBKDF2 600k + AES-GCM). Protocol: Network.framework TCP+TLS, newline-delimited JSON. Core: ChatClient actor (1644 lines) with X3DH, Double Ratchet, Sender Keys, per-device sessions, SPK rotation. UI: SwiftUI views (login/register, conversation list, chat with message bubbles, group info, profile, search). Server-side RSA PSS compatibility fix: `PSS.MAX_LENGTH` → `PSS.AUTO` in `rsa_verify()` to accept both Python (max salt) and iOS (hash-length salt) signatures.
- Logout/login bug fix — `_is_logout` flag prevents bridge.stop() on logout
- Hover text readability — `color: #cdd6f4;` added to `QListWidget::item:hover`
- File card background — `background:transparent; border:1px solid #45475a`
- Delete conversations — full stack (db, server, chat_core, gui), DMs + groups (creator-only), file cleanup from disk
- Group invitation system — full stack (schema, db, server, chat_core, gui), create/accept/decline, real-time notification, invitation list UI
- Circular avatars in conversation list — QPainter circular crop, default letter avatars, online green dot overlay
- Group avatar support — upload/download, display in group info dialog, "Change Avatar" button (creator only)
- Server graceful shutdown — force-close connected clients on SIGINT
- Profile dialog avatar circular crop — QPainter in UserProfileDialog._on_avatar_loaded
- Periodic refresh timer — 2-minute QTimer re-downloads avatars + invitations
- Group invitation notification fix — `group_invitation` added to `_background_listener` notification types
- Delete button in conversation header — trash icon for DMs always, groups creator-only
- File cleanup on conversation delete — `db.get_conversation_file_ids()` + unlink `.enc` files
- Removed right-click "Delete conversation" from conversation list context menu
- README.md updated
- **H4 Race conditions fix** — 4 asyncio.Lock guards (`_clients_lock`, `_conn_lock`, `_pairing_lock`, `_uploads_lock`) pro všechny sdílené mutable struktury v server.py. `_notify_users()` + `_notify_users_individual()` helpery. Rate limit memory cleanup v periodic task. Všechny I/O operace mimo kritické sekce.
- **Unread counts pro offline uživatele** — `db.get_unread_counts()` dotaz přes `message_reads` + `message_recipients`, server vrací `unread_count` v `list_conversations`, GUI populuje `_unread_counts` ze serverových dat (max z server vs local). Opravuje bug kdy offline uživatel po přihlášení neviděl nepřečtené zprávy.
- **C6 Path traversal fix** — `_UUID_RE` regex + `_valid_file_id()`, `_safe_upload_path()`, `_safe_avatar_path()` helpery v server.py. UUID validace v `handle_upload_image_start`, `handle_download_image`. `is_relative_to()` guard ve všech path konstrukcích: upload start/end, download, delete_message file cleanup, delete_conversation file cleanup, _cleanup_uploads, get/update avatar, get/update group avatar. Celkem 10 guardovaných míst.
- **C3+H1+M13 Lokální šifrování + permissions** — `derive_local_storage_key()` v crypto_utils.py (HKDF z identity key, odlišný salt/info od self-encryption key). `_encrypt_local()`/`_decrypt_local()` helpery v chat_core.py (AES-256-GCM, formát: nonce(12)+tag(16)+ct). `_save_session`/`_load_session`, `_save_sender_key_state`/`_load_sender_key_state`, `_save_recv_sender_key`/`_load_recv_sender_key` — volitelný `local_key` parametr, při nastavení šifruje/dešifruje, `chmod 0o600` na soubory. `ChatClient._local_key` derivováno při login/registraci/pairingu. Transparentní migrace: pokud dešifrování selže, zkusí plaintext a re-uloží šifrovaně. `os.chmod(d, 0o700)` na všechny `mkdir()` v get_key_dir, opk_private, sessions, sender_keys, sender_keys_recv, message_cache. `os.chmod(p, 0o600)` na plaintext fallback message cache.
- **H7 Avatar path traversal** — `_safe_avatar_path()` guard na handle_get_avatar, handle_get_group_avatar + defense-in-depth na handle_update_avatar, handle_update_group_avatar.
- **Multi-device support (per-device sessions)** — `devices` table, `device_id` columns on prekeys/messages/recipients/sender_keys. Server: device registration at login, `writer_device_map`, per-device key bundles (`device_bundles` array), per-device notification routing (`device_entries`), `list_devices`/`remove_device` handlers. Client: `device_id` persistence, sessions keyed by `"user_id:device_id"`, `_get_device_bundles()` with 5-min TTL cache, per-device encryption in `_send_dm`/`send_image`/`send_file`/`_distribute_sender_key`, `sender_device_id` in decrypt routing, `decrypt_notification()` handles `device_entries` format. Pairing simplified: only RSA + identity key transfer, new device generates own SPK + OPKs. Migration: old session files auto-migrated, backward compat with old clients/servers. H12 OPK race condition fixed (SELECT FOR UPDATE).
- **Connection resilience fixes** — `_background_listener` fails all pending futures with `ConnectionError` on disconnect (prevents hang). `send_and_recv` has 30s timeout + catches `ConnectionError`. Server dispatch has per-message try/except (handler crash no longer kills connection). GUI `_do_send_message`/`_do_find_or_create_and_send` catch exceptions and emit error signal.
- **DB transaction fix** — `db.get_key_bundles_for_user()` had "Transaction already in progress" error because mysql-connector starts implicit transactions. Fixed with `conn.commit()` before `conn.start_transaction()`.
- **H5+H6 Protocol error handling** — `decode_binary()` catches `binascii.Error` → `ValueError`. `parse_message()` catches `JSONDecodeError`/`UnicodeDecodeError` → `ValueError`. Server dispatch already handles `ValueError` from `read_message()` gracefully.
- **H3+H13 Anti-enumeration** — `handle_register_start` returns same "ok" response for existing email (no "Email already in use" leak). `handle_login_start` returns fake challenge for non-existent email. `handle_login_finish` returns generic "Invalid credentials" for all failure cases. `get_user_info` moved behind auth barrier (requires login).
- **H8 Password memory cleanup** — `register()`, `login()`, `pairing_wait()` convert password to `bytearray`, zero out in `finally` block after key derivation.
- **H10 Image validation** — `_safe_load_image()` helper validates size (<10MB) and dimensions (<8192px) before `QImage.fromData()`. Applied to all 6 image loading locations in gui_client.py.
- **H11 Filename sanitization** — `_safe_filename()` helper strips path components via `os.path.basename()`. Applied to save dialogs and image dialog title.
- **C1+C2+C5 DoS hardening** — C1: `LimitOverrunError` now drains buffer and raises `ValueError` (server sends error response instead of silent disconnect; memory already protected by `limit=` on StreamReader). C2: `MAX_SENDER_KEY_SKIP` reduced from 1000 to 256 (matches DoubleRatchet `MAX_SKIP`). C5: `handle_upload_image_end` validates `received_bytes == file_size` before completing upload. M12 (upload end size validation) also fixed by C5.
- **M2+M8+M10+M11 Security hardening batch** — M2: SenderKeyState HKDF salt changed from `b""` to `b"\x00" * 32` (matches X3DH convention). M8: `_valid_file_id()` renamed to `_valid_uuid()`, UUID validation added to all handlers accepting client-provided `conv_id`, `user_id`, `message_id`, `device_id`. M10: `handle_mark_read` caps `message_ids` to 500 (prevents slow SQL DoS). M11: `handle_pairing_start` generates `poll_token` (secrets.token_hex(16)), `handle_pairing_poll` requires and validates it via `secrets.compare_digest()` — prevents unauthorized poll/payload extraction.
- **H2+H14 TLS hardening** — `TLS_INSECURE` a `TLS_AUTOGEN` nyní vyžadují `ENVIRONMENT=dev` (RuntimeError bez toho). Warning log na serveru i klientovi když TLS vypnuté. C4 (OPK file permissions) bylo již opraveno v C3+H1+M13 batchi.
- **Online dot fix + sorting** — Fixed timing issue where `online_users` signal processed before conv list populated. `_rebuild_conv_list()` sorts: favorites first → online DMs → rest alphabetically.
- **Favorites system** — Right-click context menu on conversation list → Add/Remove from favorites. Star indicator (★). Persisted to `favorites.json` in user key directory.
- **Group renaming** — Full stack: `db.update_conversation_name()`, `handle_rename_conversation` (server, creator-only, max 100 chars), `rename_conversation()` (chat_core), "Rename" button in group info dialog (GUI), `conversation_renamed` push notification to all members.
- **M3+M4+M9 Security hardening** — M3: PBKDF2 600k iterations (`_encrypt_private_key`/`_decrypt_private_key` s ECP1 formátem, backward compat pro PEM). M4: SPK rotace každých 7 dní, `spk_created_at` v `get_prekey_count`, grace period s `prev_spk_private.bin`, fallback v `_process_x3dh_header`/`_decrypt_dm`. M9: `_snapshot()`/`_restore()` v `DoubleRatchet.decrypt()`, snapshot/restore v `SenderKeyState.decrypt()`.
- **Phantom invitation fix** — Phantom users now receive group invitations. `handle_create_conversation` and `handle_add_member` create invitations for phantoms (no push notification). `handle_register_confirm` upgrades phantom in-place via `db.upgrade_phantom_user()` (preserves user_id + FK references). `handle_add_member` creates phantom for unregistered emails (same as `create_conversation`). After registration, user sees pending invitations on login.
- **Message Search** — Client-side search through decrypted message cache. `ChatClient.search_messages()` searches local cache. GUI: collapsible search bar (Ctrl+F) with prev/next navigation, match count, yellow/orange highlighting in message HTML. Escape closes search. Search button in chat header.
- **Session Recovery** — `session_reset` protocol message + `handle_session_reset` server handler. `ChatClient.reset_session()` deletes local session + notifies peer. Peer handles `session_reset` notification by deleting their session. Next message auto-creates new session via X3DH. GUI: "Reset session with sender" context menu on undecryptable messages, status bar notification on incoming reset.
- **L8 Phantom user DB inflation fix** — `_valid_email()` helper validates email format before phantom creation in `handle_find_conversation`, `handle_create_conversation`, `handle_add_member`. `db.cleanup_stale_phantoms(30)` deletes phantom users older than 30 days with no active conversations with real users. Runs in `_periodic_cleanup` every 10 minutes, refreshes in-memory `phantom_user_ids` cache.
- **M6 TOCTOU race fix** — `db.remove_conversation_member_atomic()` returns bool (True if row existed). Used in `handle_remove_member` (checks return value, returns error if already removed) and `handle_leave_group`. Defense-in-depth: pre-checks remain for user-friendly errors, atomic operation prevents double-removal.
- **Local-first messages + multi-device received messages fix** — Self-encrypted copies for received messages (not just sent). `batch_reencrypt_messages()` changed to INSERT ON DUPLICATE KEY UPDATE (upsert) — allows creating SELF_DEVICE_ID rows for received messages. Server-side Python dedup in `handle_get_messages` (prefers device-specific over SELF_DEVICE_ID). `after_ts` parameter for incremental sync. `get_deleted_since` protocol + handler for deletion sync. `_pending_self_encrypt` queue + `_flush_self_encrypt()` for background self-encryption of received notifications via `asyncio.ensure_future()`.
- **Detailed server logging** — `_who(session)` helper for consistent log formatting. 25+ tagged log lines: `[CONN]`, `[LOGIN]`, `[REGISTER]`, `[MSG]`, `[FETCH]`, `[READ]`, `[UPLOAD]`, `[DOWNLOAD]`, `[CONV]`, `[INVITE]`, `[LEAVE]`, `[RENAME]`, `[DELETE]`, `[AVATAR]`, `[PREKEYS]`, `[X3DH]`, `[ROTATE]`, `[DEVICE]`, `[REENCRYPT]`, `[SESSION]`, `[MEMBER]`, `[LIST]`, `[ERROR]`.
- **Version bump to 0.8.4** — `VERSION = "0.8.4"`, `MIN_CLIENT_VERSION = "0.8.3"` in protocol.py. Server rejects clients older than 0.8.3. iOS client (0.8.3) still works — new notification types and payload fields are silently ignored.
- **Message Reactions** — Emoji reactions on messages (`thumbsup`, `heart`, `laugh`, `surprised`, `sad`, `thumbsdown`). `message_reactions` DB table. `db.add_reaction()`/`db.remove_reaction()`/`db.get_reactions()`. Server: `handle_react_message` + `message_reacted` push notification. `get_messages` response includes `reactions` array per message. GUI: reaction submenu in context menu, toggle (add/remove), emoji badges below messages (grouped by reaction, highlighted if own), real-time updates via notification. CLI: option 16.
- **Message Forwarding** — Forward messages to other conversations. `ChatClient.forward_message()` sends as normal `send_message` with `forwarded_from` metadata field (`{sender, conversation_id, message_id}`). No new server endpoint needed. GUI: "Forward" in context menu, conversation picker dialog, "Forwarded from" header with blue left border in message rendering. CLI: option 19.
- **Pinned Messages** — Pin/unpin messages in conversations. `messages.pinned_at`/`pinned_by` columns. `db.pin_message()`/`db.unpin_message()`/`db.get_pinned_messages()`. Server: `handle_pin_message` + `handle_get_pinned_messages` + `message_pinned`/`message_unpinned` push notifications. GUI: "Pin"/"Unpin" in context menu, pin emoji indicator in message header, "Pinned" button in header opens dialog with list (double-click scrolls to message), real-time updates. CLI: options 17-18.
- **@Mentions** — `@username` highlighting in messages. Client-side only (no server-side handling). GUI: `MentionCompleter` popup autocomplete activated when typing `@` in message input, filters current conversation members, inserts `@username` on selection. Message rendering: `@word` patterns highlighted in blue bold (`#89b4fa`). CLI: visible as plain `@username` text.
- **Drag & Drop souborů/obrázků** — Přetažení souboru na chat oblast (message input nebo message area) automaticky odešle. Obrázky (`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp`) se posílají přes `send_image`, ostatní přes `send_file`. Vizuální feedback: modrý dashed border při drag-over. Drop je aktivní pouze když je vybraná konverzace (`drop_enabled` flag na `MessageInput`, `current_conv_id` check v event filteru na `message_area`).
- **Registration prekey upload fix** — `_ensure_prekeys()` now detects missing SPK on server (empty `spk_created_at`) and forces upload with `need_new_spk=True`. Previously, registration uploaded prekeys before login (no session → server rejected with "Not logged in"), and the login flow only uploaded OPKs without SPK. Also added warning log in `_generate_and_upload_prekeys()` when upload fails.
- **Message sender identification fix** — `_render_single_message_html()` now uses `sender_id` (user_id UUID) instead of `sender` (username) for `is_me` detection. Fixes incorrect message alignment when two users share the same display name.
- **Privacy Overlay (lock screen)** — Anti-forensic privacy feature (PyQt only). On window deactivation: immediate dark overlay with lock icon hides all chat content (protects against Alt+Tab thumbnails, screen sharing, shoulder surfing). After 30 seconds unfocused: locks and requires login password to unlock (verified by decrypting identity key from disk via ECP1/PBKDF2-600k). Ctrl+Shift+P toggles on/off. Tray notifications continue working while locked. Messages arriving during lock are counted as unread and NOT marked as read until user unlocks. `_lock_capable` flag auto-detects if identity key is password-encrypted (ECP1 format) — lock requires password only when available.
- **Secure Deletion (anti-forensic wipe)** — `_secure_delete(path)` helper overwrites file with `os.urandom()` + `fsync` before `unlink`. Applied to all sensitive file deletions: OPK private keys, session files (reset + migration), received sender key migration, message cache migration (plaintext JSON cleanup) in chat_core.py; `.enc` and `.tmp` files on conversation delete, message delete, oversized upload cleanup, incomplete upload cleanup, stale upload periodic cleanup in server.py. Fallback to standard `unlink` if overwrite fails.
- **UI redesign (Signal/Telegram look)** — `theme.py` theme systém (ThemeColors dataclass, DARK_THEME Catppuccin Mocha, LIGHT_THEME Signal-inspired, ThemeManager singleton s persistence + live switching). Widget-based message bubbles (MessageBubble QFrame s QPainter rounded rect). ConversationDelegate (custom painting: avatar, name, preview, timestamp, unread badge). Redesigned chat header (avatar, name, status, action buttons). Pill-shaped input (auto-resize, 2-line min). Tabbed login (Login/Register/Link Device). Frameless dialogs (`_make_frameless`). Theme toggle (sun/moon) v settings.
- **Reakce/piny persistentní v cache** — `ChatClient.update_message_in_cache()` synchronně aktualizuje pole zprávy v šifrovaném message cache na disku. Volá se při přidání/odebrání reakce, pin/unpin (z kontextového menu i z push notifikací). Opravuje bug kdy reakce a piny mizely po přepnutí konverzace a návratu (incremental sync nenačítal stará data ze serveru).
- **Context menu fallback na zprávách** — `MessageBubble` context menu policy změněna z `CustomContextMenu` na `DefaultContextMenu`. S `CustomContextMenu` Qt emitoval nepřipojený signál místo volání `contextMenuEvent()` override — kontext menu se neukázalo. Event filter zůstává jako primární handler, `contextMenuEvent` je fallback.
- **Forward obrázků a souborů** — `forward_message()` v chat_core.py přeposílá kompletní `image`/`file` metadata (file_id, AES klíč, IV, thumbnail, filename, size). Dříve se posílal jen textový popis `[Forwarded image: ...]`. Šifrovaný soubor je na serveru, stačí přeposlat metadata.
- **Delete message okamžitý** — Smazání zprávy se projeví lokálně ihned (bez čekání na reload). Server posílá `message_deleted` notifikaci jen ostatním, ne odesílateli — opraveno lokálním označením zprávy jako smazané + uložením do cache + re-renderem před odesláním na server.
- **Frameless confirmation dialogy** — `_confirm_dialog()` helper nahrazuje `QMessageBox.question()` — frameless dialog bez systémové lišty, s červeným "Delete" tlačítkem. Aplikováno na Delete Message a Reset Session dialogy.
- **Reakce — explicitní barva textu** — Reaction badge QLabel nyní má explicitní `color: {t.text_primary}` aby byl text viditelný v obou režimech (předtím spoléhal na CSS dědičnost).
- **SPK/OPK encryption + brute-force lockout** — SPK/OPK private keys now encrypted with AES-256-GCM via `_local_key` (derived from identity key via HKDF). Transparent migration from plaintext. Client-side brute-force lockout: exponential backoff `min(2^N, 300)` seconds after N failed password attempts. Lockout state in `login_lockout.json`. Applied to `ChatClient.login()` and GUI privacy overlay unlock (`_on_unlock_attempt`). `_clear_lockout()` on success.
- **Contact Key Verification** — Signal-style safety numbers, fingerprints, QR codes. TOFU key tracking (`known_identity_keys.bin`) + explicit verification (`verified_contacts.bin`), both encrypted with `_local_key`. `crypto_utils.py`: `compute_fingerprint()` (iterated SHA-512, 5200x), `format_fingerprint()` (30 digits), `compute_safety_number()` (60 digits, symmetric), `encode_verification_qr()`/`decode_verification_qr()`. `chat_core.py`: `check_identity_key()` (TOFU with "new"/"trusted"/"verified"/"changed"/"changed_verified"), `verify_contact()`, `unverify_contact()`, `accept_key_change()`, `get_verification_status()`, `get_safety_number()`, `get_my_fingerprint()`, `get_peer_fingerprint()`, `get_verification_qr_data()`, `verify_qr_code()`. GUI: `VerificationDialog` (safety number, QR code, fingerprints, verify/unverify buttons, QR scan), green checkmark in conversation list for verified DMs (`ROLE_VERIFIED`), E2E label clickable with verification status, key change warning dialog, security section in `UserProfileDialog`, verified badge in group info member list. CLI: options 20 (verify contact) and 21 (show fingerprint). Zero server changes.
- **Metadata Privacy (Ochrana metadat)** — Čtyři opatření pro minimalizaci metadat: **(A) Message Padding** — `pad_plaintext()`/`unpad_plaintext()` v crypto_utils.py. Bucketed padding (64/128/256/512/1K/2K/4K/8K/16K/32K/64K). Formát: `0x01 + plaintext + random_padding + pad_length(4B)`. Prefix `0x01` rozliší od legacy JSON (začíná `{`). Aplikováno na všech 6 send cest v chat_core.py (send_message, distribute_sender_key, forward_message, send_image, send_file, reencrypt_history) + unpad na 2 decrypt cestách (_decrypt_dm, _decrypt_group). **(B) Log Sanitizace** — `_who()` vrací `u=XXXXXXXX d=YYYYYYYY` místo `username (email) [device]`. Group names a recipient counts odstraněny z logů. **(C) Metadata Retention** — `db.cleanup_old_reads()`/`db.cleanup_old_reactions()` mažou záznamy starší N dní (default `METADATA_RETENTION_DAYS=90`). Batch delete (10k/iterace). Spouští se v `_periodic_cleanup()` každé 2 minuty. **(D) Sender Chain Přesun** — `sender_chain_id`/`sender_chain_n` se pro nové group zprávy ukládají do `message_recipients.ratchet_header` místo `messages` tabulky. Server `handle_get_messages` extrahuje z obou míst (backward compat). Notifikace stále posílají chain data pro live decrypt.

### 🐛 Známé bugy a problémy
- **Sender Key Redistribution (High Priority):** New group member can't decrypt old messages. On `add_member`, existing members should re-create and redistribute sender keys.
- ~~**Database Connection Pooling:**~~ ✅ OPRAVENO — `MySQLConnectionPool(pool_size=10)`.
- ~~**Duplicate FETCH after send (GUI):**~~ ✅ OPRAVENO — `send_message` vrací payload lokálně, GUI appenduje bez re-fetch.
- ~~**Group delete confirmation message is generic**~~ ✅ OPRAVENO — frameless dialog s kontextovým textem.
- ~~**Reakce a piny mizely po přepnutí konverzace:**~~ ✅ OPRAVENO — `update_message_in_cache()` ukládá na disk.
- ~~**Forward obrázku/souboru posílal jen text:**~~ ✅ OPRAVENO — přeposílá kompletní image/file metadata.
- ~~**Delete message se neprojevil okamžitě:**~~ ✅ OPRAVENO — lokální smazání + cache update před serverovým voláním.
- ~~**Delete/Reset dialogy měly systémovou lištu:**~~ ✅ OPRAVENO — frameless `_confirm_dialog()`.

### ⏭️ Další kroky (TODO)

#### Bezpečnostní opravy (priorita dle auditu)
1. **C6 (CRITICAL): Path traversal přes file_id** — `handle_upload_image_start` vytváří soubor `UPLOAD_DIR / f"{file_id}.tmp"` bez validace. Útočník může `../../...` a zapisovat/mazat mimo UPLOAD_DIR. Řešení: validovat UUID formát, ověřit `path.resolve().is_relative_to(UPLOAD_DIR.resolve())`.
2. ~~**H12 (HIGH): OPK race condition v db.get_key_bundle()**~~ ✅ OPRAVENO (součást multi-device — SELECT FOR UPDATE v consume_one_time_prekey + get_key_bundle)
3. **H3+H13: User enumeration** — `get_user_info` dostupné bez auth, vrací identity_key pro libovolný email. `register_start`/`login_start` vrací jednoznačné chyby. Řešení: auth pro `get_user_info`, generické odpovědi pro register/login.
4. ~~**H2+H14: TLS hardening**~~ ✅ OPRAVENO — `TLS_INSECURE` a `TLS_AUTOGEN` vyžadují `ENVIRONMENT=dev`. Warning log při vypnutém TLS.
5. ~~**C1+C2+C5**~~ ✅ OPRAVENO — DoS vektory (LimitOverrunError → ValueError, MAX_SENDER_KEY_SKIP 256, upload completeness check)
6. **C3+C4+H1** — Šifrování dat na disku (message cache, sessions, OPK permissions, `chmod 0o700` pro adresáře)
7. **H5+H6** — Error handling v protokolu (base64, JSON)
8. **H7** — Path traversal v avatar souborech (`resolved_path.is_relative_to()`)
9. ~~**M11 (MEDIUM): Pairing poll DoS**~~ ✅ OPRAVENO — poll_token binding (secrets.token_hex(16) + secrets.compare_digest)
10. ~~**M12: Upload end bez validace velikosti**~~ ✅ OPRAVENO (součást C5 fixu — `handle_upload_image_end` validuje `received_bytes == file_size`)
11. ~~**L8: Phantom user DB inflation**~~ ✅ OPRAVENO — email validace + periodic cleanup stale phantoms (30 dní)
12. **Version negotiation** — `VERSION = "0.8"` v protocol.py, klient posílá `client_version` při loginu, server loguje a vrací `server_version`

#### Před nasazením do produkce (checklist)
1. **TLS certifikáty** — Získat certifikát (Let's Encrypt / vlastní CA). Nastavit `TLS_ENABLED=true`, `TLS_CERT_FILE`, `TLS_KEY_FILE` v `.env`. Ověřit že `TLS_INSECURE` a `TLS_AUTOGEN` NEJSOU nastavené (vyžadují `ENVIRONMENT=dev`). Na klientovi nastavit `TLS_ENABLED=true` a případně `TLS_CA_FILE` pokud vlastní CA.
2. **Email validace** — Zapnout `_valid_email()` kontrolu v `handle_find_conversation`, `handle_create_conversation`, `handle_add_member` (kód existuje v server.py, volání zakomentována). Teď vypnuto protože dev prostředí používá emaily bez @.
3. **MySQL TLS** — Přidat SSL parametry do `db.get_connection()` (`ssl_ca`, `ssl_cert`, `ssl_key`) pokud DB běží na jiném stroji.
4. **Connection pooling** — Nahradit `get_connection()` za `mysql.connector.pooling.MySQLConnectionPool(pool_size=10)`.
5. **SMTP** — Nastavit reálný SMTP server pro registrační kódy (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`).
6. **UPLOAD_DIR** — Ověřit že `UPLOAD_DIR` je na persistentním disku s dostatkem místa, správnými právy (0o700).
7. **Rate limity** — Přezkoumat limity pro produkční zátěž (registrace 3/min, login 10/min, send_message 20/min, max 10 spojení/IP).
8. **Packaging** — Zabalit klienta (pyinstaller / cx_Freeze) pro distribuci. Po zabalení zvážit auto-update mechanismus a `get_version` endpoint.
9. **Penetrační testy** — Provést před ostrým nasazením (viz sekce níže).
10. **Backup** — Nastavit pravidelný backup MySQL databáze + `UPLOAD_DIR`.

#### Penetrační testy
- Naplánovat a provést manuální penetrační testy zaměřené na:
  - Path traversal (file_id, avatar_file)
  - DoS vektory (readuntil, sender key fast-forward, upload flooding)
  - Race conditions (OPK reuse, membership TOCTOU)
  - User enumeration (register, login, get_user_info)
  - TLS downgrade / MITM bez TLS
  - Pairing session hijacking
  - Memory exhaustion (rate_limits, phantom users, message_ids)
- Vytvořit testovací skripty pro automatizované security testy
- Zdokumentovat výsledky a opravit nalezené problémy

#### Ke zvážení
- **Auto-update klientů** — distribuce aktualizovaných souborů klientům před login/registrací. Řešit až po kompilaci/packagingu (pyinstaller apod.). Mechanismus: server verze check → klient stáhne nové soubory → restart.
- **Server version check endpoint** — po packagingu mít jednoduchý endpoint (např. `get_version`), který vrací min/aktuální podporovanou verzi klienta + URL/metadata pro update; klient může před loginem ověřit kompatibilitu a nabídnout update. Vhodné i pro postupné vypínání starých klientů.

#### Monetizace — plán

**Princip:** Oddělený platební server (KYC/AML compliant) od chat serveru (anonymní). Platba generuje jednorázový premium kód, chat server zná jen "user_id aktivoval kód". Žádný přímý link platba↔chat identita.

**Architektura:**
```
Platební server (Stripe/Paddle):  zákazník → platba → vygeneruje premium_code
    sdílí jen: premium_code (jednorázový string)
Chat server:                      user_id → aktivuje premium_code → premium do data X
```
- Platební server neví jaký user_id kód použil
- Chat server neví kdo kód koupil
- AML splněno na platební straně, privacy zachováno na chat straně

**Free vs Premium tier:**
| | Free | Premium |
|---|---|---|
| Konverzace | 5 | neomezeno |
| Soubory | 10 MB | 50 MB |
| Zařízení | 1 | 5 |
| Message retention | 30 dní | neomezeno |
| Skupiny | max 10 členů | neomezeno |

**Implementace (chat server):**
- Tabulka `premium_codes(code VARCHAR(64) PK, plan ENUM('premium_30','premium_365'), created_at, redeemed_by CHAR(36) nullable FK, redeemed_at nullable)`
- Tabulka `user_plans(user_id PK FK, plan ENUM('free','premium'), expires_at DATETIME nullable, message_retention_days INT DEFAULT 30)`
- Sloupec `messages.expires_at` (nullable DATETIME) — NULL = neomezená retence
- Handler `redeem_code` — validuje kód, aktivuje plán, nastaví expires_at
- `handle_send_message` — kontroluje limity (konverzace, velikost)
- Periodic cleanup: `DELETE FROM messages WHERE expires_at IS NOT NULL AND expires_at < NOW()` + CASCADE + `.enc` smazání
- Retence podle konverzace: pokud alespoň jeden člen premium → vyšší retence
- Klient: indikace expirace zpráv, upozornění na limity, "Upgrade" tlačítko

**Implementace (platební server — oddělený deployment):**
- Jednoduchý web (Flask/FastAPI) s Stripe Checkout
- Generuje premium_code, uloží do sdílené DB nebo pošle přes API
- AML/KYC řeší Stripe (PCI DSS, SCA, reporting)

**Další revenue streams:**
- **Enterprise/B2B licence** — self-hosted deployment, LDAP/SSO, admin dashboard, SLA. Faktura na IČO.
- **Šifrovaný cloud backup** — export/import historie šifrované uživatelským heslem (nezávisle na retenci)

#### Funkční vylepšení
1. **Sender Key Redistribution** — on add_member, redistribute sender keys to all members including new one
2. ~~**Device Linking fix**~~ ✅ — replaced with true multi-device (per-device sessions, simplified pairing)
3. ~~**SPK Rotation**~~ ✅ — periodic rotation with grace period (implemented in M4 fix)
4. **Typing Indicators** (budoucí) — `typing_start`/`typing_stop` protocol + GUI indicator (3s timeout, debounce)
5. ~~**CLI support**~~ ✅ — profiles, file sharing, invitations, leave/rename/delete, search, devices in `client.py`
6. ~~**Message search**~~ ✅ — client-side search through decrypted cache, Ctrl+F toggle, highlight + navigation
7. ~~**Session Recovery**~~ ✅ — `session_reset` protocol, auto-recreate via X3DH on next message
8. ~~**Connection Pooling**~~ ✅ — `MySQLConnectionPool(pool_size=10)`, lazy init, `DB_POOL_SIZE` env var. `conn.close()` vrací do poolu.
9. ~~**Version negotiation**~~ ✅ — `VERSION = "0.8.4"` in protocol.py, client sends `client_version` at login, server rejects clients < MIN_CLIENT_VERSION
10. **Delivery Receipts** — `message_delivered` notifikace po přijetí na zařízení (1 fajfka = odesláno, 2 fajfky = doručeno, modré = přečteno). Nová tabulka `message_deliveries` nebo rozšíření `message_reads`.
11. ~~**Reakce na zprávy (Message Reactions)**~~ ✅ — emoji reakce na zprávy. Tabulka `message_reactions`. Push notifikace `message_reacted`. GUI: emoji badges pod zprávou, submenu v kontext menu.
12. ~~**Přeposílání zpráv (Message Forwarding)**~~ ✅ — kontext menu "Forward", výběr konverzace, odeslání s `forwarded_from` metadatem. GUI: "Forwarded from" header.
13. ~~**Připnuté zprávy (Pinned Messages)**~~ ✅ — `messages.pinned_at`/`pinned_by` sloupce. `pin_message`/`unpin_message`/`get_pinned_messages` protokol. GUI: pin ikona + dialog s připnutými zprávami.
14. ~~**Zmínky (@mentions)**~~ ✅ — parsování `@username` v textu, autocomplete při psaní @, zvýraznění zmínek v modré. Klient-side only (bez server notifikací).
15. ~~**Contact Key Verification**~~ ✅ — Signal-style safety numbers (60 digits, symmetric), fingerprints (30 digits), QR codes. TOFU key tracking + explicit verification. GUI: VerificationDialog, green checkmark v conv listu, E2E label status, key change warning. CLI: options 20+21. Zero server changes.

#### Optimalizace serveru
1. ~~**DB Connection Pooling**~~ ✅ — viz bod 8 výše.
2. ~~**Oprava duplicitních FETCH v GUI**~~ ✅ — `send_message` vrací payload lokálně, GUI appenduje bez re-fetch. Dedup guard v `_on_notification`.
3. ~~**Batch prekey replenishment**~~ ✅ — `ensure_prekeys` handler na serveru (get_prekey_count + upload v jednom roundtripu). `_generate_and_upload_prekeys_batch()` v chat_core.
4. ~~**Server-side message count**~~ ✅ — `get_messages` response obsahuje `total_count`. `db.count_messages()` funkce.
5. **Prepared statements / query cache** — pro často opakované dotazy (get_messages, list_conversations) připravit prepared statements.
6. **WebSocket upgrade** — dlouhodobě nahradit raw TCP za WebSocket pro lepší kompatibilitu s firewally, load balancery, a web klienty.

#### Mobilní push notifikace (budoucí — iOS + Android)
- Tabulka `push_tokens(user_id, device_id, platform ENUM('ios','android'), token, created_at)`
- Server: při `new_message` pokud cílový uživatel nemá aktivní TCP spojení → odeslat přes APNs (iOS) / FCM (Android)
- Obsah push: jen "Nová zpráva od X" (E2EE = server nezná plaintext)
- iOS: `UserNotifications` framework, registrace tokenu při loginu, `didReceiveRemoteNotification`
- Android: Firebase Cloud Messaging, `FirebaseMessagingService`
- Server-side: `aioapns` (Python APNs library) + `firebase-admin` (FCM SDK)

## Bezpečnostní audit (Security Audit)

Kompletní audit provedený přes všechny soubory projektu. Nálezy seřazené podle závažnosti.

### 🔴 CRITICAL — Okamžitě řešit před nasazením

#### ~~C1. readuntil() bez limitu → memory exhaustion (protocol.py:62)~~ ✅ OPRAVENO
`ProtocolReader.read_message()` volá `readuntil(b"\n")`, které načte CELOU zprávu do paměti PŘED kontrolou velikosti. Útočník pošle gigabyty dat bez newline → server spadne na out-of-memory.
```python
line = await self._reader.readuntil(b"\n")  # buffers everything first!
if len(line) > MAX_MESSAGE_BYTES:            # too late
```
**Řešení:** Implementovat framing s hlavičkou obsahující velikost zprávy, nebo použít `readuntil()` s `limit` parametrem (asyncio StreamReader nemá nativně — nutno obalit vlastním čtením po částech).

#### ~~C2. SenderKeyState — neomezený fast-forward DoS (crypto_utils.py:642-645)~~ ✅ OPRAVENO
Při dešifrování skupinové zprávy s libovolně vysokým `n` se smyčka `while self.n <= n` provede milionkrát — derivuje milion klíčů, spotřebuje stovky MB RAM.
```python
while self.n <= n:
    self.chain_key, mk = kdf_ck(self.chain_key)
    self._known_keys[self.n] = mk  # unbounded dict growth
    self.n += 1
```
**Řešení:** Přidat `MAX_FORWARD_SKIP` limit (např. 1000) — stejně jako Double Ratchet má `MAX_SKIP=256`.

#### C3. Dešifrované zprávy uložené jako plaintext na disku (chat_core.py:222-239)
Message cache v `~/.encrypted_chat/{email}/message_cache/{conv_id}.json` obsahuje plný obsah dešifrovaných zpráv v nešifrovaném JSONu. Bez nastavení `chmod 0o600`. Kdokoliv s přístupem k disku přečte kompletní historii.
**Řešení:** Šifrovat cache klíčem odvozeným z identity key + nastavit `chmod 0o600` na soubory.

#### ~~C4. OPK private keys bez file permissions (chat_core.py:153-156)~~ ✅ OPRAVENO
OPK privátní klíče se ukládají bez `os.chmod(0o600)`. RSA klíče (řádek 87) a identity key (řádek 121) mají `chmod` — OPK ne. Na sdílených systémech může jiný uživatel přečíst ephemeral klíče.
**Opraveno:** Součást C3+H1+M13 fixu — `_save_opk_private()` nyní volá `os.chmod(path, 0o600)` + `os.chmod(dir, 0o700)`.

#### ~~C5. Chunked upload nevaliduje celkovou velikost (server.py:1138-1142)~~ ✅ OPRAVENO
`handle_upload_image_chunk` akumuluje `received_bytes` ale nekontroluje limit. Útočník deklaruje `file_size=5MB`, pak posílá chunky donekonečna → disk exhaustion.
```python
upload["received_bytes"] += len(raw)  # no check against file_size!
```
**Řešení:** Přidat `if upload["received_bytes"] > upload["file_size"]: reject`.

### 🟠 HIGH — Řešit před production nasazením

#### H1. Session + sender key soubory nešifrované na disku (chat_core.py:176-215)
Double Ratchet session state (DH privátní klíče, root key, chain keys) a sender key state se ukládají jako plaintext hex JSON v `sessions/` a `sender_keys/`. Bez šifrování, bez `chmod 0o600`. Kompromitace disku = dešifrování celé historie.
**Řešení:** Šifrovat state klíčem z identity key, nastavit `chmod 0o600`.

#### ~~H2. TLS vypnuté ve výchozím stavu (chat_core.py:274-291, server.py)~~ ✅ OPRAVENO (hardening)
`TLS_ENABLED` je defaultně `false`. Bez TLS jdou po síti RSA challenge-response, session tokeny a metadata v plaintextu. `TLS_INSECURE=true` vypíná certificate verification → MITM.
**Opraveno:** `TLS_INSECURE` a `TLS_AUTOGEN` nyní vyžadují `ENVIRONMENT=dev` — v produkci RuntimeError. Warning log při vypnutém TLS na serveru i klientovi. TLS_ENABLED zůstává default false (uživatel nemá certifikát), ale po nasazení Let's Encrypt stačí flip na true.

#### H3. User enumeration přes registraci (server.py:182-189)
Registrace vrací "Email already in use" pro existující uživatele vs. tiché vytvoření phantoma pro neexistující. Útočník může enumerovat platné emaily.
**Řešení:** Vrátit generickou odpověď "Check your email for verification code" i když email existuje.

#### ~~H4. Race conditions v in-memory strukturách (server.py: multiple)~~ ✅ OPRAVENO
`connected_clients` dict, `phantom_user_ids` set, `pairing_sessions` dict — čteny a zapisovány z více concurrent koroutin bez synchronizace. Asyncio je single-threaded, ale yieldy uvnitř handlerů (await) mohou způsobit nekonzistentní stav.
**Opraveno:** 4 asyncio.Lock guards: `_clients_lock` (connected_clients, phantom_user_ids), `_conn_lock` (connection_counts, current_connections, rate_limits), `_pairing_lock` (pairing_sessions, pending_registrations), `_uploads_lock` (pending_uploads). Helper funkce `_notify_users()` / `_notify_users_individual()` — snapshot under lock, send outside. Rate limit memory cleanup v periodic task. Žádný handler nedrží dva locky současně → deadlock impossible.

#### H5. base64 decode bez error handling (protocol.py:14-16, server.py + chat_core.py)
`decode_binary()` volá `base64.b64decode()` bez try-except. Nevalidní base64 od klienta → unhandled `binascii.Error` → handler crash. Mnoho callsites v server.py (řádky 357, 378, 783) nemá catch.
**Řešení:** Obalit `decode_binary()` try-except, nebo validovat base64 vstup před dekódováním.

#### H6. JSON parsing bez exception handling (protocol.py:48-50)
`parse_message()` volá `json.loads()` bez try-catch. Malformovaný JSON = neošetřený `JSONDecodeError`. Server handler catch (řádek 1399) to odchytí, ale není to explicitní.
**Řešení:** Obalit `json.loads()` v `parse_message()` try-except s explicitní chybovou zprávou.

#### H7. Path traversal v avatar souborech (server.py:1265, 1318)
`avatar_file` ze serveru (z DB) se přímo joinuje s `UPLOAD_DIR / "avatars"` bez validace. Pokud DB obsahuje `../../etc/passwd`, server přečte libovolný soubor.
**Řešení:** Přidat `resolved_path.resolve().is_relative_to(UPLOAD_DIR)` check.

#### H8. Heslo v paměti jako Python string (chat_core.py, gui_client.py)
Python stringy jsou immutable — nelze je bezpečně vymazat z paměti. Heslo zůstává v paměti dokud garbage collector neuklidí. Memory dump = plaintext heslo.
**Řešení:** Použít `bytearray` (mutable), po použití přepsat nulami: `pwd[:] = b'\x00' * len(pwd)`.

#### H9. Self-encryption key je statický a deterministický (chat_core.py:904+, crypto_utils.py:224-233)
`derive_self_encryption_key(identity_private)` produkuje vždy stejný klíč. Kompromitace identity klíče = dešifrování všech vlastních kopií zpráv navždy. Žádná forward secrecy pro self-copies.
**Poznámka:** Toto je by-design (nutné pro cross-device čtení), ale je to architektonické omezení.

#### H10. Malicious image data → QImage crash (gui_client.py)
`QImage.fromData(data)` zpracovává nevalidované binární data. Speciálně vytvořený obrázek může způsobit crash, memory exhaustion, nebo v extrémním případě RCE přes Qt image codec.
**Řešení:** Validovat velikost dat před parsováním, limit na max rozlišení.

#### H11. Filename z serveru v save dialogu (gui_client.py:2389, 2460)
Server-controlled `filename` se předává jako default do `QFileDialog.getSaveFileName()`. Pokud server pošle `"../../../.bashrc"`, dialog to navrhne.
**Řešení:** Sanitizovat filename — odstranit `../`, `\`, absolutní cesty. Použít jen `os.path.basename()`.

### 🟡 MEDIUM — Zvážit pro hardening

#### M1. Inconsistentní Ed25519 serializace (crypto_utils.py:99-102)
Bez hesla: 32 raw bytes. S heslem: PEM PKCS8 (~302 bytes). Dva různé formáty mohou způsobit problém při migraci nebo obnově klíčů.
**Poznámka:** M3 fix částečně řeší — s heslem je nyní ECP1 formát (ne PEM), ale `load_ed25519_private()` stále detekuje 3 formáty (ECP1, PEM, raw). Legacy PEM soubory se automaticky migrují při dalším uložení.

#### ~~M2. Prázdný salt v SenderKeyState HKDF (crypto_utils.py:610)~~ ✅ OPRAVENO
`hkdf_derive(sender_key, salt=b"", ...)` — RFC 5869 doporučuje nenulový salt. X3DH správně používá `b"\x00" * 32`.
**Opraveno:** Změněno `salt=b""` → `salt=b"\x00" * 32` aby odpovídalo X3DH konvenci.

#### ~~M3. PBKDF2 iterace pod doporučeným minimem (crypto_utils.py)~~ ✅ OPRAVENO
`BestAvailableEncryption` používá ~100k iterací PBKDF2. OWASP 2023 doporučuje 480k+.
**Opraveno:** Nahrazeno vlastním `_encrypt_private_key`/`_decrypt_private_key` s PBKDF2-HMAC-SHA256 (600k iterací) + AES-256-GCM. ECP1 formát (magic prefix) s backward compat pro staré PEM soubory. Aplikováno na RSA (`serialize_private_key`/`load_private_key`) i Ed25519 (`serialize_ed25519_private`/`load_ed25519_private`).

#### ~~M4. SPK bez replay protection a bez rotace (server.py:360-368)~~ ✅ OPRAVENO
Stejný SPK lze nahrát opakovaně. Žádný nonce/timestamp v podpisu. SPK se nikdy nerotuje → kompromitovaný SPK = trvalé dešifrování nových sessions.
**Opraveno:** SPK rotace každých 7 dní (`SPK_ROTATION_DAYS`). Server vrací `spk_created_at` v `handle_get_prekey_count`. `_ensure_prekeys()` kontroluje stáří SPK a rotuje pokud >= 7 dní. Předchozí SPK uložen jako grace period (`prev_spk_private.bin`/`prev_spk_id.txt`) pro in-flight X3DH. `_process_x3dh_header` přijímá `spk_override`, `_decrypt_dm` retry s předchozím SPK při selhání dešifrování.

#### ~~M5. Rate limit unbounded memory (server.py:73-83)~~ ✅ OPRAVENO (součást H4 fixu)
Staré záznamy se nikdy nečistí pokud klíč přestane být aktivní → útočník vytvoří miliony klíčů → memory leak.
**Opraveno:** `_cleanup_rate_limits()` v periodic cleanup (každých 10 min) maže stale entries z `rate_limits` i `connection_counts`.

#### ~~M6. TOCTOU race v membership checks (db.py)~~ ✅ OPRAVENO
`is_conversation_member()` → `remove_conversation_member()` — mezi kontrolou a akcí může jiný klient stav změnit.
**Opraveno:** `db.remove_conversation_member_atomic()` vrací bool (True pokud řádek existoval). Použito v `handle_remove_member` a `handle_leave_group`.

#### M7. MySQL spojení bez TLS (db.py:20-28)
`get_connection()` nepředává SSL parametry. Na vzdáleném serveru jdou credentials v plaintextu.

#### ~~M8. Chybějící validace UUID formátu (server.py: throughout)~~ ✅ OPRAVENO
`conv_id`, `user_id` — kontrola jen na neprázdnost, ne na formát UUID v4.
**Opraveno:** `_valid_file_id()` přejmenováno na `_valid_uuid()`. UUID validace přidána do všech handlerů přijímajících klientem poskytnuté `conv_id`, `user_id`, `message_id`, `device_id`.

#### ~~M9. Ratchet state corruption recovery (chat_core.py:1088-1104)~~ ✅ OPRAVENO
Pokud `decrypt()` změní chain keys ale selže na AAD verification, backup/restore mechanismus funguje, ale pokud backup selže (out-of-memory), stav zůstane corrupted.
**Opraveno:** `DoubleRatchet.decrypt()` nyní snapshotuje stav přes `_snapshot()`/`_restore()` a rollbackuje při jakékoliv výjimce (včetně skipped message key restore). `SenderKeyState.decrypt()` stejně snapshotuje `chain_key`, `n`, `_known_keys` před fast-forward a rollbackuje při selhání.

#### ~~M10. Chybějící validace velikosti message_ids listu (db.py:641-646)~~ ✅ OPRAVENO
Klient může poslat tisíce message_ids v jednom požadavku → pomalý SQL dotaz, DoS.
**Opraveno:** `handle_mark_read` nyní odmítá požadavky s více než 500 message_ids.

### 🟢 LOW — Dobrá praxe, nízké riziko

- **L1.** Hex string keys v skipped messages dict — timing side-channel po úspěšné autentikaci (crypto_utils.py:425)
- **L2.** RatchetHeader serializace redundantně konvertuje typy (crypto_utils.py:394-405)
- **L3.** `notif_label.setText()` bezpečné proti XSS (Qt neinterpretuje HTML v setText), ale křehké — přepnutí na `setHtml()` by to rozbilo (gui_client.py:1524, 2259)
- **L4.** SQL column interpolation v `update_user_profile` — whitelist chrání, ale pattern je nebezpečný při kopírování (db.py:818-822)
- **L5.** Chybějící TLS cipher suite hardening — Python defaulty jsou rozumné, ale ne explicitně nastavené (protocol.py)
- **L6.** Temporary pairing key není bezpečně vymazán z paměti (chat_core.py:581)
- **L7.** `_user_cache` ukládá public identity keys indefinitely — memory leak pro hodně kontaktů

### Druhý bezpečnostní review (zaměření na návrh, DB, komunikaci, lokální tmp/cache)

#### C6. Path traversal → libovolný zápis/smazání souborů přes file_id (server.py)
`handle_upload_image_start` vytváří soubor `UPLOAD_DIR / f"{file_id}.tmp"` bez validace `file_id`. Útočník může poslat `../../...` a zapisovat mimo UPLOAD_DIR. Následné rename, cleanup, `delete_message` a `delete_conversation` pak mohou mazat libovolné soubory.
**Řešení:** Striktně validovat file_id (UUID hex/kanonický formát), odmítnout cokoliv s `/`, `\`, `..`. Ověřit `path.resolve().is_relative_to(UPLOAD_DIR.resolve())`. Ideálně ukládat do podadresářů podle hash/UUID.

#### ~~H12. OPK race condition — reuse one-time pre-keys (db.py)~~ ✅ OPRAVENO
V `db.get_key_bundle()` se OPK vybírá SELECT → DELETE bez transakčního zámku. Při souběhu může být stejný OPK vydán vícekrát → porušení bezpečnostních předpokladů X3DH.
**Opraveno:** `consume_one_time_prekey()` a `get_key_bundle()` nyní používají `SELECT ... FOR UPDATE` + DELETE v jedné transakci (součást multi-device implementace).

#### H13. Neautentizované get_user_info + identity key exfiltrace (server.py)
`get_user_info` je dostupné bez přihlášení a vrací username, email a identity_key pro libovolný email/user_id. Umožňuje enumeraci uživatelů a sběr metadata/klíčů.
**Řešení:** Vyžadovat auth, nebo omezit na "kontakty v konverzaci".

#### ~~H14. TLS_INSECURE umožňuje MITM i v produkci (chat_core.py, server.py)~~ ✅ OPRAVENO
`TLS_INSECURE=true` vypíná verifikaci certifikátu → útočník může podvrhnout key bundle. Přímo ohrožuje E2EE integritu.
**Opraveno:** `TLS_INSECURE` vyžaduje `ENVIRONMENT=dev`, jinak RuntimeError. Součást H2 fixu.

#### ~~M11. Pairing poll DoS — neautentizovaný přístup k payload (server.py)~~ ✅ OPRAVENO
Kdokoli s 8-místným kódem může pollovat a "vyzvednout" payload (smazán po vyzvednutí). I když je šifrovaný, jde o snadný DoS (reálnému zařízení pairing selže).
**Opraveno:** `handle_pairing_start` generuje `poll_token` (secrets.token_hex(16)), vrací klientovi. `handle_pairing_poll` vyžaduje `poll_token` a porovnává přes `secrets.compare_digest()`. Klient ukládá token v `pairing_start()` a posílá v `pairing_wait()`.

#### ~~M12. Upload end bez validace received_bytes == file_size (server.py)~~ ✅ OPRAVENO
`upload_image_end` neověřuje, že `received_bytes == file_size`. Může zůstat nedokončený/nevalidní soubor.
**Řešení:** Kontrola délky před `complete_image_upload`.

#### M13. Klíčové adresáře bez chmod 700 (chat_core.py)
`get_key_dir` a podadresáře (`sessions`, `sender_keys`, `opk_private`) se vytvářejí bez explicitních práv; spoléhá se na umask.
**Řešení:** Po `mkdir` vždy `chmod 0o700` pro adresář, `0o600` pro soubory.

#### ~~L8. Phantom users — DB inflation (server.py, db.py)~~ ✅ OPRAVENO
`find_conversation` vytváří phantom usery pro libovolné emaily. I s rate limit lze DB časem nafouknout.
**Opraveno:** `_valid_email()` validace před vytvořením phantomu. `db.cleanup_stale_phantoms(30)` v periodic cleanup — maže phantomy starší 30 dní bez aktivních konverzací s reálnými uživateli.

### Bezpečnostní matice (souhrn)

| Soubor | CRITICAL | HIGH | MEDIUM | LOW |
|--------|----------|------|--------|-----|
| `protocol.py` | 1 (C1) | 2 (H5, H6) | 0 | 1 (L5) |
| `crypto_utils.py` | 1 (C2) | 0 | 3 (M1-M3) | 2 (L1, L2) |
| `server.py` | 2 (C5, C6) | 3 (H3, H7, H13) | 4 (M4, M8, M11, M12) | 0 |
| `chat_core.py` | 2 (C3, C4) | 4 (H1, H2/H14, H8, H9) | 2 (M9, M13) | 1 (L6) |
| `gui_client.py` | 0 | 2 (H10, H11) | 0 | 2 (L3, L7) |
| `db.py` | 0 | 1 (H12) | 3 (M6, M7, M10) | 2 (L4, L8) |
| **Celkem** | **6** | **12** | **12** | **8** |
| **Opraveno** | 6 (~~C1~~, ~~C2~~, ~~C3~~, ~~C4~~, ~~C5~~, ~~C6~~) | 11 (~~H1~~, ~~H2~~, ~~H3~~, ~~H4~~, ~~H5~~, ~~H6~~, ~~H7~~, ~~H8~~, ~~H10~~, ~~H11~~, ~~H12~~, ~~H13~~, ~~H14~~) | 11 (~~M2~~, ~~M3~~, ~~M4~~, ~~M5~~, ~~M6~~, ~~M8~~, ~~M9~~, ~~M10~~, ~~M11~~, ~~M12~~, ~~M13~~) | 1 (~~L8~~) |
| **Zbývá** | **0** | **1** | **1** | **7** |

### Doporučené pořadí oprav (aktualizováno)
1. ~~**C6**~~ ✅ — Path traversal přes file_id — DONE (UUID validace + is_relative_to)
2. ~~**C1 + C2 + C5**~~ ✅ — DoS vektory — DONE (LimitOverrunError → ValueError, MAX_SENDER_KEY_SKIP 256, upload completeness check)
3. ~~**H12**~~ ✅ — OPK race condition — DONE (SELECT FOR UPDATE, součást multi-device)
4. ~~**C3 + H1 + H7 + M13**~~ ✅ — Šifrování dat na disku + file/dir permissions + avatar path traversal — DONE
5. ~~**H2/H14**~~ ✅ — TLS hardening (TLS_INSECURE + TLS_AUTOGEN vyžadují ENVIRONMENT=dev, warning log) — DONE
6. ~~**H5 + H6**~~ ✅ — Error handling v protokolu (base64, JSON) — DONE
7. ~~**H3 + H13**~~ ✅ — User enumeration (generické odpovědi, auth pro get_user_info) — DONE
8. ~~**H4**~~ ✅ — Race conditions (asyncio.Lock) — DONE
9. ~~**H8 + H10 + H11**~~ ✅ — Paměť hesel, image parsing, filename sanitizace — DONE
10. ~~**M2 + M8 + M10 + M11**~~ ✅ — Hardening batch (HKDF salt, UUID validace, message_ids cap, pairing poll token) — DONE
11. ~~**M3, M4, M9**~~ ✅ — PBKDF2 600k iterations, SPK rotace 7 dní s grace period, ratchet state rollback — DONE
12. **M1, ~~M6~~, M7** — Remaining hardening (Ed25519 serialization, ~~TOCTOU~~ ✅, MySQL TLS)
13. **Penetrační testy** — manuální + automatizované security testy

## Důležitá rozhodnutí a kontext
- **Invitations replace direct add for groups:** `handle_add_member` and `handle_create_conversation` (for groups) now create invitations instead of directly adding members. DMs still auto-add both users. This was a design decision to give users control over joining groups.
- **Group delete = total destruction:** When creator deletes a group, ALL members are removed and the conversation is fully deleted. This is different from "leave group" which only removes the leaving user.
- **DM delete is per-user:** Deleting a DM only removes the deleting user. The other user still sees the conversation until they also delete it.
- **Avatar caching in GUI is pixmap-based:** `_avatar_cache` and `_group_avatar_cache` store QPixmap objects, not raw bytes. The `_on_avatar_for_conv_list` and `_on_group_avatar_for_conv_list` signals convert bytes → QImage → QPixmap on receipt.
- **No context menu on conversation list anymore:** Delete was the only action. Now handled by header buttons. `conv_list.setContextMenuPolicy(DefaultContextMenu)`.

## Environment Variables
See README.md for full list. Key: `SERVER_HOST`, `SERVER_PORT`, `MYSQL_*`, `TLS_*`, `SMTP_*`, `LOG_LEVEL`, `MAX_INPUT_CHARS`, `UPLOAD_DIR`, `MAX_IMAGE_BYTES`, `MAX_FILE_BYTES`, `MAX_MESSAGE_BYTES`, `METADATA_RETENTION_DAYS` (default 90).

## Commands & Workflow

- Start server: `python server.py`
- Start GUI client: `python gui_client.py`
- Start CLI client: `python client.py`
- Environment: `.env` file in project root (loaded by `dotenv`)
- Dependencies: `PyQt6`, `mysql-connector-python`, `cryptography`, `Pillow` (for image sharing), `python-dotenv`
- Check syntax: `python3 -m py_compile <file>.py`
- All files on both server AND client side: `crypto_utils.py`, `protocol.py`, `chat_core.py`, `gui_client.py` (or `client.py`)
