initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

9
zaloha/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.env
.env.*
.encrypted_chat/
certs/*
!certs/*.sh
!certs/*.example
!certs/README.md

758
zaloha/CLAUDE.md Normal file
View File

@@ -0,0 +1,758 @@
# 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` | ~158 | MySQL schema (users, devices, signed_prekeys, one_time_prekeys, conversations, conversation_members, group_invitations, messages, message_recipients, group_sender_keys, message_reads, image_uploads, user_profiles) |
| `db.py` | ~1245 | 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. |
| `server.py` | ~1986 | 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`. |
| `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` | ~812 | 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). |
| `chat_core.py` | ~2555 | `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. Used by CLI + GUI |
| `client.py` | ~382 | Interactive CLI client |
| `gui_client.py` | ~2591 | PyQt6 GUI — `AsyncBridge` QThread bridges asyncio <-> Qt signals, `MainWindow`, `UserProfileDialog`, 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 |
## 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)
### 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)
- `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
message_recipients: message_id + user_id + device_id (composite PK), encrypted_content (BLOB), nonce (BLOB),
ratchet_header (BLOB nullable), x3dh_header (BLOB nullable)
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 |
| `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) |
| `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 |
| `reencrypt_messages` | `handle_reencrypt_messages` | Batch re-encrypt message history with self-key (max 500/request, for device pairing) |
| `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) |
## 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
### 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
prev_spk_private.bin / prev_spk_id.txt — Previous SPK for grace period (M4, in-flight X3DH)
opk_private/{opk_id}.bin — One-time prekeys
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)
```
Storage functions: `save_keys()`, `load_keys()`, `_save_identity_keys()`, `_load_identity_keys()`, `_save_spk()`, `_load_spk()`, `_save_prev_spk()`, `_load_prev_spk()`, `_save_opk_private()`, `_load_opk_private()`, `_delete_opk_private()`, `_save_session()`, `_load_session()`, `_save_sender_key_state()`, `_load_sender_key_state()`, `_save_recv_sender_key()`, `_load_recv_sender_key()`
**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
- `_user_cache: dict[str, dict]` — user_id -> {identity_key, username, email}
- `connected: bool` — current connection state
**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()` — Batch decrypt, marks read, skips control 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
### 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`
**MainWindow:** Dark theme (Catppuccin Mocha), conversation list with circular avatars + online green dot overlay + unread count badges, 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.
**UserProfileDialog:** View (read-only) and edit (own profile) modes. Fields: avatar (circular crop), username, email, phone, location, visibility toggles. Avatar upload/download. Opened from "My Profile" button or user info button in group info dialog.
**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.
### 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
### 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"` constant in `protocol.py` (shared between client and server)
- 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 listening on ..."`
- Future: server can reject incompatible client versions, client can warn about outdated server
## 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)
- 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.
### 🐛 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:** Every `db.*` call creates new MySQL connection. Should use pooling for production.
- **Group delete confirmation message is generic** — could say "Delete group and remove all members?" for groups vs "Delete conversation?" for DMs.
### ⏭️ 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ů.
#### 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**`typing_start`/`typing_stop` protocol + GUI indicator
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**`mysql.connector.pooling` for production
9. ~~**Version negotiation**~~ ✅ — `VERSION = "0.8"` in protocol.py, client sends `client_version` at login, server logs it and returns `server_version`
## 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`.
## 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`)

314
zaloha/README.md Normal file
View File

@@ -0,0 +1,314 @@
# Encrypted Chat
End-to-end encrypted chat s forward secrecy (X3DH + Double Ratchet, Signal Protocol).
Server ukládá a přeposílá šifrované bloby — nikdy nevidí plaintext.
## Soubory
### Server
| Soubor | Účel |
|--------|------|
| `server.py` | Asyncio TCP server, handler dispatch, rate limiting, notifikace |
| `db.py` | MySQL CRUD, jedna connection na volání |
| `schema.sql` | MySQL schéma (users, conversations, messages, ...) |
### Klient
| Soubor | Účel |
|--------|------|
| `gui_client.py` | PyQt6 GUI |
| `client.py` | CLI klient |
| `chat_core.py` | Logika klienta — session management, šifrování, lokální klíče |
### Sdílené (server + klient)
| Soubor | Účel |
|--------|------|
| `crypto_utils.py` | Ed25519, X25519, AES-256-GCM, HKDF, PBKDF2, X3DH, Double Ratchet (state rollback), Sender Keys (state rollback), ECP1 key encryption |
| `protocol.py` | Newline-delimited JSON protokol, base64 encoding |
## Quick Start
1. `pip install -r requirements.txt`
2. Spustit `schema.sql` v MySQL (kompletní clean start). Pro migraci existující DB: `migration_multi_device.sql`.
3. `python server.py`
4. Klient: `python client.py` (CLI) nebo `python gui_client.py` (GUI, PyQt6)
## Jak funguje šifrování
### Klíče na uživatele
| Klíč | Typ | Účel |
|------|-----|------|
| RSA-4096 | Asymetrický | Pouze login challenge-response. Šifrovaný PBKDF2 (600k iterací) + AES-256-GCM. |
| Identity Key (IK) | Ed25519 | Podpisy, konverze na X25519 pro X3DH. Šifrovaný PBKDF2 (600k iterací) + AES-256-GCM. |
| Signed Pre-Key (SPK) | X25519 | DH v X3DH, podepsaný IK. **Rotuje se každých 7 dní** s grace periodem pro in-flight X3DH. |
| One-Time Pre-Keys (OPK) | X25519 | Jednorázové, spotřebuje se při X3DH, automaticky doplňované (< 20 → +50) |
### DM (1:1 zprávy) — X3DH + Double Ratchet
1. Alice chce napsat Bobovi poprvé → stáhne jeho key bundle (IK, SPK, OPK) ze serveru.
2. X3DH: 4 DH výpočty → shared secret.
3. Double Ratchet inicializován ze shared secret.
4. Každá zpráva: symmetric ratchet (HMAC chain) → message key → AES-256-GCM.
5. Každá odpověď: DH ratchet (nový X25519 keypair) → nový root key + chain key.
6. Per-recipient ciphertext — každý recipient má vlastní šifrovaný blob.
7. Při selhání dešifrování: automatický rollback stavu ratchetu (snapshot/restore).
### Skupiny — Sender Keys
1. Každý člen má vlastní sender key chain pro skupinu.
2. Sender key se distribuuje ostatním členům přes pairwise Double Ratchet (jako DM).
3. Skupinové zprávy: symmetric ratchet na sender key → AES-256-GCM.
4. Jeden ciphertext pro celou skupinu (efektivní).
### Lokální úložiště klíčů
```
~/.encrypted_chat/{email}/
private.pem # RSA (login) — ECP1 formát s heslem, PEM bez hesla
public.pem # RSA (login)
identity_private.bin # Ed25519 — ECP1 formát s heslem, 32B raw bez hesla
identity_public.bin # Ed25519
device_id.txt # UUID tohoto zařízení
spk_private.bin # Aktuální signed prekey
spk_id.txt
prev_spk_private.bin # Předchozí SPK (grace period pro in-flight X3DH)
prev_spk_id.txt
opk_private/ # One-time prekeys
{opk_id}.bin
sessions/ # Double Ratchet stavy (šifrované AES-256-GCM)
{user_id}_{device_id}.bin
sender_keys/ # Vlastní sender keys pro skupiny
{conv_id}.bin
sender_keys_recv/ # Přijaté sender keys od ostatních
{conv_id}_{sender_id}_{device_id}.bin
```
## Bezpečnostní hardening
### Šifrování privátních klíčů na disku (ECP1 formát)
RSA a Ed25519 privátní klíče šifrované heslem používají vlastní formát ECP1 (Encrypted Chat PBKDF v1):
- **PBKDF2-HMAC-SHA256** s 600 000 iteracemi (OWASP 2023 doporučuje 480k+)
- **AES-256-GCM** pro šifrování, magic bytes "ECP1" jako AAD
- **Formát:** `ECP1(4B) + salt(16B) + nonce(12B) + ciphertext+tag`
- **Zpětná kompatibilita:** Staré PEM soubory (z `BestAvailableEncryption`) se načtou automaticky a při dalším uložení se přešifrují do ECP1.
### SPK rotace (7 dní)
Signed Pre-Key se rotuje periodicky:
- Po přihlášení `_ensure_prekeys()` zjistí stáří SPK ze serveru (`spk_created_at`)
- Pokud je SPK starší než 7 dní → vygeneruje nový, starý uloží jako grace period
- **Grace period:** `prev_spk_private.bin` — pokud příchozí X3DH selže s aktuálním SPK, zkusí předchozí
- Omezuje dopad kompromitace SPK — útočník může vytvářet nové sessions max 7 dní
### Odolnost ratchetu (state rollback)
Double Ratchet i Sender Keys automaticky rollbackují stav při selhání dešifrování:
- Před modifikací chain keys/counters se vytvoří snapshot
- Pokud AES-GCM dešifrování selže (corrupted data, wrong key), stav se obnoví
- Session zůstane funkční i po zpracování poškozené zprávy
## Registrace
1. `register` → server pošle 6-místný kód na email (nebo vrátí přímo v dev módu bez SMTP).
2. `register_confirm` → potvrzení kódu.
3. Automaticky se vygenerují a uploadnou prekeys (1 SPK + 50 OPKs).
4. Login.
## Multi-Device Support
Pravý multi-device (Signal-like) — každé zařízení má nezávislé Double Ratchet sessions.
Při posílání DM se zpráva šifruje zvlášť pro každé zařízení příjemce.
Všechna zařízení uživatele sdílejí Ed25519 identity key (pro self-encryption kompatibilitu).
### Architektura
- **Devices tabulka** — každé přihlášení registruje device (UUID), server mapuje writer→device
- **Per-device prekeys** — každé zařízení má vlastní SPK + OPKs, server vrací `device_bundles` pole
- **Per-device sessions** — sessions klíčované `"user_id:device_id"`, nezávislé Double Ratchet instance
- **Self-encryption** — odesílatel šifruje vlastní kopii statickým klíčem z identity key (čitelné všemi vlastními zařízeními)
- **Notifikace** — `device_entries` pole, klient vybere záznam odpovídající svému device_id
### Device Pairing (zjednodušený)
Nové zařízení získá RSA + Ed25519 identity klíče od existujícího zařízení.
Přenos šifrovaný RSA-OAEP + AES-GCM přes server (server nevidí klíče).
Nové zařízení si po přihlášení automaticky vygeneruje vlastní SPK + OPKs.
1. Nové zařízení: `Link Device` → dostane 8-místný kód.
2. Existující zařízení: `Authorize Device` → zadá kód → odešle RSA + identity klíče.
3. Nové zařízení importuje klíče, přihlásí se, vygeneruje vlastní prekeys.
### Migrace
- Existující DB: spustit `migration_multi_device.sql` (nebo `migration_multi_device_resume.sql` pro idempotentní re-run)
- Čistá DB: `schema.sql` již obsahuje všechny multi-device sloupce
## Device Revocation (Key Rotation)
Rotuje RSA login klíč. Odpojí ostatní sessions. Forward secrecy zajišťuje, že kompromitace
jednoho session klíče neodhalí historii — není potřeba re-encryption.
## Konfigurace
### Server + DB
- `SERVER_HOST` (default `127.0.0.1`), `SERVER_PORT` (default `9999`)
- `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
### TLS
- `TLS_ENABLED` — zapne TLS (default `false`)
- `TLS_REQUIRED` — vyžaduje TLS_ENABLED, jinak server odmítne start
- `TLS_CERT_FILE`, `TLS_KEY_FILE` — cesty k certifikátu a privátnímu klíči (PEM)
- `TLS_AUTOGEN` — auto-generuje self-signed cert (**jen s `ENVIRONMENT=dev`**)
- `TLS_CA_FILE` (klient) — vlastní CA certifikát pro ověření serveru
- `TLS_INSECURE` (klient) — vypne ověření certifikátu (**jen s `ENVIRONMENT=dev`**)
- `ENVIRONMENT``dev`/`development` povolí TLS_INSECURE a TLS_AUTOGEN
#### Produkční nasazení s Let's Encrypt
```bash
# 1. Nainstalovat certbot
sudo apt install certbot
# 2. Získat certifikát (port 80 musí být volný pro ověření)
sudo certbot certonly --standalone -d chat.example.com
# 3. V .env nastavit:
TLS_ENABLED=true
TLS_CERT_FILE=/etc/letsencrypt/live/chat.example.com/fullchain.pem
TLS_KEY_FILE=/etc/letsencrypt/live/chat.example.com/privkey.pem
# 4. Klient — stačí zapnout TLS (Let's Encrypt je v systémovém trust store):
TLS_ENABLED=true
```
Certifikát funguje na jakémkoliv portu (9999, 443, ...) — je vázaný na doménu, ne port. Certbot automaticky obnovuje certifikát každých 90 dní.
#### Dev/testování (self-signed)
```bash
ENVIRONMENT=dev
TLS_ENABLED=true
TLS_AUTOGEN=true # server auto-generuje self-signed cert
TLS_INSECURE=true # klient přeskočí ověření certifikátu
```
### SMTP
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`
- Bez SMTP = dev mód (kód se vrací přímo klientovi).
### Obrázky
- `UPLOAD_DIR` (default `uploads`), `MAX_IMAGE_BYTES` (default 5 MB, `0` = bez limitu)
### Limity
- `MAX_MESSAGE_BYTES` (default `65536`), `MAX_INPUT_CHARS` (GUI, default `2000`)
- Rate limity: register 3/min, login 10/min, send_message 20/min, pairing_poll 10/min
- Connection: 20 req/s per connection, max 10 per IP, 200 global
- Pairing TTL: 120s, max 5 failed poll pokusů
### Logging
- `LOG_LEVEL` (default `INFO`)
## Features
- Registrace (2-step, SMTP), login (RSA challenge-response), key rotation
- **Multi-device** — per-device sessions (Signal-like), device pairing (RSA + identity key transfer), automatické prekey generování na novém zařízení
- DM s forward secrecy (X3DH + Double Ratchet) — per-device šifrování
- Skupiny se Sender Keys (distribuované přes pairwise ratchet)
- Skupinové pozvánky — přidání do skupiny vyžaduje souhlas (accept/decline)
- Odpovědi na zprávy (reply_to)
- Mazání zpráv (soft-delete pro všechny, real-time notifikace)
- Mazání konverzací (pravý klik → smaže pro uživatele, pokud nezbývají členové smaže celou konverzaci)
- Šifrované obrázky (AES-256-GCM, chunked upload, thumbnail v bublině)
- Šifrované soubory (PDF, ZIP, atd. až 50 MB, chunked upload)
- Read receipts (real-time, client-side resoluce)
- Prekey replenishment (automatické doplňování OPKs po loginu + SPK rotace každých 7 dní)
- Silné šifrování klíčů na disku (PBKDF2 600k iterací + AES-256-GCM, ECP1 formát)
- Odolný ratchet — automatický rollback stavu při selhání dešifrování
- TLS (volitelný, auto-gen self-signed)
- Real-time notifikace konverzací — nové konverzace, přidání/odebrání členů se zobrazí okamžitě bez re-loginu
- Connection state indicator — zelená/červená/oranžová tečka, automatický reconnect s exponential backoff
- Online/offline status — zelená tečka na avataru v seznamu konverzací + v group info
- User profily — telefon, lokace, avatar, nastavení viditelnosti (email, telefon, lokace)
- Phantom users — anti user-enumeration: konverzace s neregistrovaným emailem funguje normálně (odesílatel vidí své zprávy), zprávy pro phantom příjemce se neukládají, phantom se smaže při skutečné registraci
- Clickable links — HTTPS modré, HTTP oranžové s ikonou zámku + potvrzovací dialog
### GUI (PyQt6)
- Dark theme (Catppuccin Mocha)
- Seznam konverzací s kulatými avatary a online indikátorem (zelená tečka)
- Unread count badge na konverzacích (číselný počet nepřečtených zpráv)
- Message bubliny s barevným left border, timestamp vedle jména
- Read receipts (checkmarks), group info dialog, add/remove member
- Context menu: reply, delete, view image, download file
- Attach button pro obrázky a soubory, thumbnail v bublině, full-size viewer + save
- Pagination ("Load older messages")
- Connection indicator (zelená=online, červená=offline, oranžová=reconnecting)
- Auto-reconnect s exponential backoff (1s → 2s → 4s → ... → max 30s)
- Tlačítko "My Profile" — editace vlastního profilu (telefon, lokace, avatar, viditelnost)
- User profil dialog — klik na info tlačítko v group info → read-only profil uživatele
- Avatar upload/download (JPEG/PNG, max 2 MB, kruhový výřez)
- Leave group (červené tlačítko v group info, přenos creatora)
- Pozvánky do skupin — seznam pending pozvánek nad konverzacemi, pravý klik → accept/decline
- Periodický refresh avatarů a pozvánek (každé 2 minuty)
### CLI
- Základní funkcionalita (DM, skupiny, šifrování). Profily a soubory pouze přes GUI.
## Závislosti
- `cryptography` — Ed25519, X25519, AES-GCM, RSA, HKDF, PBKDF2
- `mysql-connector-python` — MySQL
- `python-dotenv` — env vars
- `PyQt6` — GUI
- `Pillow` — resize/thumbnail obrázků
## Known Issues
- Sender Keys pro skupiny se nedistribuují znovu při přidání nového člena (nový člen neuvidí staré skupinové zprávy).
## TODO
### Security — Zbývající
- [ ] **H9: Self-encryption key** — statický/deterministický klíč (by-design pro cross-device, architektonické omezení)
- [ ] M1: Nekonzistentní Ed25519 serializace (částečně vyřešeno M3 — ECP1 formát, ale 3 legacy formáty)
- [ ] M6: TOCTOU race v membership checks
- [ ] M7: MySQL spojení bez TLS
- [ ] L1-L8: Low-priority hardening
- [ ] **Penetrační testy** — manuální + automatizované
### Features — High Priority
- [ ] Redistribuce sender keys při přidání nového člena do skupiny
- [ ] Typing indicators
### Features — Medium Priority
- [ ] Hledání zpráv v konverzacích
- [ ] Group admin roles (více adminů)
- [ ] Edit sent messages
### Features — Low Priority
- [ ] Dark/light theme toggle
- [ ] Desktop notifications (system tray)
- [ ] Database connection pooling
- [ ] Image gallery view
- [ ] Systemd + Docker deployment
### Hotovo — Security
- [x] **C1-C6: Všechny CRITICAL opraveny** — readuntil DoS, sender key fast-forward, OPK permissions, upload size check, path traversal (UUID validace + is_relative_to)
- [x] **H1-H8, H10-H14: Většina HIGH opravena** — lokální šifrování dat (AES-256-GCM), TLS hardening (INSECURE/AUTOGEN jen v dev), anti-enumeration, race conditions (asyncio.Lock), protokol error handling, avatar path traversal, hesla v paměti (bytearray+zero), image validace, filename sanitizace, OPK race condition (SELECT FOR UPDATE)
- [x] **M2-M5+M8-M13: Většina MEDIUM opravena** — HKDF salt, PBKDF2 600k iterací (ECP1 formát), SPK rotace 7 dní s grace periodem, rate limit cleanup, UUID validace, ratchet state rollback, message_ids cap, pairing poll token, upload check, chmod 0o700/0o600
### Hotovo — Features
- [x] **Multi-device support** — per-device sessions (Signal-like), device pairing, automatické prekey generování
- [x] Unread counts pro offline uživatele
- [x] Clickable HTTP links — HTTPS modré, HTTP oranžové s varováním
- [x] User profily (telefon, lokace, avatar, viditelnost)
- [x] Connection state indicator + auto-reconnect
- [x] Encrypted file sharing (až 50 MB)
- [x] Leave group + přenos creatora
- [x] Unread count badge
- [x] User avatars (upload/download, kruhový výřez)
- [x] Online/offline status (zelená tečka na avataru)
- [x] Mazání konverzací
- [x] Skupinové pozvánky (accept/decline)
- [x] Graceful server shutdown
## Bezpečnostní audit
Dva bezpečnostní audity provedeny (kód review). Nalezeno 6 CRITICAL, 12 HIGH, 12 MEDIUM, 8 LOW nálezů.
| Závažnost | Celkem | Opraveno | Zbývá |
|-----------|--------|----------|-------|
| CRITICAL | 6 | **6** | 0 |
| HIGH | 12 | **11** | 1 (H9 — by-design) |
| MEDIUM | 12 | **10** | 2 (M1 částečně, M6, M7) |
| LOW | 8 | 0 | 8 |
Detaily viz `CLAUDE.md`.

2609
zaloha/chat_core.py Normal file

File diff suppressed because it is too large Load Diff

636
zaloha/client.py Normal file
View File

@@ -0,0 +1,636 @@
"""Interactive CLI client for encrypted chat (X3DH + Double Ratchet)."""
import asyncio
import logging
import os
from chat_core import ChatClient
def setup_logging():
level_name = os.getenv("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
async def prompt(text: str) -> str:
"""Non-blocking terminal input."""
return await asyncio.get_event_loop().run_in_executor(None, lambda: input(text).strip())
def _human_size(n: int) -> str:
if n >= 1024 * 1024:
return f"{n / (1024*1024):.1f} MB"
if n >= 1024:
return f"{n / 1024:.0f} KB"
return f"{n} B"
async def _select_conversation(client: ChatClient, label: str = "Select conversation") -> tuple[dict | None, list[dict]]:
"""List conversations and let user pick one. Returns (conv, convs) or (None, [])."""
convs = await client.list_conversations()
if not convs:
print("[*] No conversations.")
return None, []
def conv_label(c):
if c.get("name"):
return c["name"]
others = [m.get("username") or m.get("email") or "?" for m in c["members"] if m.get("email") != client.email]
return ", ".join(others) if others else client.username
print()
for i, c in enumerate(convs):
print(f" {i+1}) {conv_label(c)}")
choice = await prompt(f"{label}: ")
try:
idx = int(choice) - 1
if not (0 <= idx < len(convs)):
print("[!] Invalid selection.")
return None, convs
except ValueError:
print("[!] Invalid selection.")
return None, convs
return convs[idx], convs
async def interactive_menu(client: ChatClient):
"""Interactive terminal menu."""
while True:
print("\n--- Encrypted Chat ---")
print("1) Send direct message")
print("2) Send to conversation")
print("3) Read messages")
print("4) Create group conversation")
print("5) Add member to group")
print("6) Send image")
print("7) Send file")
print("8) Invitations")
print("9) Leave group")
print("10) Rename group")
print("11) Delete conversation")
print("12) Search messages")
print("13) My profile")
print("14) View user profile")
print("15) Manage devices")
print("q) Quit")
choice = await prompt("> ")
if choice == "1":
email = await prompt("To (email): ")
if not email:
continue
text = await prompt("Message: ")
if not text:
continue
conv_id, msg = await client.find_or_create_conversation(email)
if not conv_id:
print(f"[!] {msg}")
continue
convs = await client.list_conversations()
members = []
for c in convs:
if c["conversation_id"] == conv_id:
members = c["members"]
break
ok, msg = await client.send_message(conv_id, text, members)
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "2":
conv, _ = await _select_conversation(client)
if not conv:
continue
text = await prompt("Message: ")
if not text:
continue
ok, msg = await client.send_message(conv["conversation_id"], text, conv["members"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "3":
conv, _ = await _select_conversation(client)
if not conv:
continue
messages = await client.get_messages(conv["conversation_id"])
if not messages:
print("[*] No messages.")
continue
_print_messages(messages, client, conv)
action = await prompt("\nAction (r=reply, d=delete, dl=download file, empty=back): ")
if not action:
continue
if action.lower().startswith("dl"):
await _download_file_action(client, messages)
continue
if action.lower().startswith("d"):
await _delete_message_action(client, messages)
continue
if action.lower().startswith("r"):
reply_choice = await prompt("Reply to message #: ")
else:
reply_choice = action
try:
reply_idx = int(reply_choice) - 1
if not (0 <= reply_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
reply_to_id = messages[reply_idx]["message_id"]
text = await prompt("Message: ")
if not text:
continue
ok, msg = await client.send_message(conv["conversation_id"], text, conv["members"], reply_to=reply_to_id)
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "4":
name = await prompt("Group name (empty for none): ")
members_input = await prompt("Member emails (comma-separated): ")
members = [m.strip() for m in members_input.split(",") if m.strip()]
if not members:
continue
conv_id, msg = await client.create_conversation(members, name=name.strip() or None)
if conv_id:
print(f"[+] Group created with: {', '.join(members)}")
else:
print(f"[!] {msg}")
elif choice == "5":
conv, _ = await _select_conversation(client)
if not conv:
continue
email = await prompt("Email to add: ")
ok, msg = await client.add_member(conv["conversation_id"], email)
print(f"[{'+'if ok else '!'}] {msg or 'Invitation sent.'}")
elif choice == "6":
conv, _ = await _select_conversation(client)
if not conv:
continue
image_path = await prompt("Image path: ")
if not image_path:
continue
ok, msg = await client.send_image(conv["conversation_id"], image_path, conv["members"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "7":
conv, _ = await _select_conversation(client)
if not conv:
continue
file_path = await prompt("File path: ")
if not file_path:
continue
if not os.path.isfile(file_path):
print("[!] File not found.")
continue
ok, msg = await client.send_file(conv["conversation_id"], file_path, conv["members"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "8":
await _invitations_menu(client)
elif choice == "9":
conv, _ = await _select_conversation(client, "Select group to leave")
if not conv:
continue
confirm = await prompt(f"Leave '{conv.get('name', 'this conversation')}'? (y/n): ")
if confirm.lower() != "y":
continue
ok, msg = await client.leave_group(conv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "10":
conv, _ = await _select_conversation(client, "Select group to rename")
if not conv:
continue
name = await prompt("New name: ")
if not name:
continue
ok, msg = await client.rename_conversation(conv["conversation_id"], name.strip())
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "11":
conv, _ = await _select_conversation(client, "Select conversation to delete")
if not conv:
continue
confirm = await prompt("Delete this conversation? This cannot be undone. (y/n): ")
if confirm.lower() != "y":
continue
ok, msg = await client.delete_conversation(conv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "12":
conv, _ = await _select_conversation(client, "Select conversation to search")
if not conv:
continue
query = await prompt("Search query: ")
if not query:
continue
# First ensure we have messages cached by fetching them
await client.get_messages(conv["conversation_id"])
results = client.search_messages(conv["conversation_id"], query)
if not results:
print("[*] No matches found.")
continue
print(f"\n[*] {len(results)} match(es):")
for r in results:
sender = r.get("sender", "???")
text = r.get("text", "")
ts = r.get("created_at", "")[:16]
# Highlight match in text
idx = text.lower().find(query.lower())
if idx >= 0:
text = text[:idx] + "\033[33m" + text[idx:idx+len(query)] + "\033[0m" + text[idx+len(query):]
print(f" [{ts}] {sender}: {text}")
elif choice == "13":
await _my_profile_menu(client)
elif choice == "14":
email = await prompt("User email: ")
if not email:
continue
# Need to find user_id from email — try via conversation members
user_id = None
convs = await client.list_conversations()
for c in convs:
for m in c.get("members", []):
if m.get("email") == email:
user_id = m.get("user_id") or m.get("id")
break
if user_id:
break
if not user_id:
print("[!] User not found in your conversations.")
continue
profile = await client.get_profile(user_id)
if not profile:
print("[!] Could not load profile.")
continue
_print_profile(profile)
elif choice == "15":
await _devices_menu(client)
elif choice in ("q", "Q", "quit", "exit"):
print("[*] Bye.")
break
def _print_messages(messages, client, conv):
"""Print messages to terminal."""
print()
for i, m in enumerate(messages):
if m.get("deleted"):
print(f" #{i+1} [Message deleted]")
continue
reply_info = ""
if m.get("reply_to"):
for j, orig in enumerate(messages):
if orig["message_id"] == m["reply_to"]:
reply_info = f" (reply to #{j+1})"
break
else:
reply_info = " (reply to older message)"
image_info = ""
if m.get("image"):
img = m["image"]
image_info = f" [Image: {img.get('filename', '?')} ({_human_size(img.get('size', 0))})]"
file_info = ""
if m.get("file"):
fi = m["file"]
file_info = f" [File: {fi.get('filename', '?')} ({_human_size(fi.get('size', 0))})]"
read_info = ""
if m.get("sender") == client.username:
read_by = m.get("read_by", [])
member_map = {}
for mem in conv.get("members", []):
uid = mem.get("user_id") or mem.get("id", "")
if uid:
member_map[uid] = mem.get("username") or mem.get("email") or "?"
my_uid = client.session.get("user_id", "") if client.session else ""
others_read = [r for r in read_by if r.get("user_id") != my_uid]
if others_read:
names = ", ".join(member_map.get(r["user_id"], r["user_id"][:8]) for r in others_read)
read_info = f" [\u2713\u2713 Read by {names}]"
else:
read_info = " [\u2713 Sent]"
text = m.get("text", "")
print(f" #{i+1} {m['sender']}: {text}{image_info}{file_info}{reply_info}{read_info}")
async def _delete_message_action(client, messages):
del_choice = await prompt("Delete message #: ")
try:
del_idx = int(del_choice) - 1
if not (0 <= del_idx < len(messages)):
print("[!] Invalid message number.")
return
except ValueError:
print("[!] Invalid number.")
return
ok, msg = await client.delete_message(messages[del_idx]["message_id"])
print(f"[{'+'if ok else '!'}] {msg}")
async def _download_file_action(client, messages):
dl_choice = await prompt("Download from message #: ")
try:
dl_idx = int(dl_choice) - 1
if not (0 <= dl_idx < len(messages)):
print("[!] Invalid message number.")
return
except ValueError:
print("[!] Invalid number.")
return
m = messages[dl_idx]
file_info = m.get("file") or m.get("image")
if not file_info:
print("[!] No file/image in this message.")
return
filename = file_info.get("filename", "download")
save_path = await prompt(f"Save as [{filename}]: ")
if not save_path:
save_path = filename
data = await client.download_file(file_info["file_id"], file_info)
if data:
with open(save_path, "wb") as f:
f.write(data)
print(f"[+] Saved to {save_path} ({_human_size(len(data))})")
else:
print("[!] Download failed.")
async def _invitations_menu(client):
invitations = await client.list_invitations()
if not invitations:
print("[*] No pending invitations.")
return
print("\nPending invitations:")
for i, inv in enumerate(invitations):
inv_name = inv.get("conversation_name") or inv.get("conversation_id", "")[:8]
invited_by = inv.get("invited_by_username") or inv.get("invited_by", "")[:8]
print(f" {i+1}) {inv_name} (invited by {invited_by})")
choice = await prompt("Select invitation (or empty to go back): ")
if not choice:
return
try:
idx = int(choice) - 1
if not (0 <= idx < len(invitations)):
print("[!] Invalid selection.")
return
except ValueError:
print("[!] Invalid selection.")
return
inv = invitations[idx]
action = await prompt("(a)ccept or (d)ecline? ")
if action.lower().startswith("a"):
ok, msg = await client.accept_invitation(inv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
elif action.lower().startswith("d"):
ok, msg = await client.decline_invitation(inv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
def _print_profile(profile):
print(f"\n Username: {profile.get('username', '?')}")
print(f" Email: {profile.get('email', '?')}")
phone = profile.get("phone")
if phone:
print(f" Phone: {phone}")
location = profile.get("location")
if location:
print(f" Location: {location}")
has_avatar = profile.get("avatar_file")
print(f" Avatar: {'Yes' if has_avatar else 'No'}")
async def _my_profile_menu(client):
profile = await client.get_profile()
if not profile:
print("[!] Could not load profile.")
return
print("\n--- My Profile ---")
_print_profile(profile)
print(f" Phone visible: {profile.get('phone_visible', False)}")
print(f" Email visible: {profile.get('email_visible', False)}")
print(f" Location visible: {profile.get('location_visible', False)}")
action = await prompt("\n(e)dit, (a)vatar upload, or empty to go back: ")
if not action:
return
if action.lower().startswith("e"):
print("[*] Leave fields empty to keep current value.")
phone = await prompt(f"Phone [{profile.get('phone', '')}]: ")
location = await prompt(f"Location [{profile.get('location', '')}]: ")
phone_vis = await prompt(f"Phone visible [{profile.get('phone_visible', False)}] (y/n): ")
email_vis = await prompt(f"Email visible [{profile.get('email_visible', False)}] (y/n): ")
loc_vis = await prompt(f"Location visible [{profile.get('location_visible', False)}] (y/n): ")
fields = {}
if phone:
fields["phone"] = phone
if location:
fields["location"] = location
if phone_vis.lower() in ("y", "n"):
fields["phone_visible"] = phone_vis.lower() == "y"
if email_vis.lower() in ("y", "n"):
fields["email_visible"] = email_vis.lower() == "y"
if loc_vis.lower() in ("y", "n"):
fields["location_visible"] = loc_vis.lower() == "y"
if fields:
ok, msg = await client.update_profile(**fields)
print(f"[{'+'if ok else '!'}] {msg}")
else:
print("[*] No changes.")
elif action.lower().startswith("a"):
path = await prompt("Avatar image path: ")
if not path or not os.path.isfile(path):
print("[!] File not found.")
return
data = open(path, "rb").read()
ok, msg = await client.update_avatar(data)
print(f"[{'+'if ok else '!'}] {msg}")
async def _devices_menu(client):
resp = await client.send_and_recv("list_devices")
if resp.get("status") != "ok":
print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
return
devices = resp["data"].get("devices", [])
if not devices:
print("[*] No devices found.")
return
current_device_id = client.device_id
print("\nYour devices:")
for i, d in enumerate(devices):
name = d.get("device_name") or "Unnamed"
did = d.get("device_id", "?")
last_seen = d.get("last_seen_at", "?")
current = " (this device)" if did == current_device_id else ""
print(f" {i+1}) {name}{did[:8]}... — last seen: {last_seen}{current}")
action = await prompt("\n(r)emove a device, or empty to go back: ")
if not action or not action.lower().startswith("r"):
return
choice = await prompt("Remove device #: ")
try:
idx = int(choice) - 1
if not (0 <= idx < len(devices)):
print("[!] Invalid selection.")
return
except ValueError:
print("[!] Invalid selection.")
return
d = devices[idx]
if d.get("device_id") == current_device_id:
print("[!] Cannot remove current device.")
return
resp = await client.send_and_recv("remove_device", device_id=d["device_id"])
if resp.get("status") == "ok":
print("[+] Device removed.")
else:
print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
async def notification_printer(client: ChatClient):
"""Print real-time notifications with sender name."""
while True:
notif = await client._notification_queue.get()
notif_type = notif.get("type", "")
data = notif.get("data", {})
if notif_type == "messages_read":
continue # Silent - read receipts shown when reading messages
if notif_type == "session_reset":
from_uid = data.get("from_user_id", "")[:8]
client.handle_session_reset_notification(
data.get("from_user_id", ""),
data.get("from_device_id") or None,
)
print(f"\n[*] Session with {from_uid}... was reset. New session will be created on next message.")
continue
if notif_type == "group_invitation":
inv_name = data.get("conversation_name", "?")
invited_by = data.get("invited_by_username", "?")
print(f"\n[*] New invitation to '{inv_name}' from {invited_by}. Use option 8 to accept/decline.")
continue
if notif_type in ("conversation_created", "member_added", "member_removed", "conversation_renamed"):
print(f"\n[*] Conversation updated ({notif_type}).")
continue
if notif_type in ("user_online", "user_offline", "online_users"):
continue # Silent for CLI
payload = client.decrypt_notification(data)
if payload:
print(f"\n[*] New message from {payload['sender']} in conversation {data.get('conversation_id', '?')[:8]}...")
# None = control message (sender key distribution), skip silently
async def main():
setup_logging()
client = ChatClient()
await client.connect()
client._listener_task = asyncio.create_task(client._background_listener())
notif_task = asyncio.create_task(notification_printer(client))
print("=== Encrypted Chat Client ===")
print("1) Register")
print("2) Login")
print("3) Link new device (this device)")
print("4) Authorize new device (from this device)")
print("5) Rotate keys (revoke other devices)")
choice = await prompt("> ")
if choice == "1":
username = await prompt("Username (display): ")
email = await prompt("Email: ")
password = await prompt("Password (for private key): ")
if not email or not password:
print("[!] Email and password required.")
await client.close()
return
ok, code_or_msg = await client.register(username, password, email=email)
if not ok:
print(f"[!] {code_or_msg}")
await client.close()
return
print(f"[*] Registration code: {code_or_msg}")
code = await prompt("Enter code: ")
ok2, msg2 = await client.confirm_registration(email, username, code)
print(f"[{'+'if ok2 else '!'}] {msg2}")
if ok2:
ok3, msg3 = await client.login(email, password)
print(f"[{'+'if ok3 else '!'}] {msg3}")
elif choice == "2":
email = await prompt("Email: ")
password = await prompt("Password (for private key): ")
ok, msg = await client.login(email, password)
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "3":
email = await prompt("Email: ")
password = await prompt("Password (for private key): ")
if not password:
print("[!] Password required.")
await client.close()
return
ok, code_or_msg = await client.pairing_start(email)
if not ok:
print(f"[!] {code_or_msg}")
await client.close()
return
code = code_or_msg
print(f"[*] Pairing code: {code}")
print("[*] Approve this code on an already-logged-in device.")
ok2, msg2 = await client.pairing_wait(code, email, password)
if not ok2:
print(f"[!] {msg2}")
await client.close()
return
print(f"[+] {msg2}")
ok3, msg3 = await client.login(email, password)
print(f"[{'+'if ok3 else '!'}] {msg3}")
elif choice == "4":
email = await prompt("Email: ")
password = await prompt("Password (for private key): ")
ok, msg = await client.login(email, password)
print(f"[{'+'if ok else '!'}] {msg}")
if not ok:
await client.close()
return
code = await prompt("Pairing code: ")
ok2, msg2 = await client.authorize_device(code)
print(f"[{'+'if ok2 else '!'}] {msg2}")
elif choice == "5":
email = await prompt("Email: ")
password = await prompt("Password (for private key): ")
ok, msg = await client.login(email, password)
print(f"[{'+'if ok else '!'}] {msg}")
if not ok:
await client.close()
return
confirm = await prompt("This will revoke other devices. Type 'YES' to continue: ")
if confirm != "YES":
print("[*] Cancelled.")
await client.close()
return
ok2, msg2 = await client.rotate_keys(client.username, password)
print(f"[{'+'if ok2 else '!'}] {msg2}")
else:
print("[!] Invalid choice.")
await client.close()
return
if client.session:
await interactive_menu(client)
notif_task.cancel()
await client.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n[*] Bye.")

812
zaloha/crypto_utils.py Normal file
View File

@@ -0,0 +1,812 @@
"""Cryptographic utilities: Ed25519, X25519, AES-256-GCM, Double Ratchet, Sender Keys.
RSA functions retained for login challenge-response only.
"""
import hashlib
import hmac
import json
import os
import struct
import uuid
from dataclasses import dataclass, field
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# ---------------------------------------------------------------------------
# Password-based key encryption (M3: PBKDF2 600k iterations + AES-256-GCM)
# ---------------------------------------------------------------------------
PBKDF2_ITERATIONS = 600_000
_ECP1_MAGIC = b"ECP1" # Encrypted Chat PBKDF v1 format marker
def _encrypt_private_key(raw_bytes: bytes, password: bytes) -> bytes:
"""Encrypt raw key bytes with PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM.
Output format: MAGIC(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
"""
salt = os.urandom(16)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32,
salt=salt, iterations=PBKDF2_ITERATIONS)
derived = kdf.derive(password)
nonce = os.urandom(12)
aesgcm = AESGCM(derived)
ct = aesgcm.encrypt(nonce, raw_bytes, _ECP1_MAGIC) # AAD = magic bytes
return _ECP1_MAGIC + salt + nonce + ct
def _decrypt_private_key(data: bytes, password: bytes) -> bytes:
"""Decrypt key bytes encrypted with _encrypt_private_key."""
if not data.startswith(_ECP1_MAGIC):
raise ValueError("Not ECP1 format")
salt = data[4:20]
nonce = data[20:32]
ct = data[32:]
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32,
salt=salt, iterations=PBKDF2_ITERATIONS)
derived = kdf.derive(password)
aesgcm = AESGCM(derived)
return aesgcm.decrypt(nonce, ct, _ECP1_MAGIC)
# ---------------------------------------------------------------------------
# RSA (login challenge-response ONLY)
# ---------------------------------------------------------------------------
def generate_rsa_keypair(key_size: int = 4096) -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
return private_key, private_key.public_key()
def serialize_private_key(key: rsa.RSAPrivateKey, password: bytes | None = None) -> bytes:
if password:
raw = key.private_bytes(serialization.Encoding.DER, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
return _encrypt_private_key(raw, password)
return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
def serialize_public_key(key: rsa.RSAPublicKey) -> bytes:
return key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
def load_private_key(data: bytes, password: bytes | None = None) -> rsa.RSAPrivateKey:
if data.startswith(_ECP1_MAGIC):
raw = _decrypt_private_key(data, password)
return serialization.load_der_private_key(raw, password=None)
# Legacy PEM format (old BestAvailableEncryption or unencrypted)
return serialization.load_pem_private_key(data, password=password)
def load_public_key(pem: bytes) -> rsa.RSAPublicKey:
return serialization.load_pem_public_key(pem)
def rsa_sign(private_key: rsa.RSAPrivateKey, data: bytes) -> bytes:
return private_key.sign(
data,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)
def rsa_verify(public_key: rsa.RSAPublicKey, signature: bytes, data: bytes) -> bool:
try:
public_key.verify(
signature, data,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# AES-256-GCM (symmetric encryption — used by ratchet message keys & images)
# ---------------------------------------------------------------------------
def aes_encrypt(plaintext: bytes, key: bytes | None = None) -> tuple[bytes, bytes, bytes, bytes]:
"""Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag)."""
if key is None:
key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ct_with_tag = aesgcm.encrypt(nonce, plaintext, None)
ciphertext = ct_with_tag[:-16]
tag = ct_with_tag[-16:]
return key, nonce, ciphertext, tag
def aes_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, tag: bytes) -> bytes:
"""Decrypt with AES-256-GCM."""
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext + tag, None)
# ---------------------------------------------------------------------------
# Ed25519 Identity Keys
# ---------------------------------------------------------------------------
def generate_identity_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
priv = Ed25519PrivateKey.generate()
return priv, priv.public_key()
def serialize_ed25519_private(key: Ed25519PrivateKey, password: bytes | None = None) -> bytes:
if password:
raw = serialize_ed25519_private_raw(key) # 32 bytes
return _encrypt_private_key(raw, password)
return serialize_ed25519_private_raw(key) # 32 bytes, no password
def serialize_ed25519_private_raw(key: Ed25519PrivateKey) -> bytes:
"""Serialize Ed25519 private key to 32 raw bytes (unencrypted)."""
return key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
def serialize_ed25519_public(key: Ed25519PublicKey) -> bytes:
"""Serialize Ed25519 public key to 32 raw bytes."""
return key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
def load_ed25519_private(data: bytes, password: bytes | None = None) -> Ed25519PrivateKey:
if data.startswith(_ECP1_MAGIC):
raw = _decrypt_private_key(data, password)
return Ed25519PrivateKey.from_private_bytes(raw)
# Legacy formats: PEM (old BestAvailableEncryption) or 32-byte raw
if password:
return serialization.load_pem_private_key(data, password=password)
if len(data) == 32:
return Ed25519PrivateKey.from_private_bytes(data)
return serialization.load_pem_private_key(data, password=None)
def load_ed25519_public(data: bytes) -> Ed25519PublicKey:
if len(data) == 32:
return Ed25519PublicKey.from_public_bytes(data)
return serialization.load_pem_public_key(data)
def ed25519_sign(private_key: Ed25519PrivateKey, data: bytes) -> bytes:
"""Sign data with Ed25519. Returns 64-byte signature."""
return private_key.sign(data)
def ed25519_verify(public_key: Ed25519PublicKey, signature: bytes, data: bytes) -> bool:
"""Verify Ed25519 signature."""
try:
public_key.verify(signature, data)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# X25519 Key Exchange
# ---------------------------------------------------------------------------
def generate_x25519_keypair() -> tuple[X25519PrivateKey, X25519PublicKey]:
priv = X25519PrivateKey.generate()
return priv, priv.public_key()
def serialize_x25519_private(key: X25519PrivateKey) -> bytes:
"""Serialize X25519 private key to 32 raw bytes."""
return key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
def serialize_x25519_public(key: X25519PublicKey) -> bytes:
"""Serialize X25519 public key to 32 raw bytes."""
return key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
def load_x25519_private(data: bytes) -> X25519PrivateKey:
return X25519PrivateKey.from_private_bytes(data)
def load_x25519_public(data: bytes) -> X25519PublicKey:
return X25519PublicKey.from_public_bytes(data)
def x25519_dh(private_key: X25519PrivateKey, public_key: X25519PublicKey) -> bytes:
"""Perform X25519 Diffie-Hellman. Returns 32-byte shared secret."""
return private_key.exchange(public_key)
# ---------------------------------------------------------------------------
# Ed25519 <-> X25519 conversion (for Identity Key dual use)
# ---------------------------------------------------------------------------
def ed25519_private_to_x25519(ed_private: Ed25519PrivateKey) -> X25519PrivateKey:
"""Derive X25519 private key from Ed25519 private key via RFC 7748 clamping."""
raw = ed_private.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)
# SHA-512 hash of the seed, take first 32 bytes, clamp per RFC 7748
h = hashlib.sha512(raw).digest()[:32]
clamped = bytearray(h)
clamped[0] &= 248
clamped[31] &= 127
clamped[31] |= 64
return X25519PrivateKey.from_private_bytes(bytes(clamped))
def ed25519_public_to_x25519(ed_public: Ed25519PublicKey) -> X25519PublicKey:
"""Derive X25519 public key from Ed25519 public key.
Uses the cryptography library's internal conversion. For production use,
we compute the X25519 public key from the converted private key when possible.
For remote keys (where we don't have the private key), we use a pure-Python
implementation of the Ed25519->X25519 point conversion.
"""
# Montgomery u = (1 + y) / (1 - y) mod p, where p = 2^255 - 19
raw = ed_public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
y = int.from_bytes(raw, "little")
# Clear the sign bit
y &= (1 << 255) - 1
p = (1 << 255) - 19
# u = (1 + y) * inverse(1 - y) mod p
one_plus_y = (1 + y) % p
one_minus_y = (1 - y) % p
inv = pow(one_minus_y, p - 2, p)
u = (one_plus_y * inv) % p
x25519_bytes = u.to_bytes(32, "little")
return X25519PublicKey.from_public_bytes(x25519_bytes)
# ---------------------------------------------------------------------------
# HKDF
# ---------------------------------------------------------------------------
_HKDF_INFO_SELF = b"EncryptedChat_SelfKey"
_HKDF_INFO_RK = b"EncryptedChat_RootKey"
def derive_self_encryption_key(identity_private: Ed25519PrivateKey) -> bytes:
"""Derive a static AES-256 key from identity key for encrypting own sent messages.
This is NOT a ratchet — it's a static key. Safe because only the owner
has the identity private key, and self-copies don't need forward secrecy.
"""
raw = identity_private.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)
return hkdf_derive(raw, salt=b"self_encryption", info=_HKDF_INFO_SELF, length=32)
_HKDF_INFO_LOCAL = b"EncryptedChat_LocalStorage"
def derive_local_storage_key(identity_private: Ed25519PrivateKey) -> bytes:
"""Derive AES-256 key for encrypting local session/sender key files."""
raw = identity_private.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)
return hkdf_derive(raw, salt=b"local_storage", info=_HKDF_INFO_LOCAL, length=32)
_HKDF_INFO_CK_MSG = b"\x01" # chain key -> message key
_HKDF_INFO_CK_NEXT = b"\x02" # chain key -> next chain key
def hkdf_derive(input_key: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=length, salt=salt, info=info).derive(input_key)
def kdf_rk(root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]:
"""Root key KDF. Returns (new_root_key, chain_key).
Uses HKDF with the root key as salt and DH output as input key material.
Derives 64 bytes: first 32 = new root key, last 32 = chain key.
"""
derived = hkdf_derive(dh_output, salt=root_key, info=_HKDF_INFO_RK, length=64)
return derived[:32], derived[32:]
def kdf_ck(chain_key: bytes) -> tuple[bytes, bytes]:
"""Chain key KDF. Returns (new_chain_key, message_key).
Uses HMAC-SHA256:
message_key = HMAC(chain_key, 0x01)
new_chain_key = HMAC(chain_key, 0x02)
"""
message_key = hmac.new(chain_key, _HKDF_INFO_CK_MSG, hashlib.sha256).digest()
new_chain_key = hmac.new(chain_key, _HKDF_INFO_CK_NEXT, hashlib.sha256).digest()
return new_chain_key, message_key
# ---------------------------------------------------------------------------
# X3DH
# ---------------------------------------------------------------------------
_X3DH_INFO = b"EncryptedChat_X3DH"
def generate_signed_prekey(identity_private: Ed25519PrivateKey) -> dict:
"""Generate a signed pre-key (SPK).
Returns {private: X25519PrivateKey, public: X25519PublicKey, signature: bytes, id: str}.
"""
spk_priv, spk_pub = generate_x25519_keypair()
spk_pub_bytes = serialize_x25519_public(spk_pub)
signature = ed25519_sign(identity_private, spk_pub_bytes)
return {
"private": spk_priv,
"public": spk_pub,
"signature": signature,
"id": str(uuid.uuid4()),
}
def generate_one_time_prekeys(count: int = 50) -> list[dict]:
"""Generate a batch of one-time pre-keys.
Returns [{private: X25519PrivateKey, public: X25519PublicKey, id: str}, ...].
"""
result = []
for _ in range(count):
priv, pub = generate_x25519_keypair()
result.append({"private": priv, "public": pub, "id": str(uuid.uuid4())})
return result
def x3dh_initiate(
ik_private_ed: Ed25519PrivateKey,
ik_public_remote_ed: Ed25519PublicKey,
spk_remote: X25519PublicKey,
spk_signature: bytes,
opk_remote: X25519PublicKey | None = None,
) -> tuple[bytes, X25519PrivateKey, X25519PublicKey]:
"""Initiator side of X3DH.
Args:
ik_private_ed: Our Ed25519 identity private key
ik_public_remote_ed: Remote Ed25519 identity public key
spk_remote: Remote signed pre-key (X25519 public)
spk_signature: Ed25519 signature of spk_remote by ik_public_remote_ed
opk_remote: Optional one-time pre-key (X25519 public)
Returns:
(shared_secret, ephemeral_private, ephemeral_public)
"""
# Verify SPK signature
spk_remote_bytes = serialize_x25519_public(spk_remote)
if not ed25519_verify(ik_public_remote_ed, spk_signature, spk_remote_bytes):
raise ValueError("Invalid SPK signature")
# Convert identity keys to X25519
ik_x25519_private = ed25519_private_to_x25519(ik_private_ed)
ik_x25519_remote = ed25519_public_to_x25519(ik_public_remote_ed)
# Generate ephemeral keypair
ek_priv, ek_pub = generate_x25519_keypair()
# DH computations
dh1 = x25519_dh(ik_x25519_private, spk_remote) # IK_A, SPK_B
dh2 = x25519_dh(ek_priv, ik_x25519_remote) # EK_A, IK_B
dh3 = x25519_dh(ek_priv, spk_remote) # EK_A, SPK_B
dh_concat = dh1 + dh2 + dh3
if opk_remote is not None:
dh4 = x25519_dh(ek_priv, opk_remote) # EK_A, OPK_B
dh_concat += dh4
# Derive shared secret
shared_secret = hkdf_derive(dh_concat, salt=b"\x00" * 32, info=_X3DH_INFO, length=32)
return shared_secret, ek_priv, ek_pub
def x3dh_respond(
ik_private_ed: Ed25519PrivateKey,
spk_private: X25519PrivateKey,
ik_remote_ed: Ed25519PublicKey,
ek_remote: X25519PublicKey,
opk_private: X25519PrivateKey | None = None,
) -> bytes:
"""Responder side of X3DH.
Args:
ik_private_ed: Our Ed25519 identity private key
spk_private: Our signed pre-key private (X25519)
ik_remote_ed: Remote Ed25519 identity public key
ek_remote: Remote ephemeral key (X25519 public)
opk_private: Our one-time pre-key private (X25519), if used
Returns:
shared_secret (32 bytes)
"""
ik_x25519_private = ed25519_private_to_x25519(ik_private_ed)
ik_x25519_remote = ed25519_public_to_x25519(ik_remote_ed)
dh1 = x25519_dh(spk_private, ik_x25519_remote) # SPK_B, IK_A
dh2 = x25519_dh(ik_x25519_private, ek_remote) # IK_B, EK_A
dh3 = x25519_dh(spk_private, ek_remote) # SPK_B, EK_A
dh_concat = dh1 + dh2 + dh3
if opk_private is not None:
dh4 = x25519_dh(opk_private, ek_remote) # OPK_B, EK_A
dh_concat += dh4
shared_secret = hkdf_derive(dh_concat, salt=b"\x00" * 32, info=_X3DH_INFO, length=32)
return shared_secret
# ---------------------------------------------------------------------------
# Double Ratchet
# ---------------------------------------------------------------------------
MAX_SKIP = 256 # max messages to skip in a single chain (out-of-order tolerance)
@dataclass
class RatchetHeader:
"""Header sent with each ratchet message."""
dh_pub: bytes # sender's current ratchet public key (32 bytes)
n: int # message number in current sending chain
pn: int # number of messages in previous sending chain
def serialize(self) -> bytes:
return json.dumps({
"dh_pub": serialize_x25519_public(load_x25519_public(self.dh_pub)).hex()
if isinstance(self.dh_pub, bytes) else serialize_x25519_public(self.dh_pub).hex(),
"n": self.n,
"pn": self.pn,
}).encode()
def to_dict(self) -> dict:
pub_hex = self.dh_pub.hex() if isinstance(self.dh_pub, bytes) else \
serialize_x25519_public(self.dh_pub).hex()
return {"dh_pub": pub_hex, "n": self.n, "pn": self.pn}
@classmethod
def from_dict(cls, d: dict) -> "RatchetHeader":
return cls(dh_pub=bytes.fromhex(d["dh_pub"]), n=d["n"], pn=d["pn"])
class DoubleRatchet:
"""Signal Double Ratchet implementation."""
def __init__(self):
self.dh_pair: tuple[X25519PrivateKey, X25519PublicKey] | None = None
self.dh_remote: X25519PublicKey | None = None
self.root_key: bytes = b""
self.send_chain_key: bytes | None = None
self.recv_chain_key: bytes | None = None
self.send_n: int = 0
self.recv_n: int = 0
self.prev_send_n: int = 0
# (dh_pub_hex, n) -> message_key for out-of-order messages
self.skipped: dict[tuple[str, int], bytes] = {}
@classmethod
def init_alice(cls, shared_secret: bytes, bob_spk_pub: X25519PublicKey) -> "DoubleRatchet":
"""Initialize as initiator (Alice) after X3DH.
Alice performs the first DH ratchet step immediately.
"""
ratchet = cls()
ratchet.dh_pair = generate_x25519_keypair()
ratchet.dh_remote = bob_spk_pub
# Perform DH ratchet to derive send chain
dh_output = x25519_dh(ratchet.dh_pair[0], ratchet.dh_remote)
ratchet.root_key, ratchet.send_chain_key = kdf_rk(shared_secret, dh_output)
ratchet.recv_chain_key = None
ratchet.send_n = 0
ratchet.recv_n = 0
ratchet.prev_send_n = 0
return ratchet
@classmethod
def init_bob(cls, shared_secret: bytes, spk_pair: tuple[X25519PrivateKey, X25519PublicKey]) -> "DoubleRatchet":
"""Initialize as responder (Bob) after X3DH.
Bob uses his SPK as the initial ratchet key pair.
"""
ratchet = cls()
ratchet.dh_pair = spk_pair
ratchet.root_key = shared_secret
ratchet.send_chain_key = None
ratchet.recv_chain_key = None
ratchet.send_n = 0
ratchet.recv_n = 0
ratchet.prev_send_n = 0
return ratchet
def encrypt(self, plaintext: bytes) -> dict:
"""Encrypt a message.
Returns {header: {dh_pub, n, pn}, ciphertext: bytes, nonce: bytes}.
"""
if self.send_chain_key is None:
raise RuntimeError("Send chain not initialized")
self.send_chain_key, message_key = kdf_ck(self.send_chain_key)
header = RatchetHeader(
dh_pub=serialize_x25519_public(self.dh_pair[1]),
n=self.send_n,
pn=self.prev_send_n,
)
# Encrypt with AES-256-GCM using the message key
nonce = os.urandom(12)
aesgcm = AESGCM(message_key)
# Include header as AAD to bind ciphertext to header
aad = header.serialize()
ct_with_tag = aesgcm.encrypt(nonce, plaintext, aad)
self.send_n += 1
return {
"header": header.to_dict(),
"ciphertext": ct_with_tag, # includes 16-byte tag
"nonce": nonce,
}
def decrypt(self, header_dict: dict, ciphertext: bytes, nonce: bytes) -> bytes:
"""Decrypt a message. Handles DH ratchet step if new dh_pub.
State is snapshotted before modification and restored on failure (M9 fix).
"""
header = RatchetHeader.from_dict(header_dict)
remote_dh_pub_bytes = header.dh_pub
# Check if this is from a skipped message (no state modification needed)
skip_key = (remote_dh_pub_bytes.hex(), header.n)
if skip_key in self.skipped:
mk = self.skipped.pop(skip_key)
aad = header.serialize()
aesgcm = AESGCM(mk)
try:
return aesgcm.decrypt(nonce, ciphertext, aad)
except Exception:
self.skipped[skip_key] = mk # restore skipped key
raise
# Snapshot state before modifications
snap = self._snapshot()
try:
remote_dh_pub = load_x25519_public(remote_dh_pub_bytes)
current_remote_bytes = serialize_x25519_public(self.dh_remote) if self.dh_remote else None
if current_remote_bytes is None or remote_dh_pub_bytes != current_remote_bytes:
# New DH ratchet step
self._skip_messages(header.pn)
self._dh_ratchet(remote_dh_pub)
self._skip_messages(header.n)
# Derive message key from receive chain
self.recv_chain_key, mk = kdf_ck(self.recv_chain_key)
self.recv_n += 1
aad = header.serialize()
aesgcm = AESGCM(mk)
return aesgcm.decrypt(nonce, ciphertext, aad)
except Exception:
self._restore(snap)
raise
def _snapshot(self) -> dict:
"""Capture mutable state for rollback on decrypt failure."""
return {
"dh_pair": self.dh_pair,
"dh_remote": self.dh_remote,
"root_key": self.root_key,
"send_chain_key": self.send_chain_key,
"recv_chain_key": self.recv_chain_key,
"send_n": self.send_n,
"recv_n": self.recv_n,
"prev_send_n": self.prev_send_n,
"skipped": dict(self.skipped),
}
def _restore(self, snap: dict):
"""Restore state from snapshot."""
self.dh_pair = snap["dh_pair"]
self.dh_remote = snap["dh_remote"]
self.root_key = snap["root_key"]
self.send_chain_key = snap["send_chain_key"]
self.recv_chain_key = snap["recv_chain_key"]
self.send_n = snap["send_n"]
self.recv_n = snap["recv_n"]
self.prev_send_n = snap["prev_send_n"]
self.skipped = snap["skipped"]
def _skip_messages(self, until: int):
"""Skip ahead in the receive chain, storing message keys for out-of-order delivery."""
if self.recv_chain_key is None:
return
if until - self.recv_n > MAX_SKIP:
raise RuntimeError(f"Too many skipped messages ({until - self.recv_n} > {MAX_SKIP})")
while self.recv_n < until:
self.recv_chain_key, mk = kdf_ck(self.recv_chain_key)
remote_hex = serialize_x25519_public(self.dh_remote).hex() if self.dh_remote else ""
self.skipped[(remote_hex, self.recv_n)] = mk
self.recv_n += 1
def _dh_ratchet(self, remote_dh_pub: X25519PublicKey):
"""Perform a DH ratchet step: update receive chain, generate new DH pair, update send chain."""
self.prev_send_n = self.send_n
self.send_n = 0
self.recv_n = 0
self.dh_remote = remote_dh_pub
# Derive new receive chain key
dh_output = x25519_dh(self.dh_pair[0], self.dh_remote)
self.root_key, self.recv_chain_key = kdf_rk(self.root_key, dh_output)
# Generate new DH pair and derive new send chain key
self.dh_pair = generate_x25519_keypair()
dh_output = x25519_dh(self.dh_pair[0], self.dh_remote)
self.root_key, self.send_chain_key = kdf_rk(self.root_key, dh_output)
def export_state(self) -> bytes:
"""Serialize full ratchet state for persistent storage."""
state = {
"dh_priv": serialize_x25519_private(self.dh_pair[0]).hex() if self.dh_pair else None,
"dh_pub": serialize_x25519_public(self.dh_pair[1]).hex() if self.dh_pair else None,
"dh_remote": serialize_x25519_public(self.dh_remote).hex() if self.dh_remote else None,
"root_key": self.root_key.hex(),
"send_ck": self.send_chain_key.hex() if self.send_chain_key else None,
"recv_ck": self.recv_chain_key.hex() if self.recv_chain_key else None,
"send_n": self.send_n,
"recv_n": self.recv_n,
"prev_send_n": self.prev_send_n,
"skipped": {f"{k[0]}:{k[1]}": v.hex() for k, v in self.skipped.items()},
}
return json.dumps(state).encode()
@classmethod
def import_state(cls, data: bytes) -> "DoubleRatchet":
"""Deserialize ratchet state."""
state = json.loads(data)
r = cls()
if state["dh_priv"] and state["dh_pub"]:
priv = load_x25519_private(bytes.fromhex(state["dh_priv"]))
pub = load_x25519_public(bytes.fromhex(state["dh_pub"]))
r.dh_pair = (priv, pub)
if state["dh_remote"]:
r.dh_remote = load_x25519_public(bytes.fromhex(state["dh_remote"]))
r.root_key = bytes.fromhex(state["root_key"])
r.send_chain_key = bytes.fromhex(state["send_ck"]) if state["send_ck"] else None
r.recv_chain_key = bytes.fromhex(state["recv_ck"]) if state["recv_ck"] else None
r.send_n = state["send_n"]
r.recv_n = state["recv_n"]
r.prev_send_n = state["prev_send_n"]
r.skipped = {}
for k_str, v_hex in state.get("skipped", {}).items():
parts = k_str.rsplit(":", 1)
dh_hex = parts[0]
n = int(parts[1])
r.skipped[(dh_hex, n)] = bytes.fromhex(v_hex)
return r
# ---------------------------------------------------------------------------
# Sender Keys (group messaging)
# ---------------------------------------------------------------------------
class SenderKeyState:
"""Sender key chain for group messaging.
Each sender in a group has their own sender key chain.
Other group members receive the initial sender_key via pairwise Double Ratchet.
"""
def __init__(self, sender_key: bytes | None = None):
if sender_key is None:
sender_key = os.urandom(32)
self.sender_key = sender_key
self.chain_id = hashlib.sha256(sender_key).digest()
self.chain_key = hkdf_derive(sender_key, salt=b"\x00" * 32, info=b"SenderKeyChain", length=32)
self.n = 0
# For receivers: track chain state to allow fast-forward
self._known_keys: dict[int, bytes] = {}
def encrypt(self, plaintext: bytes) -> dict:
"""Encrypt with current chain key.
Returns {chain_id: hex, n: int, ciphertext: bytes, nonce: bytes}.
"""
self.chain_key, message_key = kdf_ck(self.chain_key)
nonce = os.urandom(12)
aesgcm = AESGCM(message_key)
# AAD includes chain_id and message number
aad = self.chain_id + struct.pack(">I", self.n)
ct_with_tag = aesgcm.encrypt(nonce, plaintext, aad)
result = {
"chain_id": self.chain_id.hex(),
"n": self.n,
"ciphertext": ct_with_tag,
"nonce": nonce,
}
self.n += 1
return result
MAX_SENDER_KEY_SKIP = 256
def decrypt(self, chain_id_hex: str, n: int, ciphertext: bytes, nonce: bytes) -> bytes:
"""Decrypt a group message. Fast-forwards the chain if needed.
State is snapshotted before modification and restored on failure (M9 fix).
"""
chain_id = bytes.fromhex(chain_id_hex)
if chain_id != self.chain_id:
raise ValueError("Chain ID mismatch")
if n - self.n > self.MAX_SENDER_KEY_SKIP:
raise ValueError(f"Sender key skip too large ({n - self.n} > {self.MAX_SENDER_KEY_SKIP})")
# Snapshot before fast-forward
snap_chain_key = self.chain_key
snap_n = self.n
snap_known = dict(self._known_keys)
try:
# Fast-forward the chain to reach message n
while self.n <= n:
self.chain_key, mk = kdf_ck(self.chain_key)
self._known_keys[self.n] = mk
self.n += 1
mk = self._known_keys.pop(n, None)
if mk is None:
raise ValueError(f"Message key for n={n} not available (already consumed)")
aad = chain_id + struct.pack(">I", n)
aesgcm = AESGCM(mk)
return aesgcm.decrypt(nonce, ciphertext, aad)
except Exception:
self.chain_key = snap_chain_key
self.n = snap_n
self._known_keys = snap_known
raise
def export_key(self) -> bytes:
"""Export sender key for distribution to group members.
Contains everything needed to initialize a receiving SenderKeyState.
"""
return json.dumps({
"sender_key": self.sender_key.hex(),
}).encode()
def export_state(self) -> bytes:
"""Serialize full state for persistent storage."""
return json.dumps({
"sender_key": self.sender_key.hex(),
"chain_id": self.chain_id.hex(),
"chain_key": self.chain_key.hex(),
"n": self.n,
"known_keys": {str(k): v.hex() for k, v in self._known_keys.items()},
}).encode()
@classmethod
def import_state(cls, data: bytes) -> "SenderKeyState":
state = json.loads(data)
obj = cls.__new__(cls)
obj.sender_key = bytes.fromhex(state["sender_key"])
obj.chain_id = bytes.fromhex(state["chain_id"])
obj.chain_key = bytes.fromhex(state["chain_key"])
obj.n = state["n"]
obj._known_keys = {int(k): bytes.fromhex(v) for k, v in state.get("known_keys", {}).items()}
return obj
@classmethod
def from_key(cls, exported_key: bytes) -> "SenderKeyState":
"""Initialize a receiving SenderKeyState from an exported key."""
data = json.loads(exported_key)
return cls(sender_key=bytes.fromhex(data["sender_key"]))

1293
zaloha/db.py Normal file

File diff suppressed because it is too large Load Diff

3335
zaloha/gui_client.py Normal file

File diff suppressed because it is too large Load Diff

125
zaloha/protocol.py Normal file
View File

@@ -0,0 +1,125 @@
"""Newline-delimited JSON protocol with base64 encoding for binary data."""
import asyncio
import base64
import binascii
import json
import os
def encode_binary(data: bytes) -> str:
"""Encode bytes to base64 string."""
return base64.b64encode(data).decode("ascii")
def decode_binary(data: str) -> bytes:
"""Decode base64 string to bytes."""
try:
return base64.b64decode(data)
except (TypeError, binascii.Error) as e:
raise ValueError(f"Invalid base64: {e}")
VERSION = "0.8.2"
MIN_CLIENT_VERSION = "0.8" # server rejects clients older than this
def version_gte(version: str, minimum: str) -> bool:
"""Return True if version >= minimum (compares numeric tuples, e.g. '0.8.1' >= '0.8')."""
def _parse(v: str) -> tuple[int, ...]:
try:
return tuple(int(x) for x in v.split("."))
except (ValueError, AttributeError):
return (0,)
return _parse(version) >= _parse(minimum)
MAX_MESSAGE_BYTES = int(os.getenv("MAX_MESSAGE_BYTES", "65536")) # 64 KiB default
MAX_IMAGE_BYTES = int(os.getenv("MAX_IMAGE_BYTES", str(5 * 1024 * 1024))) # 5 MiB default, 0 = no limit
MAX_FILE_BYTES = int(os.getenv("MAX_FILE_BYTES", str(50 * 1024 * 1024))) # 50 MiB default
IMAGE_CHUNK_SIZE = 32768 # 32 KiB raw chunk size for image upload/download
def build_request(msg_type: str, request_id: str | None = None, **kwargs) -> bytes:
"""Build a protocol message (newline-terminated JSON)."""
msg = {"type": msg_type, **kwargs}
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def build_response(
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
) -> bytes:
"""Build a server response."""
msg = {"type": msg_type, "status": status}
if data is not None:
msg["data"] = data
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def parse_message(line: bytes) -> dict:
"""Parse a single protocol message from bytes."""
try:
return json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid message: {e}")
class ProtocolReader:
"""Read newline-delimited JSON messages from an asyncio StreamReader."""
def __init__(self, reader: asyncio.StreamReader):
self._reader = reader
async def read_message(self) -> dict | None:
"""Read and parse one message. Returns None on EOF."""
try:
line = await self._reader.readuntil(b"\n")
except (asyncio.IncompleteReadError, ConnectionError):
return None
except asyncio.LimitOverrunError:
# Message exceeded limit — drain the internal buffer and signal error
self._reader._buffer.clear()
self._reader._maybe_resume_transport()
raise ValueError("Message exceeds maximum size")
if not line:
return None
return parse_message(line.strip())
class ProtocolWriter:
"""Write newline-delimited JSON messages to an asyncio StreamWriter."""
def __init__(self, writer: asyncio.StreamWriter):
self._writer = writer
async def send_request(self, msg_type: str, request_id: str | None = None, **kwargs):
"""Send a request message."""
payload = build_request(msg_type, request_id=request_id, **kwargs)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
async def send_response(
self,
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
):
"""Send a response message."""
payload = build_response(msg_type, status, data, request_id=request_id)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
def close(self):
self._writer.close()

7
zaloha/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
cryptography>=42.0.0
mysql-connector-python>=8.3.0
python-dotenv>=1.0.0
# GUI client (optional, needed for gui_client.py)
PyQt6>=6.6.0
# Image sharing (optional, needed for send_image feature)
Pillow>=10.0.0

158
zaloha/schema.sql Normal file
View File

@@ -0,0 +1,158 @@
CREATE DATABASE IF NOT EXISTS encrypted_chat
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE encrypted_chat;
-- Users: identity_key is Ed25519 (32B), rsa_public_key for login challenge only
CREATE TABLE IF NOT EXISTS users (
id CHAR(36) NOT NULL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
rsa_public_key TEXT NOT NULL,
identity_key BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- Devices: each user can have multiple devices
CREATE TABLE IF NOT EXISTS devices (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
device_name VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_devices_user (user_id)
) ENGINE=InnoDB;
-- Signed Pre-Keys (X25519, signed by Ed25519 identity key) — per device
CREATE TABLE IF NOT EXISTS signed_prekeys (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
device_id CHAR(36) DEFAULT NULL,
public_key BLOB NOT NULL,
signature BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_spk_user_device (user_id, device_id)
) ENGINE=InnoDB;
-- One-Time Pre-Keys (consumed on use) — per device
CREATE TABLE IF NOT EXISTS one_time_prekeys (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
device_id CHAR(36) DEFAULT NULL,
public_key BLOB NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_opk_user_device (user_id, device_id)
) ENGINE=InnoDB;
-- Conversations
CREATE TABLE IF NOT EXISTS conversations (
id CHAR(36) NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(255) DEFAULT NULL,
created_by CHAR(36) DEFAULT NULL,
avatar_file VARCHAR(255) DEFAULT NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
joined_at DATETIME NULL,
PRIMARY KEY (conversation_id, user_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Group invitations (pending invitations to join a group)
CREATE TABLE IF NOT EXISTS group_invitations (
id CHAR(36) NOT NULL PRIMARY KEY,
conversation_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
invited_by CHAR(36) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_conv_user (conversation_id, user_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Messages: per-recipient ciphertext (Double Ratchet = each recipient has different ciphertext)
CREATE TABLE IF NOT EXISTS messages (
id CHAR(36) NOT NULL PRIMARY KEY,
conversation_id CHAR(36) NOT NULL,
sender_id CHAR(36) NOT NULL,
sender_device_id CHAR(36) DEFAULT NULL,
ratchet_header BLOB NOT NULL,
x3dh_header BLOB DEFAULT NULL,
sender_chain_id BLOB DEFAULT NULL,
sender_chain_n INT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
image_file_id CHAR(36) DEFAULT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_messages_conv_created (conversation_id, created_at)
) ENGINE=InnoDB;
-- Per-recipient encrypted content — per device
-- device_id '00000000-0000-0000-0000-000000000000' = self-encrypted / legacy
CREATE TABLE IF NOT EXISTS message_recipients (
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
device_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
encrypted_content BLOB NOT NULL,
nonce BLOB NOT NULL,
ratchet_header BLOB DEFAULT NULL,
x3dh_header BLOB DEFAULT NULL,
PRIMARY KEY (message_id, user_id, device_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Sender Keys for groups (distributed via pairwise ratchet) — per device
CREATE TABLE IF NOT EXISTS group_sender_keys (
conversation_id CHAR(36) NOT NULL,
sender_id CHAR(36) NOT NULL,
device_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
chain_id BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (conversation_id, sender_id, device_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Read receipts
CREATE TABLE IF NOT EXISTS message_reads (
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (message_id, user_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- User profiles
CREATE TABLE IF NOT EXISTS user_profiles (
user_id CHAR(36) NOT NULL PRIMARY KEY,
phone VARCHAR(50) DEFAULT NULL,
phone_visible TINYINT(1) NOT NULL DEFAULT 0,
email_visible TINYINT(1) NOT NULL DEFAULT 1,
location VARCHAR(255) DEFAULT NULL,
location_visible TINYINT(1) NOT NULL DEFAULT 0,
avatar_file VARCHAR(255) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Image uploads
CREATE TABLE IF NOT EXISTS image_uploads (
file_id CHAR(36) NOT NULL PRIMARY KEY,
conversation_id CHAR(36) NOT NULL,
uploader_id CHAR(36) NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (uploader_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

2053
zaloha/server.py Normal file

File diff suppressed because it is too large Load Diff