initial commit
This commit is contained in:
9
zaloha/.gitignore
vendored
Normal file
9
zaloha/.gitignore
vendored
Normal 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
758
zaloha/CLAUDE.md
Normal 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
314
zaloha/README.md
Normal 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
2609
zaloha/chat_core.py
Normal file
File diff suppressed because it is too large
Load Diff
636
zaloha/client.py
Normal file
636
zaloha/client.py
Normal 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
812
zaloha/crypto_utils.py
Normal 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
1293
zaloha/db.py
Normal file
File diff suppressed because it is too large
Load Diff
3335
zaloha/gui_client.py
Normal file
3335
zaloha/gui_client.py
Normal file
File diff suppressed because it is too large
Load Diff
125
zaloha/protocol.py
Normal file
125
zaloha/protocol.py
Normal 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
7
zaloha/requirements.txt
Normal 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
158
zaloha/schema.sql
Normal 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
2053
zaloha/server.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user