initial commit

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

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(python3:*)",
"Bash(ls:*)",
"Bash(pip3 show:*)",
"Bash(.venv/bin/python3:*)",
"Bash(python:*)",
"Bash(wc:*)",
"Bash(grep:*)",
"Bash(chmod:*)",
"Bash(find:*)",
"Bash(fc-list:*)",
"Bash(sudo ls:*)",
"Bash(mkdir:*)"
]
}
}

19
.env Normal file
View File

@@ -0,0 +1,19 @@
MYSQL_HOST=192.168.1.112
MYSQL_PORT=3306
MYSQL_USER=sifrator
MYSQL_PASSWORD=Brouk100+1
MYSQL_DATABASE=encrypted_chat
#SERVER_HOST=192.168.88.65
SERVER_HOST=0.0.0.0
SERVER_PORT=9999
TLS_ENABLED=true
TLS_CERT_FILE=/home/filip/encrypted_chat/certs/fullchain.pem
TLS_KEY_FILE=/home/filip/encrypted_chat/certs/privkey.pem
SMTP_HOST=smtp.protonmail.ch
SMTP_PORT=587
SMTP_USER=cryptedchat@dw-technics.com
SMTP_PASS=DBL5GKTJA28KQRZF
SMTP_FROM=cryptedchat@dw-technics.com

12
.gitignore vendored
View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -174,3 +175,14 @@ cython_debug/
# PyPI configuration file
.pypirc
=======
__pycache__/
*.pyc
#.env
#.env.*
.encrypted_chat/
certs/*
!certs/*.sh
!certs/*.example
!certs/README.md
>>>>>>> d506e65 (initial commit)

1041
CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

341
README.md
View File

@@ -1,2 +1,343 @@
<<<<<<< HEAD
# Kecalek_python
=======
# 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 (šifrovaný AES-256-GCM)
spk_id.txt
prev_spk_private.bin # Předchozí SPK, grace period (šifrovaný AES-256-GCM)
prev_spk_id.txt
opk_private/ # One-time prekeys (šifrované AES-256-GCM)
{opk_id}.bin
login_lockout.json # Brute-force lockout stav (failed_attempts, locked_until)
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.
### Šifrování SPK/OPK na disku
SPK a OPK privátní klíče jsou šifrované AES-256-GCM klíčem `_local_key` (HKDF z Ed25519 identity key):
- Při save: `_encrypt_local(raw, local_key)``nonce(12B) + tag(16B) + ciphertext`
- Při load: `_decrypt_local()` s transparentní migrací — pokud dešifrování selže, načte jako plaintext a uloží šifrovaně
- Aplikováno na `spk_private.bin`, `prev_spk_private.bin`, `opk_private/*.bin`
### Brute-force ochrana (client-side lockout)
Po chybném zadání hesla se prodlužuje čas do dalšího pokusu:
- **Vzorec:** `min(2^N, 300)` sekund, kde N = počet neúspěšných pokusů (2s, 4s, 8s, ... až 5 min)
- **Stav:** `login_lockout.json` v adresáři klíčů (`failed_attempts`, `locked_until`)
- **Aplikováno na:** `ChatClient.login()` (síťový login) + GUI privacy overlay unlock (`_on_unlock_attempt`)
- **Reset:** Úspěšné přihlášení smaže lockout soubor
- **Defense-in-depth:** Smazání souboru resetuje počítadlo, ale PBKDF2-600k stále zpomaluje každý pokus (~0.5s/pokus)
### 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
### Monetizace
Oddělený platební server (Stripe, KYC/AML compliant) od chat serveru (anonymní). Platba → premium kód → aktivace na chat serveru. Žádný přímý link platba↔chat identita.
- **Free tier:** 5 konverzací, 10 MB soubory, 1 zařízení, 30 dní retence, max 10 členů/skupina
- **Premium:** neomezeno — aktivace jednorázovým kódem z platebního serveru
- **Enterprise:** self-hosted, LDAP/SSO, admin dashboard, SLA — faktura na firmu
- Detaily implementace viz `CLAUDE.md`
### 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
- [x] **SPK/OPK šifrování + brute-force lockout** — všechny privátní klíče na disku šifrované (ECP1 nebo AES-256-GCM), exponenciální backoff po chybném hesle (2^N s, max 5 min)
### 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`.
>>>>>>> d506e65 (initial commit)

363
SECURITY_AUDIT.md Normal file
View File

@@ -0,0 +1,363 @@
# Security Audit (Encrypted Chat)
Aktualizace: 2026-03-08
Scope: `server.py`, `db.py`, `chat_core.py`, `gui_client.py`, `client.py`, `protocol.py`, `schema.sql`, `.env`, markdown dokumentace.
Metodika: statický audit kódu + konfigurace. Nebyl proveden aktivní penetrační test ani fuzzing.
## Executive Summary
Nejzávažnější aktuálně otevřené nálezy:
- Plaintext DB heslo v `.env` souborech (C3).
- Chybějící TLS mezi aplikací a MySQL (H1).
- Slabá oprávnění upload/avatary souborů na disku (H2).
- DoS přes neomezené `pending_registrations` (H6).
## CRITICAL
### ~~C1. TOFU / verifikace identity klíče se obchází při běžném X3DH flow~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- TOFU kontrola existuje jen v `_get_user_info()` (`chat_core.py:799-803`).
- Při navazování session (`_get_or_create_session`) se `identity_key` z bundle bere přímo bez TOFU kontroly (`chat_core.py:1497-1501`, `chat_core.py:1534-1538`).
- U příchozího X3DH (`_process_x3dh_header`) se remote IK také uloží bez TOFU kontroly (`chat_core.py:1551-1553`, `chat_core.py:1580-1584`).
**Dopad**
- Pokud server nebo MITM podstrčí jiný identity key, klient může navázat session bez varování.
- Prakticky to obchází uživatelskou verifikaci kontaktu ve výchozím messaging flow.
**Oprava**
- Nová výjimka `IdentityKeyChanged(user_id, new_key_bytes, status)` v `chat_core.py` — hard-fail při změně identity klíče.
- `_get_or_create_session()`: TOFU check přes `check_identity_key()` před X3DH initiate. Při `changed`/`changed_verified` vyhodí `IdentityKeyChanged` — session se nenaváže.
- `_process_x3dh_header()`: TOFU check před X3DH respond. Stejný hard-fail — příchozí zpráva s podvrženým klíčem je odmítnuta.
- GUI: `IdentityKeyChanged` zachycena v notification loopu (emituje `key_change_warning` signál místo pádu loopu) a v `_do_send_message` (zobrazí error + warning dialog).
- Session je blokována dokud uživatel explicitně neakceptuje key change přes `accept_key_change()`.
- `decrypt_notification()`: explicitní `except IdentityKeyChanged: raise` před generickým `except Exception` — výjimka se propaguje do notification loopu místo tichého spolknutí.
- `key_change_warning` signál rozšířen o 5. parametr `new_key_bytes: bytes` — "Accept New Key" dialog předává nový klíč přímo z výjimky, ne z cache (která mohla obsahovat starý klíč).
- `IdentityKeyChanged` ošetřena ve všech GUI send cestách: `_do_send_image`, `_do_send_file`, `_do_forward_message`, `_do_find_or_create_and_send` — zobrazí warning dialog + error message.
- CLI (`client.py`): `IdentityKeyChanged` ošetřena ve všech 6 send cestách (send_message ×3, send_image, send_file, forward_message).
---
### ~~C2. Perzistentní DoS konverzace přes nevalidní message headers~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Server přijímá `ratchet_header` / `x3dh_header` i jako raw `str/bytes` bez JSON schema validace (`server.py:1105-1112`, `server.py:1146-1151`).
- Při `get_messages` se hodnoty bez ochrany parsují `json.loads(...)` (`server.py:1266`, `server.py:1274`).
**Dopad**
- Útočník v konverzaci může uložit “poisoned” hlavičku a rozbít načtení historie ostatním členům (`Internal server error`).
- Chyba je perzistentní, dokud je vadná zpráva v historii.
**Oprava**
- Nový helper `_validate_header(raw, name)` v `server.py` — přijímá pouze `dict`, odmítá `str`/`bytes`, limit 4096 bajtů.
- `handle_send_message`: message-level i per-recipient headers procházejí `_validate_header()`. Nevalidní hlavička → error response, zpráva se neuloží.
- `handle_get_messages`: `json.loads()` obaleno `try/except` (JSONDecodeError, TypeError, UnicodeDecodeError). Corrupted header → prázdný dict `{}` + warning log, ostatní zprávy se načtou normálně.
- `_validate_header()` rozšířena o validaci očekávaných klíčů a typů pro ratchet headers (`dh_pub`: str, `n`: int, `pn`: int) a používá striktní kontrolu typu pro `n/pn` (`type(...) is int`) — `bool` je explicitně odmítnut.
- Realtime push notifikace nyní čtou data z validovaných `db_recipients` (ne z `recipients_raw`). Per-recipient hlavičky se dekódují z validovaných bytes zpět do `dict` pro JSON notifikaci.
- `encrypted_content` a `nonce` v push notifikacích se skládají z validovaných raw bytes a serializují se přes `encode_binary()` — untrusted hodnoty z raw requestu se do push větve nepropíší.
---
### C3. Plaintext tajemství v `.env` a `zaloha/.env`
**Evidence**
- `.env` obsahuje `MYSQL_PASSWORD` (`.env:4`).
- `zaloha/.env` obsahuje `MYSQL_PASSWORD` (`zaloha/.env:4`).
**Dopad**
- Únik souboru = okamžitý přístup do DB.
- Riziko přes backupy, sdílení projektu, malware, CI artefakty.
**Doporučení**
1. Okamžitě rotovat DB heslo.
2. Nahradit repozitářové `.env` šablonou (`.env.example`) bez tajemství.
3. Použít secrets manager / deployment-level secret injection.
## HIGH
### H1. Chybí TLS mezi aplikací a MySQL
**Evidence**
- `MySQLConnectionPool` je bez `ssl_ca`/`ssl_verify_cert` parametrů (`db.py:35-44`).
- Konfigurace používá síťovou DB (`MYSQL_HOST=192.168.1.112`, `.env:1`).
**Dopad**
- Odposlech nebo MITM na trase app<->DB může odhalit credentials i data.
**Doporučení**
1. Zapnout MySQL TLS na serveru.
2. Vynutit TLS verifikaci certifikátu v `db.py`.
---
### H2. Upload/avatary na disku mají slabá oprávnění
**Evidence**
- Upload soubory jsou vytvářeny bez explicitního `chmod` na file (`server.py:1732`, `server.py:1806`, `server.py:1909`, `server.py:1969`).
- V prostředí auditu: `uploads` a `uploads/avatars` mají `775`, soubory typicky `664`.
**Dopad**
- Lokální uživatelé na stejném hostu mohou číst citlivá data (včetně avatarů v plaintextu).
**Doporučení**
1. Nastavit adresáře `0700`.
2. Po zápisu každého souboru nastavit `0600`.
3. Upload storage přesunout mimo project tree.
---
### ~~H3. `session_reset` nemá autorizační vazbu na vztah mezi uživateli~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Handler přijme libovolné validní `peer_user_id` a pošle notifikaci (`server.py:2040-2052`).
- Neověřuje, že uživatelé sdílí konverzaci nebo existuje session.
**Dopad**
- Možnost spam/DoS reset notifikací na cílové uživatele.
**Oprava**
- Nová DB funkce `db.shares_conversation(user_id_a, user_id_b)``SELECT 1 ... LIMIT 1` přes `conversation_members` JOIN.
- `handle_session_reset`: před push notifikací ověřuje `shares_conversation()`. Pokud uživatelé nesdílí žádnou konverzaci → error response.
- Rate limit 5 požadavků/min na `session_reset` per user (`session_reset|{user_id}`) — IP adresa není součást klíče, takže změnou IP nejde limit obejít.
- Pokud je předán `peer_device_id`, reset notifikace se doručí pouze cílovému zařízení (filtr přes `writer_device_map`). Bez `peer_device_id` zůstává broadcast na všechna zařízení peera.
---
### ~~H4. User enumeration přes pairing a user-info endpointy~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `pairing_start` vrací explicitně `User not found` (`server.py:763-766`).
- `get_user_info` vrací metadata uživatele při lookupu přes email/user_id (`server.py:551-564`).
**Dopad**
- Snadné mapování existence účtů.
**Oprava**
- `handle_pairing_start`: vždy vrací `ok` s platně vypadajícím kódem a session se vytvoří vždy (i pro neexistující email), takže `pairing_poll` vrací nerozlišitelné `ready: false`.
- Přidán globální cap `PAIRING_MAX_SESSIONS = 100` pro omezení počtu současných pairing sessions (DoS hardening).
- `pairing_start` rate limit je per-IP (bez email komponenty), aby nešel obcházet rotací emailů.
- `pairing_claim` i `pairing_send`: sjednocená chyba `Invalid or expired code` (žádné rozlišení "neexistuje" vs "patří jinému účtu").
- V pairing flow se síťové I/O (`send_resp`) volá až po uvolnění `_pairing_lock`.
- `handle_get_user_info`: přidán parametr `session` (vyžaduje login). Lookupovat lze jen sebe nebo kontakty (ověřeno přes `shares_conversation()`). Pro neexistující i nepovolené cíle vrací neutrální "User not found".
- Doplňuje dřívější anti-enumeration opravy: `register_start` (generická odpověď), `login_start` (fake challenge), `login_finish` (generická chyba).
---
### ~~H5. Phantom user inflation přes `create_conversation` / `find_conversation` / `add_member` (DoS)~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `create_conversation` vytváří phantom účty pro neznámé emaily bez dedikovaného rate limitu (`server.py:906-920`).
- `find_conversation` a `add_member` rate limitují přes `_rate_limit_key(..., addr, email)`, takže rotace emailů obchází limit (`server.py:972`, `server.py:1001`, `server.py:209-212`).
- `create_phantom_user()` pro každý nový email generuje IK+SPK+OTP a zapisuje více řádků do DB (`db.py:1470-1507`).
**Dopad**
- Útočník může nafukovat DB a CPU náklady (kryptografická generace + zápisy), případně degradovat výkon serveru.
**Oprava**
1. `_can_create_phantom(addr, user_id)` helper kontroluje 3 limity před každým `create_phantom_user()`:
- Globální cap: `MAX_PHANTOM_USERS = 500` (počet v `phantom_user_ids` setu)
- Per-user rate: `phantom_create|{user_id}` — 10/min (email-nezávislé, neobejitelné rotací)
- Per-IP rate: `phantom_create_ip|{addr}` — 10/min (email-nezávislé)
2. `create_conversation` nově má per-user rate limit 10/min + phantom check před každým členem.
3. `find_conversation` a `add_member` — existující per-addr+email limit zůstává (brání hammering jednoho emailu), přidán `_can_create_phantom` check před vytvořením phantomu.
4. Stávající `cleanup_stale_phantoms(30)` v periodic cleanup (10 min) zajišťuje garbage collection.
---
### H6. `pending_registrations` nemá hard cap (memory/SMTP abuse)
**Evidence**
- `pending_registrations` je globální in-memory dict bez horního limitu (`server.py:56`).
- `register_start` používá rate limit klíč s emailem (`register_start|addr|email`), rotace emailů limit obchází (`server.py:341`, `server.py:209-212`).
- `register_start` ukládá novou pending registraci do dict bez capu (`server.py:373-382`).
- Periodický cleanup nevolá `_cleanup_registrations()`; expirace se spouští jen při `register_*` flow (`server.py:2486-2508`, `server.py:276-281`).
**Dopad**
- Riziko růstu paměti a SMTP abuse (masivní register_start s různými emaily).
**Doporučení**
1. Přidat `REGISTER_MAX_PENDING` cap a odmítnout nové requesty po dosažení limitu.
2. Změnit rate limit na per-IP (bez emailu) + případně per-subnet.
3. Přidat `_cleanup_registrations()` i do periodického cleanup tasku.
## MEDIUM
### ~~M1. `mark_read` a `confirm_delivery` neověřují, že `message_ids` patří do dané konverzace~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Handler validuje členství jen v `conversation_id` (`server.py:1464-1479`, `server.py:1516-1531`).
- DB insert metody pro receipts neváží `message_id` na konverzaci (`db.py:1102-1113`, `db.py:1188-1200`).
**Dopad**
- Možná manipulace read/delivery stavu cizích zpráv (integrita metadat).
**Oprava**
- `db.mark_messages_read()` a `db.mark_messages_delivered()` nahrazeny z per-row `INSERT IGNORE` na batch `INSERT IGNORE ... SELECT m.id, %s FROM messages m WHERE m.id IN (...) AND m.conversation_id = %s`.
- Message IDs, které nepatří do dané konverzace, jsou tiše přeskočeny (SELECT je nevrátí).
---
### M2. SMTP STARTTLS bez explicitního TLS contextu
**Evidence**
- `server.starttls()` je voláno bez `ssl.create_default_context()` (`server.py:290`).
**Dopad**
- Slabší kontrola TLS parametrů/verifikace dle runtime prostředí.
**Doporučení**
1. Použít `server.starttls(context=ssl.create_default_context())`.
2. Přidat `EHLO` před/po STARTTLS.
---
### ~~M3. CLI klient: několik lokálních hardening mezer~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Heslo se zadává přes `input()` (echo on) (`client.py:730`, `client.py:749`, `client.py:754`).
- Zprávy se tisknou bez sanitace escape sekvencí (`client.py:491`).
- Default save path při downloadu je převzat z remote `filename` (`client.py:523-530`).
**Dopad**
- Shoulder-surfing hesla, terminal escape spoofing, riskantní defaultní save path.
**Oprava**
- Všechny password prompty (register, login, pairing, authorize device, rotate keys) nahrazeny `prompt_password()` wrapping `getpass.getpass()` — heslo se nezobrazuje na terminálu.
- `_sanitize_text()` helper stripuje control znaky (`\x00-\x1f` kromě `\t`/`\n`/`\r`) a ANSI escape sekvence. Aplikováno na `sender`, `text`, `filename` při výpisu zpráv v `_print_messages()`.
- Follow-up: `_sanitize_text()` nyní bezpečně přijímá i non-string vstupy (`None -> ""`, jinak `str(...)`), čímž se eliminuje `TypeError` při neočekávaném typu z payloadu (`client.py:32-36`).
- Follow-up: sanitace rozšířena na zbývající user-controlled CLI výpisy — seznam konverzací (`client.py:63-67`), search výsledky (`client.py:293-300`), seznam pozvánek (`client.py:612-614`), profil (`client.py:637-644`), seznam zařízení (`client.py:709-713`), verify view (`client.py:435`, `client.py:446`) a notifikace včetně reaction hodnoty (`client.py:752-774`).
- `_safe_filename()` helper: `os.path.basename()` + odstranění NUL + fallback na `"download"` pro prázdné/tečkové názvy. Aplikováno na default save path při downloadu.
---
### ~~M4. `get_key_bundle` umožňuje OPK depletion (availability)~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `handle_get_key_bundle` nemá rate limit ani authorizační vazbu na vztah mezi uživateli (`server.py:648-660`).
- DB vrstva při každém volání spotřebovává one-time prekeys (`get_key_bundles_for_user` — „Consumes one OPK per device atomically”, `db.py:394-450`).
- `target_user_id` lze získat přes `find_conversation` lookup (`server.py:966-987`).
**Dopad**
- Útočník může opakovanými dotazy vyčerpat OPK oběti, zhoršit doručitelnost a vynutit časté doplňování prekeys.
**Oprava**
1. Per-caller rate limit: `get_key_bundle|{user_id}` — 10/min (omezuje celkový počet fetchů jednoho uživatele).
2. Per-target rate limit: `get_key_bundle_target|{target_user_id}` — 20/min (omezuje rychlost vyčerpávání OPK konkrétní oběti). Autorizace probíhá před per-target RL (neautorizovaný request nespálí bucket cíle).
3. Autorizace: `shares_conversation()` — caller musí sdílet konverzaci s cílem (self-fetch povolen vždy).
4. Chybová zpráva pro neautorizovaný přístup je neutrální (`”Key bundle not available”`) — shodná s neexistujícím uživatelem.
5. **Doplňující per-user rate limity** na všechny zbývající výpočetně/DB náročné handlery (celkem 29 RL checks):
- Crypto+DB: `upload_prekeys` 5/min, `ensure_prekeys` 5/min, `rotate_keys` 3/min, `reencrypt` 10/min
- DB-heavy: `get_messages` 30/min, `delete_conv` 5/min, `delete_msg` 20/min, `react` 20/min, `remove_member` 10/min, `rename_conv` 5/min
- File I/O: `update_avatar` 5/min (sdílený bucket pro user i group avatar)
---
### ~~M5. `upload_image_start` nemá anti-DoS cap na in-flight uploady~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `upload_image_start` nevynucuje request rate limit ani limit počtu aktivních uploadů na user/IP (`server.py:1786-1823`).
- In-memory `pending_uploads` je bez explicitního capu (`server.py:58`, `server.py:1812-1819`).
- Cleanup stale uploadů běží periodicky (600s) a DB stale threshold je 3600s (`server.py:2488-2490`, `db.py:1626-1633`).
**Dopad**
- Útočník může zahájit mnoho uploadů a vytvářet dočasné soubory/záznamy, což zvyšuje memory/disk tlak.
**Oprava**
1. Per-user rate limit: `upload_start|{user_id}` — 10/min.
2. Globální cap: `MAX_UPLOADS_GLOBAL = 200` (kontrola `len(pending_uploads)` pod `_uploads_lock`).
3. Per-user cap: `MAX_UPLOADS_PER_USER = 5` (počet záznamů s `uploader_id == user_id`).
4. Stale threshold snížen z 3600s na `UPLOAD_STALE_SECONDS = 600` (10 min).
5. Periodic cleanup interval snížen z 600s na 120s (2 min).
## LOW
### ~~L1. `decode_binary` není strict base64~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `base64.b64decode(data)` bez `validate=True` (`protocol.py:18`).
**Dopad**
- Méně striktní input parsing (robustnost), ne přímý průnik.
**Oprava**
- `decode_binary()` nyní volá `base64.b64decode(data, validate=True)` — odmítá neplatné base64 znaky (whitespace, non-alphabet).
## Positive Findings
- Dev-only guardy: `TLS_INSECURE` a `TLS_AUTOGEN` jsou blokovány mimo `ENVIRONMENT=dev`.
- Server používá UUID validace v řadě handlerů.
- Upload/download ověřuje členství v konverzaci.
- Klientské private keys/storage používají PBKDF2 + AES-GCM a restriktivní perms (`0700`/`0600`) v key storage.
- Přítomný client-side lockout na opakované chybné login pokusy.
## Prioritní plán oprav
### 0-48 hodin
1. Rotace DB hesla + odstranění tajemství z `.env`.
2. ~~Oprava TOFU bypassu v obou X3DH cestách.~~ ✅ DONE
3. ~~Zablokování nevalidních message headers na vstupu.~~ ✅ DONE
4. Přepnutí upload storage perms na `0700/0600`.
5. ~~Omezit phantom creation (rate limit bez emailu + cap).~~ ✅ DONE
6. Zavést cap pro `pending_registrations` a čistit je i v periodickém cleanupu.
7. ~~Přidat cap/rate limit na in-flight uploady.~~ ✅ DONE
### 7 dní
1. Zapnout TLS mezi app a MySQL (mTLS nebo minimálně server cert verify).
2. ~~Opravit autorizaci `session_reset`.~~ ✅ DONE
3. ~~Opravit vazbu `message_ids` na `conversation_id` pro receipts.~~ ✅ DONE
4. Omezit `get_key_bundle` (rate limit + policy sdílené konverzace).
### 30 dní
1. ~~Anti-enumeration sjednotit napříč endpointy.~~ ✅ DONE
2. ~~CLI hardening (`getpass`, output sanitace, filename sanitace).~~ ✅ DONE
3. Doplnit integrační testy pro bezpečnostní regresi (TOFU, poisoned headers, receipt authz, session_reset device targeting, anti-enumeration, DoS caps).

22
TODO.md Normal file
View File

@@ -0,0 +1,22 @@
# TODO
## Distributed global cap for phantom users (multi-process safe)
1. Add DB-backed quota as source of truth (`system_quotas` table, row `phantom_users` with `used` and `limit`).
2. Move cap enforcement into one DB transaction:
- lock quota row with `SELECT ... FOR UPDATE`
- check `used < limit`
- create phantom user
- increment `used`
- commit (or rollback on failure).
3. Handle same-email races using `UNIQUE(email)`:
- on duplicate key, do not increment quota
- return existing user (or unified error response).
4. Add periodic reconciliation job:
- recalculate phantom count from `users`
- repair `system_quotas.used` if drift is detected.
5. Move phantom creation rate-limits to shared backend (Redis or DB atomic counters), so all server processes enforce the same limits.
6. Add concurrency tests:
- multi-process create storm near cap boundary (499/500)
- duplicate-email storm
- assert `used <= limit` always holds.

101
certs/README.md Normal file
View File

@@ -0,0 +1,101 @@
# TLS Setup — Let's Encrypt + Cloudflare DNS
TLS certifikát přes Let's Encrypt bez nutnosti otevírat port 80.
Ověření domény probíhá přes DNS TXT záznam (Cloudflare API).
## Předpoklady
- Doména s DNS na Cloudflare (free tier stačí)
- Cloudflare API token s oprávněním "Edit zone DNS"
- Root přístup na serveru (certbot potřebuje `/etc/letsencrypt/`)
## Postup
### 1. Cloudflare API token
1. Jdi na https://dash.cloudflare.com/profile/api-tokens
2. **Create Token** → Use template **"Edit zone DNS"**
3. Zone Resources → vybrat svou doménu
4. Zkopíruj vygenerovaný token
### 2. Credentials soubor
```bash
cp cloudflare.ini.example cloudflare.ini
nano cloudflare.ini # vlož API token
chmod 600 cloudflare.ini
```
### 3. Získání certifikátu
```bash
sudo ./setup-tls.sh chat.example.com
```
Skript nainstaluje certbot + Cloudflare plugin, získá certifikát a vytvoří symlinky v tomto adresáři.
### 4. Konfigurace serveru
Přidej do `.env` v kořenovém adresáři projektu:
```env
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
```
### 5. Konfigurace klienta
Na klientovi stačí:
```env
TLS_ENABLED=true
```
Let's Encrypt je v systémovém trust store — klient ověří certifikát automaticky.
## Obnova certifikátu
Certbot obnovuje certifikát automaticky přes systemd timer (každých ~60 dní, cert platí 90).
```bash
# Ověřit že timer běží
systemctl status certbot.timer
# Ruční obnova (test)
sudo certbot renew --dry-run
```
Po úspěšné obnově se spustí `reload-server.sh` (deploy hook) — restartuje chat server aby načetl nový certifikát.
## Soubory
| Soubor | Účel |
|--------|------|
| `setup-tls.sh` | Instalace certbot + získání certifikátu |
| `reload-server.sh` | Deploy hook — restartuje server po renew |
| `cloudflare.ini.example` | Šablona pro Cloudflare API token |
| `cloudflare.ini` | Tvůj API token (gitignored) |
## FAQ
**Funguje certifikát na nestandardním portu (např. 9999)?**
Ano. Certifikát je vázaný na doménu, ne na port. `chat.example.com:9999` funguje.
**Musím otevírat port 80?**
Ne. DNS challenge ověřuje doménu přes DNS TXT záznam, žádný HTTP požadavek na server.
**Co když nemám Cloudflare?**
Můžeš použít ruční DNS challenge (bez automatického renew):
```bash
sudo certbot certonly --manual --preferred-challenges dns -d chat.example.com
```
Certbot ti řekne jaký TXT záznam přidat. Při renew to musíš opakovat ručně.
**Dev/testování bez certifikátu?**
```env
ENVIRONMENT=dev
TLS_ENABLED=true
TLS_AUTOGEN=true # server vygeneruje self-signed cert
TLS_INSECURE=true # klient přeskočí ověření
```

View File

@@ -0,0 +1,11 @@
# Cloudflare API token pro certbot DNS challenge
# 1. Jdi na https://dash.cloudflare.com/profile/api-tokens
# 2. Create Token -> Edit zone DNS (template)
# 3. Zone Resources: vybrat svou doménu
# 4. Zkopírovat token sem
#
# Po vyplnění přejmenuj na cloudflare.ini a nastav práva:
# cp cloudflare.ini.example cloudflare.ini
# chmod 600 cloudflare.ini
dns_cloudflare_api_token = VLOZ_SVUJ_CLOUDFLARE_API_TOKEN

28
certs/reload-server.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Deploy hook — spustí se automaticky po úspěšném renew certifikátu
# Certbot volá tento skript s RENEWED_LINEAGE a RENEWED_DOMAINS env vars
#
# Restartuje chat server aby načetl nový certifikát.
# Přizpůsob podle toho jak server spouštíš (systemd / screen / přímý proces).
set -euo pipefail
echo "Certifikát obnoven pro: ${RENEWED_DOMAINS:-unknown}"
# Varianta 1: Systemd service
if systemctl is-active --quiet encrypted-chat 2>/dev/null; then
systemctl restart encrypted-chat
echo "Server restartován (systemd)."
exit 0
fi
# Varianta 2: Poslat SIGINT procesu (graceful shutdown + ruční restart)
PID=$(pgrep -f "python.*server.py" || true)
if [ -n "$PID" ]; then
echo "Posílám SIGINT procesu $PID (server.py)"
kill -INT "$PID"
echo "Server zastaven. Spusť ho znovu ručně nebo přes systemd."
exit 0
fi
echo "VAROVÁNÍ: Server proces nenalezen. Restartuj server ručně."

108
certs/setup-tls.sh Executable file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Setup TLS certifikátu přes Let's Encrypt + Cloudflare DNS challenge
# Nevyžaduje otevřený port 80 — ověření přes DNS TXT záznam
#
# Použití:
# 1. Přesuň DNS domény na Cloudflare (free tier stačí)
# 2. Vytvoř API token: https://dash.cloudflare.com/profile/api-tokens
# -> Use template "Edit zone DNS" -> vybrat doménu
# 3. cp cloudflare.ini.example cloudflare.ini
# Vlož token, chmod 600 cloudflare.ini
# 4. sudo ./setup-tls.sh chat.example.com
#
# Po úspěšném získání certifikátu přidej do .env:
# TLS_ENABLED=true
# TLS_CERT_FILE=/etc/letsencrypt/live/DOMENA/fullchain.pem
# TLS_KEY_FILE=/etc/letsencrypt/live/DOMENA/privkey.pem
set -euo pipefail
DOMAIN="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CREDS="$SCRIPT_DIR/cloudflare.ini"
DEPLOY_HOOK="$SCRIPT_DIR/reload-server.sh"
if [ -z "$DOMAIN" ]; then
echo "Použití: sudo $0 <domena>"
echo "Příklad: sudo $0 chat.example.com"
exit 1
fi
if [ "$EUID" -ne 0 ]; then
echo "Spusť jako root: sudo $0 $DOMAIN"
exit 1
fi
if [ ! -f "$CREDS" ]; then
echo "Chybí $CREDS"
echo "Zkopíruj cloudflare.ini.example -> cloudflare.ini a vlož API token."
exit 1
fi
# Ověř oprávnění credentials souboru
PERMS=$(stat -c %a "$CREDS" 2>/dev/null || stat -f %Lp "$CREDS" 2>/dev/null)
if [ "$PERMS" != "600" ]; then
echo "VAROVÁNÍ: $CREDS má oprávnění $PERMS, nastavuji 600"
chmod 600 "$CREDS"
fi
echo "=== Instalace certbot + Cloudflare pluginu ==="
if ! command -v certbot &>/dev/null; then
apt-get update
apt-get install -y certbot python3-certbot-dns-cloudflare
echo "Certbot nainstalován."
else
echo "Certbot již nainstalován."
# Doinstaluj plugin pokud chybí
if ! python3 -c "import certbot_dns_cloudflare" 2>/dev/null; then
apt-get install -y python3-certbot-dns-cloudflare
fi
fi
echo ""
echo "=== Získání certifikátu pro $DOMAIN ==="
DEPLOY_ARGS=""
if [ -f "$DEPLOY_HOOK" ] && [ -x "$DEPLOY_HOOK" ]; then
DEPLOY_ARGS="--deploy-hook $DEPLOY_HOOK"
echo "Deploy hook: $DEPLOY_HOOK"
fi
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials "$CREDS" \
--dns-cloudflare-propagation-seconds 30 \
-d "$DOMAIN" \
--non-interactive \
--agree-tos \
--register-unsafely-without-email \
$DEPLOY_ARGS
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
if [ -d "$CERT_DIR" ]; then
echo ""
echo "=== Certifikát úspěšně získán ==="
echo ""
echo "Soubory:"
echo " Certifikát: $CERT_DIR/fullchain.pem"
echo " Klíč: $CERT_DIR/privkey.pem"
echo ""
echo "Přidej do .env:"
echo " TLS_ENABLED=true"
echo " TLS_CERT_FILE=$CERT_DIR/fullchain.pem"
echo " TLS_KEY_FILE=$CERT_DIR/privkey.pem"
echo ""
echo "Na klientovi stačí:"
echo " TLS_ENABLED=true"
echo ""
echo "Automatický renew: certbot timer (systemd) nebo cron"
echo " systemctl status certbot.timer"
echo ""
# Symlinky pro snadný přístup
ln -sf "$CERT_DIR/fullchain.pem" "$SCRIPT_DIR/fullchain.pem"
ln -sf "$CERT_DIR/privkey.pem" "$SCRIPT_DIR/privkey.pem"
echo "Symlinky vytvořeny v $SCRIPT_DIR/"
else
echo "CHYBA: Certifikát nebyl vytvořen."
exit 1
fi

3481
chat_core.py Normal file

File diff suppressed because it is too large Load Diff

899
client.py Normal file
View File

@@ -0,0 +1,899 @@
"""Interactive CLI client for encrypted chat (X3DH + Double Ratchet)."""
import asyncio
import getpass
import logging
import os
import re
from chat_core import ChatClient, IdentityKeyChanged
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())
async def prompt_password(text: str = "Password: ") -> str:
"""Non-blocking hidden password input (M3 fix)."""
return await asyncio.get_event_loop().run_in_executor(None, lambda: getpass.getpass(text))
# M3 fix: strip terminal control/escape sequences from untrusted text
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]")
def _sanitize_text(s) -> str:
"""Remove control characters and ANSI escape sequences."""
if not isinstance(s, str):
s = str(s) if s is not None else ""
return _CONTROL_RE.sub("", s)
def _safe_filename(name: str) -> str:
"""Sanitize remote filename: basename only, no path traversal, no NUL."""
name = os.path.basename(name)
name = name.replace("\x00", "")
if not name or name.startswith("."):
name = "download"
return name
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 _sanitize_text(c["name"])
others = [_sanitize_text(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 _sanitize_text(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("16) React to message")
print("17) Pin/Unpin message")
print("18) View pinned messages")
print("19) Forward message")
print("20) Verify contact")
print("21) Show my fingerprint")
print("22) Change password")
print("23) Change username")
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
try:
ok, result = await client.send_message(conv_id, text, members)
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
elif choice == "2":
conv, _ = await _select_conversation(client)
if not conv:
continue
text = await prompt("Message: ")
if not text:
continue
try:
ok, result = await client.send_message(conv["conversation_id"], text, conv["members"])
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
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
try:
ok, result = await client.send_message(conv["conversation_id"], text, conv["members"], reply_to=reply_to_id)
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
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
try:
ok, msg = await client.send_image(conv["conversation_id"], image_path, conv["members"])
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
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
try:
ok, msg = await client.send_file(conv["conversation_id"], file_path, conv["members"])
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
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 = _sanitize_text(r.get("sender", "???"))
text = _sanitize_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 == "16":
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)
msg_choice = await prompt("React to message #: ")
try:
msg_idx = int(msg_choice) - 1
if not (0 <= msg_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown")
reaction = await prompt("Reaction: ").strip().lower()
if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"):
print("[!] Invalid reaction.")
continue
ok, msg = await client.react_message(messages[msg_idx]["message_id"], reaction, "add")
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "17":
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)
msg_choice = await prompt("Pin/Unpin message #: ")
try:
msg_idx = int(msg_choice) - 1
if not (0 <= msg_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
m = messages[msg_idx]
action = "unpin" if m.get("pinned_at") else "pin"
ok, msg = await client.pin_message(m["message_id"], conv["conversation_id"], action)
print(f"[{'+'if ok else '!'}] {action.capitalize()}: {msg}")
elif choice == "18":
conv, _ = await _select_conversation(client)
if not conv:
continue
pinned = await client.get_pinned_messages(conv["conversation_id"])
if not pinned:
print("[*] No pinned messages.")
continue
print(f"\n[*] {len(pinned)} pinned message(s):")
for p in pinned:
print(f" {p.get('message_id', '?')[:8]}... pinned at {p.get('pinned_at', '?')}")
elif choice == "19":
conv, _ = await _select_conversation(client, "Select source conversation")
if not conv:
continue
messages = await client.get_messages(conv["conversation_id"])
if not messages:
print("[*] No messages.")
continue
_print_messages(messages, client, conv)
msg_choice = await prompt("Forward message #: ")
try:
msg_idx = int(msg_choice) - 1
if not (0 <= msg_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
target_conv, _ = await _select_conversation(client, "Select target conversation")
if not target_conv:
continue
fwd_msg = messages[msg_idx]
fwd_msg["conversation_id"] = conv["conversation_id"]
try:
ok, result = await client.forward_message(
target_conv["conversation_id"], fwd_msg, target_conv["members"]
)
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Forwarded.' if ok else result}")
elif choice == "20":
# Verify contact — show safety number for a DM conversation
conv, _ = await _select_conversation(client, "Select DM to verify")
if not conv:
continue
# Find peer user_id
peer_uid = ""
peer_name = ""
for m in conv.get("members", []):
if m.get("email") != client.email:
peer_uid = m.get("user_id") or m.get("id") or ""
peer_name = _sanitize_text(m.get("username") or m.get("email") or "?")
break
if not peer_uid:
print("[!] Could not identify peer user.")
continue
# Ensure we have their identity key in cache
info = await client._get_user_info(user_id=peer_uid)
if not info or not info.get("identity_key_bytes"):
print("[!] Could not retrieve identity key for this user.")
continue
status = client.get_verification_status(peer_uid)
print(f"\n--- Verification: {peer_name} ---")
print(f"Status: {status.upper()}")
safety = client.get_safety_number(peer_uid)
if safety:
print(f"\nSafety Number:\n{safety}")
fp = client.get_peer_fingerprint(peer_uid)
if fp:
print(f"\nTheir Fingerprint:\n{fp}")
my_fp = client.get_my_fingerprint()
if my_fp:
print(f"\nYour Fingerprint:\n{my_fp}")
if status != "verified":
action = await prompt("\nMark as verified? (y/n): ")
if action.lower() == "y":
client.verify_contact(peer_uid, info["identity_key_bytes"],
method="safety_number")
print("[+] Contact marked as verified.")
else:
action = await prompt("\nRemove verification? (y/n): ")
if action.lower() == "y":
client.unverify_contact(peer_uid)
print("[+] Verification removed.")
elif choice == "21":
# Show own fingerprint
fp = client.get_my_fingerprint()
if fp:
print(f"\n--- Your Fingerprint ---\n{fp}")
else:
print("[!] Not logged in or identity key not available.")
elif choice == "22":
# Change password
old_pw = getpass.getpass("Current password: ")
new_pw = getpass.getpass("New password: ")
confirm_pw = getpass.getpass("Confirm new password: ")
if new_pw != confirm_pw:
print("[!] Passwords do not match.")
elif not new_pw:
print("[!] Password cannot be empty.")
else:
ok, msg = client.change_password(old_pw, new_pw)
if ok:
print(f"[+] {msg}")
else:
print(f"[!] {msg}")
elif choice == "23":
new_un = await prompt("New username: ")
new_un = new_un.strip() if new_un else ""
if not new_un:
print("[!] Username cannot be empty.")
else:
ok, msg = await client.change_username(new_un)
if ok:
print(f"[+] {msg}")
else:
print(f"[!] {msg}")
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: {_sanitize_text(img.get('filename', '?'))} ({_human_size(img.get('size', 0))})]"
file_info = ""
if m.get("file"):
fi = m["file"]
file_info = f" [File: {_sanitize_text(fi.get('filename', '?'))} ({_human_size(fi.get('size', 0))})]"
read_info = ""
if m.get("sender") == client.username:
read_by = m.get("read_by", [])
delivered_to = m.get("delivered_to", [])
member_map = {}
for mem in conv.get("members", []):
uid = mem.get("user_id") or mem.get("id", "")
if uid:
member_map[uid] = _sanitize_text(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]
others_delivered = [d for d in delivered_to if d.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}]"
elif others_delivered:
read_info = " [\u2713\u2713 Delivered]"
else:
read_info = " [\u2713 Sent]"
pin_info = ""
if m.get("pinned_at"):
pin_info = " \U0001f4cc"
reaction_info = ""
reactions = m.get("reactions", [])
if reactions:
grouped = {}
for r in reactions:
grouped.setdefault(r["reaction"], 0)
grouped[r["reaction"]] += 1
_REMOJI = {"thumbsup": "\U0001f44d", "heart": "\u2764\ufe0f", "laugh": "\U0001f602",
"surprised": "\U0001f62e", "sad": "\U0001f622", "thumbsdown": "\U0001f44e"}
parts = [f"{_REMOJI.get(k, k)}{v}" for k, v in grouped.items()]
reaction_info = " [" + " ".join(parts) + "]"
fwd_info = ""
if m.get("forwarded_from"):
fwd_sender = _sanitize_text(m["forwarded_from"].get("sender", "?"))
fwd_info = f" (fwd from {fwd_sender})"
text = _sanitize_text(m.get("text", ""))
sender = _sanitize_text(m.get("sender", "?"))
print(f" #{i+1} {sender}: {text}{image_info}{file_info}{reply_info}{read_info}{pin_info}{reaction_info}{fwd_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 = _safe_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 = _sanitize_text(inv.get("conversation_name") or inv.get("conversation_id", "")[:8])
invited_by = _sanitize_text(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: {_sanitize_text(profile.get('username', '?'))}")
print(f" Email: {_sanitize_text(profile.get('email', '?'))}")
phone = profile.get("phone")
if phone:
print(f" Phone: {_sanitize_text(phone)}")
location = profile.get("location")
if location:
print(f" Location: {_sanitize_text(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 = _sanitize_text(d.get("device_name") or "Unnamed")
did = d.get("device_id", "?")
last_seen = _sanitize_text(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 = _sanitize_text(data.get("conversation_name", "?"))
invited_by = _sanitize_text(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 == "message_reacted":
username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
reaction = _sanitize_text(data.get("reaction", "?"))
action = data.get("action", "add")
print(f"\n[*] {username} {'added' if action == 'add' else 'removed'} reaction '{reaction}'")
continue
if notif_type in ("message_pinned", "message_unpinned"):
username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
act = "pinned" if notif_type == "message_pinned" else "unpinned"
print(f"\n[*] {username} {act} a message")
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 {_sanitize_text(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("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("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("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("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("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.")

935
crypto_utils.py Normal file
View File

@@ -0,0 +1,935 @@
"""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.AUTO),
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"]))
# ---------------------------------------------------------------------------
# Contact Key Verification (Safety Numbers / Fingerprints / QR Codes)
# ---------------------------------------------------------------------------
FINGERPRINT_VERSION = 0
def compute_fingerprint(user_id: str, identity_key_bytes: bytes, iterations: int = 5200) -> bytes:
"""Compute a 32-byte fingerprint for a user's identity key.
Uses iterated SHA-512 (Signal's NumericFingerprint algorithm).
Seed: version(2B) + identity_key(32B) + user_id(UTF-8).
Each iteration: SHA-512(previous_hash + identity_key).
Output: first 32 bytes of final hash.
"""
version_bytes = FINGERPRINT_VERSION.to_bytes(2, "big")
data = version_bytes + identity_key_bytes + user_id.encode("utf-8")
for _ in range(iterations):
data = hashlib.sha512(data + identity_key_bytes).digest()
return data[:32]
def format_fingerprint(fp_bytes: bytes) -> str:
"""Format 32-byte fingerprint as 6 groups of 5 zero-padded digits (30 digits).
Each group: int(bytes[i*5:(i+1)*5], big-endian) % 100000.
Output: two lines of 3 groups each, space-separated.
"""
groups = []
for i in range(6):
num = int.from_bytes(fp_bytes[i * 5:(i + 1) * 5], "big") % 100000
groups.append(f"{num:05d}")
return " ".join(groups[:3]) + "\n" + " ".join(groups[3:])
def compute_safety_number(my_uid: str, my_ik_bytes: bytes,
their_uid: str, their_ik_bytes: bytes) -> str:
"""Compute a 60-digit safety number for a pair of users.
Both users see the same number regardless of who computes it.
Lower user_id's fingerprint comes first (deterministic ordering).
Output: 12 groups of 5 digits, formatted as 3 lines of 4 groups.
"""
fp_mine = compute_fingerprint(my_uid, my_ik_bytes)
fp_theirs = compute_fingerprint(their_uid, their_ik_bytes)
if my_uid < their_uid:
combined = fp_mine + fp_theirs
else:
combined = fp_theirs + fp_mine
# 64 bytes -> 12 groups of 5 digits
groups = []
for i in range(12):
num = int.from_bytes(combined[i * 5:(i + 1) * 5], "big") % 100000
groups.append(f"{num:05d}")
lines = [
" ".join(groups[0:4]),
" ".join(groups[4:8]),
" ".join(groups[8:12]),
]
return "\n".join(lines)
def encode_verification_qr(user_id: str, identity_key_bytes: bytes) -> bytes:
"""Encode user identity for QR code verification.
Format: version(1B=0x01) + uid_len(1B) + uid(UTF-8) + identity_key(32B).
"""
uid_bytes = user_id.encode("utf-8")
return b"\x01" + len(uid_bytes).to_bytes(1, "big") + uid_bytes + identity_key_bytes
def decode_verification_qr(data: bytes) -> tuple[str, bytes]:
"""Decode QR code verification payload.
Returns (user_id, identity_key_bytes).
Raises ValueError on invalid format.
"""
if len(data) < 3:
raise ValueError("QR data too short")
if data[0] != 0x01:
raise ValueError(f"Unknown QR version: {data[0]}")
uid_len = data[1]
if len(data) < 2 + uid_len + 32:
raise ValueError("QR data truncated")
user_id = data[2:2 + uid_len].decode("utf-8")
identity_key = data[2 + uid_len:2 + uid_len + 32]
return user_id, identity_key
# ---------------------------------------------------------------------------
# Message Padding (metadata privacy — hide plaintext length)
# ---------------------------------------------------------------------------
_PAD_MAGIC = b"\x01"
_PAD_BUCKETS = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]
def pad_plaintext(plaintext: bytes) -> bytes:
"""Pad plaintext to nearest bucket size to hide message length.
Format: 0x01 + plaintext + random_padding + pad_length(4B big-endian)
Prefix 0x01 distinguishes padded messages from legacy unpadded (which start with '{').
"""
content = _PAD_MAGIC + plaintext
# +4 for the length suffix
min_size = len(content) + 4
target = next((b for b in _PAD_BUCKETS if b >= min_size), min_size)
pad_len = target - len(content)
return content + os.urandom(pad_len - 4) + struct.pack(">I", pad_len)
def unpad_plaintext(data: bytes) -> bytes:
"""Remove padding. Returns raw plaintext for both padded and legacy unpadded messages."""
if not data or data[0:1] != _PAD_MAGIC:
return data # legacy unpadded message (starts with '{' for JSON)
if len(data) < 5:
return data # too short to be validly padded
pad_len = struct.unpack(">I", data[-4:])[0]
if pad_len < 4 or pad_len > len(data) - 1:
return data # invalid padding metadata, treat as legacy
return data[1:len(data) - pad_len]

1714
db.py Normal file

File diff suppressed because it is too large Load Diff

152
gemini.md Normal file
View File

@@ -0,0 +1,152 @@
# Gemini Advanced Roadmap: Beyond the Basics
Tento dokument obsahuje pokročilé návrhy na vylepšení bezpečnosti, architektury a UX aplikace `encrypted_chat`. Tyto body jdou nad rámec běžného "best practice" a směřují k funkcionalitě profesionálních secure messengerů (Signal, Threema, Wire) se zaměřením na ochranu metadat a anti-forenzní techniky.
---
## 1. Ochrana Metadat & Traffic Analysis Resistance
*Cíl: Server by neměl vědět, KDO s KÝM komunikuje, ani JAKÝ typ dat si posílají.*
### Sealed Sender (Odesílatel v obálce)
- **Koncept:** Server zná pouze `recipient_id`. Identita odesílatele (`sender_id`) je zašifrována uvnitř zprávy (v "obálce"), kterou server nedokáže přečíst.
- **Implementace:**
1. Odesílatel vygeneruje klíč pro obálku (např. z profilu příjemce).
2. Zabalí `sender_id` a payload do šifrovaného bloku.
3. Server doručí blob příjemci bez ověření odesílatele (ověření proběhne až na klientovi po rozbalení).
4. **Výhoda:** Při kompromitaci serveru útočník nevidí sociální graf (kdo se s kým baví).
### Traffic Padding & Constant Bitrate
- **Problém:** Délka paketu prozrazuje obsah (krátký paket = "Ahoj", dlouhý paket = obrázek/klíč). Intervaly prozrazují aktivitu.
- **Řešení:**
1. **Padding:** Všechny zprávy doplňovat náhodnými daty na fixní velikosti (např. bloky 4KB).
2. **Dummy Traffic (Chaff):** Klient náhodně odesílá "falešné" pakety na server, které server zahodí nebo vrátí (echo).
3. **Výhoda:** Pro síťového analytika (ISP) vypadá tok dat jako konstantní šum.
---
## 2. Anti-Forenzní Ochrana (Client-side)
*Cíl: Minimalizovat dopad fyzického zabavení zařízení nebo vynuceného odemčení.*
### Duress Password (Heslo pod nátlakem)
- **Funkce:** Uživatel si nastaví *druhé* heslo.
- **Chování:** Pokud se přihlásí tímto heslem:
- **Varianta A (Decoy):** Odemkne se prázdná nebo falešná databáze s neškodnými konverzacemi.
- **Varianta B (Panic):** Aplikace na pozadí tiše provede **secure wipe** (přepis) privátních klíčů a reálné DB, zatímco uživateli zobrazí "Connection Error".
### Secure Deletion & DB Vacuuming
- **Problém:** SQL `DELETE` data nesmaže fyzicky, jen označí místo jako volné.
- **Řešení:**
1. Před smazáním zprávy přepsat obsah náhodnými byty (`UPDATE messages SET content = random_blob WHERE id = ...`).
2. Pravidelně spouštět `VACUUM` (u SQLite) nebo optimalizaci tabulek.
3. Pro soubory (obrázky) použít bezpečné mazání (overwrite passes) před `os.unlink()`.
### Disappearing Messages (TTL)
- **Funkce:** Odesílatel nastaví životnost zprávy (např. 1 minuta).
- **Implementace:** Odpočet začíná okamžikem zobrazení (Read Receipt). Po uplynutí času klient data nenávratně smaže z disku (včetně secure wipe). Server maže ihned po doručení.
---
## 3. Infrastruktura & Škálování
*Cíl: Odlehčit Python procesu a databázi pro podporu tisíců uživatelů.*
### Object Storage (MinIO / S3) + Presigned URLs
- **Problém:** `server.py` blokuje I/O při příjmu velkých souborů.
- **Řešení:**
1. Klient požádá server o upload.
2. Server vygeneruje **Presigned PUT URL** (časově omezený token pro přímý upload do MinIO/S3).
3. Klient nahrává data přímo do úložiště (obchází aplikační server).
4. Server ukládá pouze odkaz (URL/Key).
- **Výhoda:** Masivní zrychlení, server řeší jen metadata.
### Read/Write Splitting (MySQL Replication)
- **Architektura:**
- **Master DB:** Pouze pro `INSERT`, `UPDATE`, `DELETE`.
- **Read Replicas (Slaves):** Pro těžké `SELECT` dotazy (historie zpráv, hledání).
- **Implementace v `db.py`:** Router, který podle typu dotazu volí connection pool.
---
## 4. Protokol & Funkce
*Cíl: Rozšíření možností komunikace bez nutnosti centralizovaného streamování.*
### P2P Volání (WebRTC Signalizace)
- **Koncept:** Využít existující bezpečný kanál (Double Ratchet) pro výměnu SDP (Session Description Protocol) paketů.
- **Flow:**
1. Alice pošle Bobovi zašifrovanou zprávu typu `CALL_OFFER` s parametry WebRTC.
2. Bob odpoví `CALL_ANSWER`.
3. Klienti si vymění `ICE_CANDIDATES` (IP adresy/porty) a naváží přímé P2P spojení (UDP).
4. Audio/Video stream (SRTP) jde mimo server.
### Diferenciální Synchronizace (Merkle Trees)
- **Problém:** Stahování seznamu kontaktů (`get_user_contacts`) je pomalé při velkém množství dat.
- **Řešení:** Klient a server si udržují Hash Tree (Merkle Tree) stavu. Při synchronizaci porovnají pouze root hash. Pokud se liší, stahují se jen změněné větve stromu (delta update).
---
## 5. UI/UX (PyQt Speciality)
*Cíl: Ochrana soukromí na úrovni OS a skrytá komunikace.*
### Privacy Overlay (Task Switcher)
- **Funkce:** Detekovat událost ztráty fokusu okna (`QEvent.WindowDeactivate`) nebo minimalizace.
- **Akce:** Překrýt obsah okna rozmazaným efektem (`QGraphicsBlurEffect`) nebo logem aplikace.
- **Důvod:** Zabrání operačnímu systému (Windows/Linux/macOS) vytvořit čitelný náhled okna v Alt+Tab menu nebo v historii aktivit.
### Steganografie
- **Funkce:** Ukrýt šifrovanou zprávu do obrazových dat nevinného obrázku (např. meme kočky).
- **Implementace:** Modifikace LSB (Least Significant Bit) pixelů obrázku.
- **Výhoda:** Pro síťového admina nebo forenzní analýzu to vypadá jako běžné posílání obrázků, přítomnost šifrované komunikace je popiratelná.
---
## 6. High Availability Architecture (Distribuovaný Cluster)
*Cíl: Zajištění provozu i při výpadku/napadení serveru (Active-Active "RAID 1 přes síť").*
### Architektura: Geograficky Distribuovaný "Zero-Trust" Cluster
#### 1. Vstupní brána (Global Traffic Manager)
- **Funkce:** Rozděluje klienty mezi dostupné servery (Round Robin / Geo-DNS).
- **Self-Healing:** Při výpadku Serveru A okamžitě přesměruje provoz na Server B. Uživatel nic nepozná.
#### 2. Aplikační vrstva (Stateless Servers)
- **Stav:** Servery jsou **bezstavové**. `server.py` neukládá nic důležitého v RAM.
- **Škálování:** Můžete spustit N instancí serveru. Je jedno, ke kterému se uživatel připojí.
- **Komunikace:** Servery spolu mluví přes rychlý Message Bus (Redis Pub/Sub) pro doručování real-time zpráv mezi uživateli na různých uzlech.
#### 3. Datová vrstva (Zrcadlení Dat - "RAID 1")
- **Databáze (MySQL Galera Cluster):** Synchronní multi-master replikace. Zápis na Serveru A se potvrdí, až když je fyzicky zapsán i na Serveru B (a C).
- *Efekt:* Ztráta serveru neznamená ztrátu dat (klíčů, zpráv).
- **Soubory (MinIO Cluster):** Distribuovaný Object Storage s Erasure Coding. Soubory jsou matematicky rozprostřeny přes všechny servery. Výpadek disku/serveru nevadí.
#### 4. Bezpečnostní pojistky ("Poisoned Node")
- **Soft Delete:** Databáze nemaže data ihned, ale označuje je jako smazané (ochrana proti `DELETE *` od útočníka).
- **Client-Side Verification:** I kdyby kompromitovaný server posílal podvržené klíče, klienti ověřují digitální podpisy (Identity Keys). Server nemůže zfalšovat identitu uživatelů.
---
## 7. Technický Upgrade pro Stateless Architekturu
*Cíl: Odstranit závislost na paměti procesu (RAM) pro umožnění horizontálního škálování.*
### 1. Redis jako Distribuovaná Paměť
Nahrazení Python `dict` struktur, které jsou lokální pro jeden proces, za centrální Redis úložiště přístupné všem serverům.
* **Párovací Session:**
* *Stav:* `pairing_sessions` (dict) -> Redis Key `pair:{code}` (Hash/String s TTL).
* *Efekt:* Uživatel může vyžádat kód na Serveru A a potvrdit ho na Serveru B.
* **Rate Limiting:**
* *Stav:* `rate_limits` (dict) -> Redis Key `rl:{ip}:{action}` (Counter s EXPIRE).
* *Efekt:* Limity platí globálně pro celý cluster, ne jen per server.
### 2. Redis Pub/Sub pro Real-Time Routing
Doručení zprávy uživateli, který je připojen k JINÉMU serveru než odesílatel.
* **Princip:**
1. Server A (odesílatel) zjistí, že příjemce Bob není připojen lokálně.
2. Server A publikuje zprávu do Redis kanálu `user:{bob_user_id}`.
3. Server B (kde je Bob připojen) tento kanál poslouchá (subscribe).
4. Server B přijme zprávu z Redisu a pošle ji Bobovi do otevřeného TCP socketu.
### 3. Session Sticky vs. Stateless Uploads
Řešení pro nahrávání souborů po částech (chunks).
* **Varianta A (Infrastructure - Sticky Sessions):** Load Balancer (HAProxy/Nginx) zajistí, že všechny požadavky od jedné IP jdou vždy na stejný server. Nejjednodušší, nevyžaduje změnu kódu.
* **Varianta B (Architectural - Direct Upload):** Viz bod 3 "Object Storage + Presigned URLs". Server vůbec nepřijímá data souboru, pouze vygeneruje token. Plně stateless řešení.

6338
gui_client.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import Foundation
import SwiftUI
enum ConnectionStatus: Equatable {
case disconnected
case connecting
case connected
}
@Observable
final class AppState {
var isLoggedIn = false
var currentUser: User?
var connectionStatus: ConnectionStatus = .disconnected
var email: String = ""
let chatClient = ChatClient()
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
@main
struct EncryptedChatApp: App {
@State private var appState = AppState()
@State private var authViewModel = AuthViewModel()
var body: some Scene {
WindowGroup {
if appState.isLoggedIn {
MainTabView(appState: appState)
} else {
LoginView(viewModel: authViewModel, appState: appState)
}
}
}
}
struct MainTabView: View {
var appState: AppState
@State private var convListVM = ConversationListVM()
var body: some View {
TabView {
ConversationListView(appState: appState, viewModel: convListVM)
.tabItem {
Label("Chats", systemImage: "bubble.left.and.bubble.right.fill")
}
ProfileView(appState: appState, isOwnProfile: true)
.tabItem {
Label("Profile", systemImage: "person.fill")
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
import Foundation
import CryptoKit
/// Local file storage for keys, sessions, and sender keys.
/// Matches Python: chat_core.py key storage functions.
///
/// Base directory: Application Support / EncryptedChat / {email}
/// Same file names as Python client for cross-platform compatibility.
enum KeyStorage {
// MARK: - Base Directory
/// Get or create the key storage directory for a user
static func getKeyDir(email: String) throws -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dir = appSupport.appendingPathComponent("EncryptedChat").appendingPathComponent(email)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// iOS file protection
try (dir as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey)
return dir
}
// MARK: - RSA Keys
/// Save RSA keypair
static func saveRSAKeys(email: String, privateKey: SecKey, publicKey: SecKey, password: Data? = nil) throws {
let dir = try getKeyDir(email: email)
let privData = try RSACrypto.serializePrivateKey(privateKey, password: password)
let pubData = try RSACrypto.serializePublicKey(publicKey)
try writeProtected(privData, to: dir.appendingPathComponent("private.pem"))
try pubData.write(to: dir.appendingPathComponent("public.pem"))
}
/// Load RSA keypair. Returns (private, public, error).
static func loadRSAKeys(email: String, password: Data? = nil) -> (SecKey?, SecKey?, String?) {
guard let dir = try? getKeyDir(email: email) else {
return (nil, nil, "Cannot access key directory")
}
let privPath = dir.appendingPathComponent("private.pem")
let pubPath = dir.appendingPathComponent("public.pem")
guard FileManager.default.fileExists(atPath: privPath.path) else {
return (nil, nil, "No local keys found.")
}
guard let privData = try? Data(contentsOf: privPath),
let pubData = try? Data(contentsOf: pubPath) else {
return (nil, nil, "Cannot read key files.")
}
do {
let privateKey = try RSACrypto.loadPrivateKey(privData, password: password)
let publicKey = try RSACrypto.loadPublicKey(pubData)
return (privateKey, publicKey, nil)
} catch {
// Try without password (unencrypted)
do {
let privateKey = try RSACrypto.loadPrivateKey(privData, password: nil)
let publicKey = try RSACrypto.loadPublicKey(pubData)
// Re-save with password if provided
if let password = password {
try? saveRSAKeys(email: email, privateKey: privateKey, publicKey: publicKey, password: password)
}
return (privateKey, publicKey, nil)
} catch {
return (nil, nil, "Invalid or missing password.")
}
}
}
// MARK: - Identity Keys (Ed25519)
static func saveIdentityKeys(
email: String,
privateKey: Curve25519.Signing.PrivateKey,
publicKey: Curve25519.Signing.PublicKey,
password: Data? = nil
) throws {
let dir = try getKeyDir(email: email)
let privData = try Ed25519Crypto.serializePrivate(privateKey, password: password)
let pubData = Ed25519Crypto.serializePublic(publicKey)
try writeProtected(privData, to: dir.appendingPathComponent("identity_private.bin"))
try pubData.write(to: dir.appendingPathComponent("identity_public.bin"))
}
static func loadIdentityKeys(
email: String,
password: Data? = nil
) -> (Curve25519.Signing.PrivateKey?, Curve25519.Signing.PublicKey?) {
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
let privPath = dir.appendingPathComponent("identity_private.bin")
let pubPath = dir.appendingPathComponent("identity_public.bin")
guard FileManager.default.fileExists(atPath: privPath.path),
let privData = try? Data(contentsOf: privPath),
let pubData = try? Data(contentsOf: pubPath) else {
return (nil, nil)
}
do {
let priv = try Ed25519Crypto.loadPrivate(privData, password: password)
let pub = try Ed25519Crypto.loadPublic(pubData)
return (priv, pub)
} catch {
return (nil, nil)
}
}
// MARK: - Signed Pre-Key
static func saveSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
let dir = try getKeyDir(email: email)
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("spk_private.bin"))
try spkId.write(to: dir.appendingPathComponent("spk_id.txt"), atomically: true, encoding: .utf8)
}
static func loadSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
let privPath = dir.appendingPathComponent("spk_private.bin")
let idPath = dir.appendingPathComponent("spk_id.txt")
guard FileManager.default.fileExists(atPath: privPath.path),
let privData = try? Data(contentsOf: privPath),
let priv = try? X25519Crypto.loadPrivate(privData) else {
return (nil, nil)
}
let spkId = (try? String(contentsOf: idPath, encoding: .utf8))?.trimmed ?? ""
return (priv, spkId)
}
// MARK: - Previous SPK (Grace Period)
static func savePrevSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
let dir = try getKeyDir(email: email)
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("prev_spk_private.bin"))
try spkId.write(to: dir.appendingPathComponent("prev_spk_id.txt"), atomically: true, encoding: .utf8)
}
static func loadPrevSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
let privPath = dir.appendingPathComponent("prev_spk_private.bin")
let idPath = dir.appendingPathComponent("prev_spk_id.txt")
guard FileManager.default.fileExists(atPath: privPath.path),
let privData = try? Data(contentsOf: privPath),
let priv = try? X25519Crypto.loadPrivate(privData) else {
return (nil, nil)
}
let spkId = (try? String(contentsOf: idPath, encoding: .utf8))?.trimmed ?? ""
return (priv, spkId)
}
// MARK: - One-Time Pre-Keys
static func saveOPKPrivate(email: String, opkId: String, privateKey: Curve25519.KeyAgreement.PrivateKey) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("opk_private")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("\(opkId).bin"))
}
static func loadOPKPrivate(email: String, opkId: String) -> Curve25519.KeyAgreement.PrivateKey? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
guard let data = try? Data(contentsOf: path) else { return nil }
return try? X25519Crypto.loadPrivate(data)
}
static func deleteOPKPrivate(email: String, opkId: String) {
guard let dir = try? getKeyDir(email: email) else { return }
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
try? FileManager.default.removeItem(at: path)
}
// MARK: - Device ID
static func saveDeviceId(email: String, deviceId: String) throws {
let dir = try getKeyDir(email: email)
try writeProtected(Data(deviceId.utf8), to: dir.appendingPathComponent("device_id.txt"))
}
static func loadDeviceId(email: String) -> String? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("device_id.txt")
guard let data = try? Data(contentsOf: path) else { return nil }
let str = String(data: data, encoding: .utf8)?.trimmed
return (str?.isEmpty ?? true) ? nil : str
}
// MARK: - Sessions (Double Ratchet)
static func saveSession(
email: String,
peerUserId: String,
ratchet: DoubleRatchet,
localKey: Data? = nil,
peerDeviceId: String? = nil
) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("sessions")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let filename: String
if let deviceId = peerDeviceId {
filename = "\(peerUserId)_\(deviceId).bin"
} else {
filename = "\(peerUserId).bin"
}
var data = try ratchet.exportState()
if let localKey = localKey {
data = try CryptoUtils.encryptLocal(data, key: localKey)
}
try writeProtected(data, to: dir.appendingPathComponent(filename))
}
static func loadSession(
email: String,
peerUserId: String,
localKey: Data? = nil,
peerDeviceId: String? = nil
) -> DoubleRatchet? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let sessionsDir = dir.appendingPathComponent("sessions")
let filename: String
if let deviceId = peerDeviceId {
filename = "\(peerUserId)_\(deviceId).bin"
} else {
filename = "\(peerUserId).bin"
}
let path = sessionsDir.appendingPathComponent(filename)
return loadSessionFile(path, localKey: localKey)
}
static func deleteSession(email: String, peerUserId: String, peerDeviceId: String? = nil) {
guard let dir = try? getKeyDir(email: email) else { return }
let sessionsDir = dir.appendingPathComponent("sessions")
if let deviceId = peerDeviceId {
let path = sessionsDir.appendingPathComponent("\(peerUserId)_\(deviceId).bin")
try? FileManager.default.removeItem(at: path)
} else {
// Delete all sessions for this user
if let files = try? FileManager.default.contentsOfDirectory(atPath: sessionsDir.path) {
for file in files where file.hasPrefix(peerUserId) {
try? FileManager.default.removeItem(at: sessionsDir.appendingPathComponent(file))
}
}
}
}
private static func loadSessionFile(_ path: URL, localKey: Data?) -> DoubleRatchet? {
guard let raw = try? Data(contentsOf: path) else { return nil }
if let localKey = localKey {
// Try encrypted first
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
return try? DoubleRatchet.importState(decrypted)
}
// Fallback: plaintext (transparent migration)
if let ratchet = try? DoubleRatchet.importState(raw) {
// Re-save encrypted
try? writeProtected(CryptoUtils.encryptLocal(try ratchet.exportState(), key: localKey), to: path)
return ratchet
}
return nil
}
return try? DoubleRatchet.importState(raw)
}
// MARK: - Sender Keys
static func saveSenderKeyState(
email: String,
convId: String,
state: SenderKeyState,
localKey: Data? = nil
) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
var data = state.exportState()
if let localKey = localKey {
data = try CryptoUtils.encryptLocal(data, key: localKey)
}
try writeProtected(data, to: dir.appendingPathComponent("\(convId).bin"))
}
static func loadSenderKeyState(
email: String,
convId: String,
localKey: Data? = nil
) -> SenderKeyState? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
guard let raw = try? Data(contentsOf: path) else { return nil }
if let localKey = localKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
return try? SenderKeyState.importState(decrypted)
}
// Plaintext fallback
if let state = try? SenderKeyState.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
return state
}
return nil
}
return try? SenderKeyState.importState(raw)
}
static func deleteSenderKeyState(email: String, convId: String) {
guard let dir = try? getKeyDir(email: email) else { return }
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
try? FileManager.default.removeItem(at: path)
}
// MARK: - Received Sender Keys
static func saveRecvSenderKey(
email: String,
convId: String,
senderId: String,
senderDeviceId: String,
state: SenderKeyState,
localKey: Data? = nil
) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys_recv")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
var data = state.exportState()
if let localKey = localKey {
data = try CryptoUtils.encryptLocal(data, key: localKey)
}
try writeProtected(data, to: dir.appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin"))
}
static func loadRecvSenderKey(
email: String,
convId: String,
senderId: String,
senderDeviceId: String,
localKey: Data? = nil
) -> SenderKeyState? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("sender_keys_recv").appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin")
guard let raw = try? Data(contentsOf: path) else { return nil }
if let localKey = localKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
return try? SenderKeyState.importState(decrypted)
}
if let state = try? SenderKeyState.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
return state
}
return nil
}
return try? SenderKeyState.importState(raw)
}
static func deleteRecvSenderKeys(email: String, convId: String) {
guard let dir = try? getKeyDir(email: email) else { return }
let recvDir = dir.appendingPathComponent("sender_keys_recv")
guard let files = try? FileManager.default.contentsOfDirectory(atPath: recvDir.path) else { return }
for file in files where file.hasPrefix(convId) {
try? FileManager.default.removeItem(at: recvDir.appendingPathComponent(file))
}
}
// MARK: - Favorites
static func saveFavorites(email: String, favorites: Set<String>) throws {
let dir = try getKeyDir(email: email)
let data = try JSONSerialization.data(withJSONObject: Array(favorites))
try data.write(to: dir.appendingPathComponent("favorites.json"))
}
static func loadFavorites(email: String) -> Set<String> {
guard let dir = try? getKeyDir(email: email) else { return [] }
let path = dir.appendingPathComponent("favorites.json")
guard let data = try? Data(contentsOf: path),
let array = try? JSONSerialization.jsonObject(with: data) as? [String] else {
return []
}
return Set(array)
}
// MARK: - Helpers
private static func writeProtected(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .completeFileProtection)
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
/// Encrypted local message cache.
/// Matches Python: chat_core.py message cache (message_cache/{conv_id}.json)
enum MessageCache {
/// Save messages for a conversation (encrypted with local storage key)
static func save(email: String, convId: String, messages: [[String: Any]], cacheKey: Data?) throws {
let dir = try KeyStorage.getKeyDir(email: email).appendingPathComponent("message_cache")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let jsonData = try JSONSerialization.data(withJSONObject: messages)
let dataToWrite: Data
if let cacheKey = cacheKey {
dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
} else {
dataToWrite = jsonData
}
try dataToWrite.write(to: dir.appendingPathComponent("\(convId).json"), options: .completeFileProtection)
}
/// Load messages for a conversation
static func load(email: String, convId: String, cacheKey: Data?) -> [[String: Any]]? {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
guard let raw = try? Data(contentsOf: path) else { return nil }
let jsonData: Data
if let cacheKey = cacheKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
jsonData = decrypted
} else {
// Plaintext fallback (migration)
jsonData = raw
}
} else {
jsonData = raw
}
return try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]]
}
/// Search messages in a conversation
static func search(email: String, convId: String, query: String, cacheKey: Data?) -> [[String: Any]] {
guard let messages = load(email: email, convId: convId, cacheKey: cacheKey) else {
return []
}
let lowerQuery = query.lowercased()
return messages.filter { msg in
if let text = msg["text"] as? String, text.lowercased().contains(lowerQuery) {
return true
}
return false
}
}
/// Delete cache for a conversation
static func delete(email: String, convId: String) {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
try? FileManager.default.removeItem(at: path)
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
enum CryptoError: Error, LocalizedError {
case invalidBase64
case invalidHex
case invalidKeyData(String)
case invalidSignature
case signatureVerificationFailed
case encryptionFailed(String)
case decryptionFailed(String)
case invalidECP1Format
case pbkdf2Failed
case rsaKeyGenerationFailed
case rsaOperationFailed(String)
case x3dhFailed(String)
case ratchetError(String)
case senderKeyError(String)
case maxSkipExceeded
case duplicateMessage
case invalidHeader(String)
case stateImportFailed(String)
case keyConversionFailed(String)
var errorDescription: String? {
switch self {
case .invalidBase64: return "Invalid base64 encoding"
case .invalidHex: return "Invalid hex encoding"
case .invalidKeyData(let msg): return "Invalid key data: \(msg)"
case .invalidSignature: return "Invalid signature format"
case .signatureVerificationFailed: return "Signature verification failed"
case .encryptionFailed(let msg): return "Encryption failed: \(msg)"
case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
case .invalidECP1Format: return "Invalid ECP1 key format"
case .pbkdf2Failed: return "PBKDF2 key derivation failed"
case .rsaKeyGenerationFailed: return "RSA key generation failed"
case .rsaOperationFailed(let msg): return "RSA operation failed: \(msg)"
case .x3dhFailed(let msg): return "X3DH failed: \(msg)"
case .ratchetError(let msg): return "Ratchet error: \(msg)"
case .senderKeyError(let msg): return "Sender key error: \(msg)"
case .maxSkipExceeded: return "Maximum message skip exceeded"
case .duplicateMessage: return "Duplicate message detected"
case .invalidHeader(let msg): return "Invalid header: \(msg)"
case .stateImportFailed(let msg): return "State import failed: \(msg)"
case .keyConversionFailed(let msg): return "Key conversion failed: \(msg)"
}
}
}
enum NetworkError: Error, LocalizedError {
case notConnected
case connectionFailed(String)
case timeout
case serverError(String)
case protocolError(String)
case messageTooLarge
case invalidResponse(String)
case authenticationFailed(String)
case alreadyConnected
var errorDescription: String? {
switch self {
case .notConnected: return "Not connected to server"
case .connectionFailed(let msg): return "Connection failed: \(msg)"
case .timeout: return "Request timed out"
case .serverError(let msg): return "Server error: \(msg)"
case .protocolError(let msg): return "Protocol error: \(msg)"
case .messageTooLarge: return "Message exceeds maximum size"
case .invalidResponse(let msg): return "Invalid response: \(msg)"
case .authenticationFailed(let msg): return "Authentication failed: \(msg)"
case .alreadyConnected: return "Already connected"
}
}
}
enum ChatError: Error, LocalizedError {
case notLoggedIn
case conversationNotFound
case membershipRequired
case permissionDenied(String)
case operationFailed(String)
case fileError(String)
case invalidData(String)
var errorDescription: String? {
switch self {
case .notLoggedIn: return "Not logged in"
case .conversationNotFound: return "Conversation not found"
case .membershipRequired: return "Must be a member of this conversation"
case .permissionDenied(let msg): return "Permission denied: \(msg)"
case .operationFailed(let msg): return "Operation failed: \(msg)"
case .fileError(let msg): return "File error: \(msg)"
case .invalidData(let msg): return "Invalid data: \(msg)"
}
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
import CryptoKit
/// Core cryptographic utilities: AES-GCM, HKDF, KDF helpers
enum CryptoUtils {
// MARK: - AES-256-GCM
/// Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag) all as Data.
/// If key is nil, generates a random 256-bit key.
/// Matches Python: aes_encrypt(plaintext, key=None)
static func aesEncrypt(_ plaintext: Data, key: Data? = nil) throws -> (key: Data, nonce: Data, ciphertext: Data, tag: Data) {
let keyData = key ?? Data.randomBytes(32)
let symmetricKey = SymmetricKey(data: keyData)
let nonceData = Data.randomBytes(12)
let gcmNonce = try AES.GCM.Nonce(data: nonceData)
let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: gcmNonce)
return (
key: keyData,
nonce: nonceData,
ciphertext: Data(sealedBox.ciphertext),
tag: Data(sealedBox.tag)
)
}
/// Decrypt with AES-256-GCM.
/// Matches Python: aes_decrypt(key, nonce, ciphertext, tag)
static func aesDecrypt(key: Data, nonce: Data, ciphertext: Data, tag: Data) throws -> Data {
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ciphertext,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey)
} catch {
throw CryptoError.decryptionFailed("AES-GCM decryption failed")
}
}
/// Encrypt with AES-256-GCM using AAD. Returns ciphertext with tag appended.
/// Used by Double Ratchet and Sender Keys.
static func aesGcmEncrypt(_ plaintext: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.seal(
plaintext,
using: symmetricKey,
nonce: gcmNonce,
authenticating: aad
)
// Return ciphertext + tag concatenated (matches Python AESGCM.encrypt)
return Data(sealedBox.ciphertext) + Data(sealedBox.tag)
}
/// Decrypt AES-256-GCM with AAD. Input ciphertext has tag appended (last 16 bytes).
static func aesGcmDecrypt(_ ctWithTag: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
guard ctWithTag.count >= 16 else {
throw CryptoError.decryptionFailed("Ciphertext too short")
}
let ct = ctWithTag.prefix(ctWithTag.count - 16)
let tag = ctWithTag.suffix(16)
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ct,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: aad)
} catch {
throw CryptoError.decryptionFailed("AES-GCM decryption with AAD failed")
}
}
// MARK: - HKDF
/// HKDF-SHA256 key derivation.
/// Matches Python: hkdf_derive(input_key, salt, info, length=32)
static func hkdfDerive(inputKey: Data, salt: Data, info: Data, length: Int = 32) -> Data {
let symmetricKey = SymmetricKey(data: inputKey)
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: symmetricKey,
salt: salt,
info: info,
outputByteCount: length
)
return derived.withUnsafeBytes { Data($0) }
}
// MARK: - KDF for Double Ratchet
/// Root key KDF. Returns (newRootKey, chainKey).
/// HKDF with rootKey as salt and DH output as input. Derives 64 bytes, split in half.
/// Matches Python: kdf_rk(root_key, dh_output)
static func kdfRK(rootKey: Data, dhOutput: Data) -> (newRootKey: Data, chainKey: Data) {
let derived = hkdfDerive(
inputKey: dhOutput,
salt: rootKey,
info: Data(Constants.rootKeyInfo.utf8),
length: 64
)
return (derived.prefix(32), Data(derived.suffix(32)))
}
/// Chain key KDF. Returns (newChainKey, messageKey).
/// HMAC-SHA256: messageKey = HMAC(chainKey, 0x01), newChainKey = HMAC(chainKey, 0x02)
/// Matches Python: kdf_ck(chain_key)
static func kdfCK(chainKey: Data) -> (newChainKey: Data, messageKey: Data) {
let symmetricKey = SymmetricKey(data: chainKey)
let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: symmetricKey))
let newChainKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: symmetricKey))
return (newChainKey, messageKey)
}
// MARK: - Self-Encryption Key
/// Derive static AES-256 key from identity key for self-encrypted message copies.
/// Matches Python: derive_self_encryption_key(identity_private)
static func deriveSelfEncryptionKey(identityPrivateRaw: Data) -> Data {
hkdfDerive(
inputKey: identityPrivateRaw,
salt: Data(Constants.selfEncryptionSalt.utf8),
info: Data(Constants.selfEncryptionInfo.utf8),
length: 32
)
}
// MARK: - Local Storage Key
/// Derive AES-256 key for encrypting local session/sender key files.
/// Matches Python: derive_local_storage_key(identity_private)
static func deriveLocalStorageKey(identityPrivateRaw: Data) -> Data {
hkdfDerive(
inputKey: identityPrivateRaw,
salt: Data(Constants.localStorageSalt.utf8),
info: Data(Constants.localStorageInfo.utf8),
length: 32
)
}
// MARK: - Local File Encryption
/// Encrypt data for local storage. Format: nonce(12) + tag(16) + ciphertext
/// Matches Python: _encrypt_local(data, key)
static func encryptLocal(_ data: Data, key: Data) throws -> Data {
let symmetricKey = SymmetricKey(data: key)
let sealedBox = try AES.GCM.seal(data, using: symmetricKey)
var result = Data()
result.append(Data(sealedBox.nonce)) // 12 bytes
result.append(Data(sealedBox.tag)) // 16 bytes
result.append(Data(sealedBox.ciphertext)) // N bytes
return result
}
/// Decrypt locally stored data. Format: nonce(12) + tag(16) + ciphertext
/// Matches Python: _decrypt_local(raw, key)
static func decryptLocal(_ raw: Data, key: Data) throws -> Data {
guard raw.count >= 28 else { // 12 + 16 minimum
throw CryptoError.decryptionFailed("Local encrypted data too short")
}
let nonce = raw[0..<12]
let tag = raw[12..<28]
let ct = raw[28...]
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ct,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey)
} catch {
throw CryptoError.decryptionFailed("Local storage decryption failed")
}
}
}

View File

@@ -0,0 +1,371 @@
import Foundation
import CryptoKit
/// Ratchet header sent with each message
struct RatchetHeader {
let dhPub: Data // sender's current ratchet public key (32 bytes)
let n: Int // message number in current sending chain
let pn: Int // number of messages in previous sending chain
/// Serialize header to JSON bytes for use as AAD.
/// Matches Python: RatchetHeader.serialize()
func serialize() -> Data {
let dict: [String: Any] = [
"dh_pub": dhPub.hexString,
"n": n,
"pn": pn,
]
// Must produce consistent JSON sorted keys for determinism
return try! JSONSerialization.data(withJSONObject: dict, options: .sortedKeys)
}
/// Convert to dictionary for protocol.
/// Matches Python: RatchetHeader.to_dict()
func toDict() -> [String: Any] {
[
"dh_pub": dhPub.hexString,
"n": n,
"pn": pn,
]
}
/// Parse from dictionary.
/// Matches Python: RatchetHeader.from_dict(d)
static func fromDict(_ d: [String: Any]) throws -> RatchetHeader {
guard let dhPubHex = d["dh_pub"] as? String,
let dhPub = Data(hexString: dhPubHex),
let n = d["n"] as? Int,
let pn = d["pn"] as? Int else {
throw CryptoError.invalidHeader("Missing or invalid header fields")
}
return RatchetHeader(dhPub: dhPub, n: n, pn: pn)
}
}
/// Signal Double Ratchet implementation.
/// Matches Python: DoubleRatchet class in crypto_utils.py
class DoubleRatchet {
private(set) var dhPair: (privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey)?
private(set) var dhRemote: Curve25519.KeyAgreement.PublicKey?
private(set) var rootKey: Data = Data()
private(set) var sendChainKey: Data?
private(set) var recvChainKey: Data?
private(set) var sendN: Int = 0
private(set) var recvN: Int = 0
private(set) var prevSendN: Int = 0
// Skipped message keys: "dh_pub_hex:n" message_key
private(set) var skipped: [String: Data] = [:]
/// Attached X3DH header set when creating a new session, consumed on first send.
/// Matches Python: ratchet._x3dh_header
var x3dhHeader: [String: Any]?
init() {}
// MARK: - Initialization
/// Initialize as initiator (Alice) after X3DH.
/// Matches Python: DoubleRatchet.init_alice(shared_secret, bob_spk_pub)
static func initAlice(sharedSecret: Data, bobSpkPub: Curve25519.KeyAgreement.PublicKey) throws -> DoubleRatchet {
let ratchet = DoubleRatchet()
let (priv, pub) = X25519Crypto.generateKeypair()
ratchet.dhPair = (priv, pub)
ratchet.dhRemote = bobSpkPub
// Perform DH ratchet to derive send chain
let dhOutput = try X25519Crypto.dh(priv, bobSpkPub)
let (newRK, sendCK) = CryptoUtils.kdfRK(rootKey: sharedSecret, dhOutput: dhOutput)
ratchet.rootKey = newRK
ratchet.sendChainKey = sendCK
ratchet.recvChainKey = nil
ratchet.sendN = 0
ratchet.recvN = 0
ratchet.prevSendN = 0
return ratchet
}
/// Initialize as responder (Bob) after X3DH.
/// Matches Python: DoubleRatchet.init_bob(shared_secret, spk_pair)
static func initBob(
sharedSecret: Data,
spkPair: (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey)
) -> DoubleRatchet {
let ratchet = DoubleRatchet()
ratchet.dhPair = spkPair
ratchet.rootKey = sharedSecret
ratchet.sendChainKey = nil
ratchet.recvChainKey = nil
ratchet.sendN = 0
ratchet.recvN = 0
ratchet.prevSendN = 0
return ratchet
}
// MARK: - Encrypt
/// Encrypt a message.
/// Returns (header dict, ciphertext with tag, nonce).
/// Matches Python: DoubleRatchet.encrypt(plaintext)
func encrypt(_ plaintext: Data) throws -> (header: [String: Any], ciphertext: Data, nonce: Data) {
guard sendChainKey != nil else {
throw CryptoError.ratchetError("Send chain not initialized")
}
guard let dhPair = dhPair else {
throw CryptoError.ratchetError("DH pair not set")
}
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: sendChainKey!)
sendChainKey = newCK
let header = RatchetHeader(
dhPub: X25519Crypto.serializePublic(dhPair.publicKey),
n: sendN,
pn: prevSendN
)
let nonce = Data.randomBytes(12)
let aad = header.serialize()
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
sendN += 1
return (header.toDict(), ctWithTag, nonce)
}
// MARK: - Decrypt
/// Decrypt a message. Handles DH ratchet step if new dh_pub.
/// State is snapshotted before modification and restored on failure (M9 fix).
/// Matches Python: DoubleRatchet.decrypt(header_dict, ciphertext, nonce)
func decrypt(headerDict: [String: Any], ciphertext: Data, nonce: Data) throws -> Data {
let header = try RatchetHeader.fromDict(headerDict)
let remoteDhPubBytes = header.dhPub
// Check if this is from a skipped message
let skipKey = "\(remoteDhPubBytes.hexString):\(header.n)"
if let mk = skipped[skipKey] {
skipped.removeValue(forKey: skipKey)
let aad = header.serialize()
do {
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
} catch {
// Restore skipped key on failure
skipped[skipKey] = mk
throw error
}
}
// Snapshot state before modifications
let snap = snapshot()
do {
let remoteDhPub = try X25519Crypto.loadPublic(remoteDhPubBytes)
let currentRemoteBytes: Data? = dhRemote.map { X25519Crypto.serializePublic($0) }
if currentRemoteBytes == nil || remoteDhPubBytes != currentRemoteBytes {
// New DH ratchet step
try skipMessages(until: header.pn)
try dhRatchet(remoteDhPub: remoteDhPub)
}
try skipMessages(until: header.n)
// Derive message key from receive chain
guard recvChainKey != nil else {
throw CryptoError.ratchetError("Receive chain key is nil")
}
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
recvChainKey = newCK
recvN += 1
let aad = header.serialize()
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
} catch {
restore(snap)
throw error
}
}
// MARK: - State Snapshot/Restore (M9)
private struct Snapshot {
let dhPairPriv: Data?
let dhPairPub: Data?
let dhRemote: Data?
let rootKey: Data
let sendChainKey: Data?
let recvChainKey: Data?
let sendN: Int
let recvN: Int
let prevSendN: Int
let skipped: [String: Data]
}
private func snapshot() -> Snapshot {
Snapshot(
dhPairPriv: dhPair.map { X25519Crypto.serializePrivate($0.privateKey) },
dhPairPub: dhPair.map { X25519Crypto.serializePublic($0.publicKey) },
dhRemote: dhRemote.map { X25519Crypto.serializePublic($0) },
rootKey: rootKey,
sendChainKey: sendChainKey,
recvChainKey: recvChainKey,
sendN: sendN,
recvN: recvN,
prevSendN: prevSendN,
skipped: skipped
)
}
private func restore(_ snap: Snapshot) {
if let privData = snap.dhPairPriv, let pubData = snap.dhPairPub,
let priv = try? X25519Crypto.loadPrivate(privData),
let pub = try? X25519Crypto.loadPublic(pubData) {
dhPair = (priv, pub)
} else {
dhPair = nil
}
if let remoteData = snap.dhRemote, let remote = try? X25519Crypto.loadPublic(remoteData) {
dhRemote = remote
} else {
dhRemote = nil
}
rootKey = snap.rootKey
sendChainKey = snap.sendChainKey
recvChainKey = snap.recvChainKey
sendN = snap.sendN
recvN = snap.recvN
prevSendN = snap.prevSendN
skipped = snap.skipped
}
// MARK: - Internal Ratchet Operations
private func skipMessages(until: Int) throws {
guard recvChainKey != nil else { return }
if until - recvN > Constants.maxSkip {
throw CryptoError.maxSkipExceeded
}
while recvN < until {
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
recvChainKey = newCK
let remoteHex = dhRemote.map { X25519Crypto.serializePublic($0).hexString } ?? ""
skipped["\(remoteHex):\(recvN)"] = mk
recvN += 1
}
}
private func dhRatchet(remoteDhPub: Curve25519.KeyAgreement.PublicKey) throws {
prevSendN = sendN
sendN = 0
recvN = 0
dhRemote = remoteDhPub
// Derive new receive chain key
guard let dhPair = dhPair else {
throw CryptoError.ratchetError("DH pair not set")
}
let dhOutput1 = try X25519Crypto.dh(dhPair.privateKey, remoteDhPub)
let (newRK1, recvCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput1)
rootKey = newRK1
recvChainKey = recvCK
// Generate new DH pair and derive new send chain key
let (newPriv, newPub) = X25519Crypto.generateKeypair()
self.dhPair = (newPriv, newPub)
let dhOutput2 = try X25519Crypto.dh(newPriv, remoteDhPub)
let (newRK2, sendCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput2)
rootKey = newRK2
sendChainKey = sendCK
}
// MARK: - State Export/Import
/// Serialize full ratchet state for persistent storage.
/// Produces JSON matching Python's DoubleRatchet.export_state() exactly.
func exportState() throws -> Data {
var state: [String: Any] = [:]
if let pair = dhPair {
state["dh_priv"] = X25519Crypto.serializePrivate(pair.privateKey).hexString
state["dh_pub"] = X25519Crypto.serializePublic(pair.publicKey).hexString
} else {
state["dh_priv"] = NSNull()
state["dh_pub"] = NSNull()
}
if let remote = dhRemote {
state["dh_remote"] = X25519Crypto.serializePublic(remote).hexString
} else {
state["dh_remote"] = NSNull()
}
state["root_key"] = rootKey.hexString
state["send_ck"] = sendChainKey?.hexString ?? NSNull()
state["recv_ck"] = recvChainKey?.hexString ?? NSNull()
state["send_n"] = sendN
state["recv_n"] = recvN
state["prev_send_n"] = prevSendN
// Skipped keys: Python format is "dh_pub_hex:n" -> message_key_hex
var skippedDict: [String: String] = [:]
for (key, value) in skipped {
skippedDict[key] = value.hexString
}
state["skipped"] = skippedDict
return try JSONSerialization.data(withJSONObject: state)
}
/// Deserialize ratchet state.
/// Matches Python: DoubleRatchet.import_state(data)
static func importState(_ data: Data) throws -> DoubleRatchet {
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw CryptoError.stateImportFailed("Invalid JSON")
}
let r = DoubleRatchet()
if let dhPrivHex = state["dh_priv"] as? String,
let dhPubHex = state["dh_pub"] as? String,
let privData = Data(hexString: dhPrivHex),
let pubData = Data(hexString: dhPubHex) {
let priv = try X25519Crypto.loadPrivate(privData)
let pub = try X25519Crypto.loadPublic(pubData)
r.dhPair = (priv, pub)
}
if let dhRemoteHex = state["dh_remote"] as? String,
let remoteData = Data(hexString: dhRemoteHex) {
r.dhRemote = try X25519Crypto.loadPublic(remoteData)
}
guard let rootKeyHex = state["root_key"] as? String,
let rootKey = Data(hexString: rootKeyHex) else {
throw CryptoError.stateImportFailed("Missing root_key")
}
r.rootKey = rootKey
if let sendCKHex = state["send_ck"] as? String, let ck = Data(hexString: sendCKHex) {
r.sendChainKey = ck
}
if let recvCKHex = state["recv_ck"] as? String, let ck = Data(hexString: recvCKHex) {
r.recvChainKey = ck
}
r.sendN = state["send_n"] as? Int ?? 0
r.recvN = state["recv_n"] as? Int ?? 0
r.prevSendN = state["prev_send_n"] as? Int ?? 0
if let skippedDict = state["skipped"] as? [String: String] {
for (key, valueHex) in skippedDict {
if let value = Data(hexString: valueHex) {
r.skipped[key] = value
}
}
}
return r
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
import CryptoKit
/// Ed25519 signing operations Identity Key management
enum Ed25519Crypto {
// MARK: - Key Generation
/// Generate Ed25519 keypair
static func generateKeypair() -> (privateKey: Curve25519.Signing.PrivateKey, publicKey: Curve25519.Signing.PublicKey) {
let privateKey = Curve25519.Signing.PrivateKey()
return (privateKey, privateKey.publicKey)
}
// MARK: - Serialization
/// Serialize Ed25519 private key. With password: raw 32B ECP1. Without: raw 32B.
/// Matches Python: serialize_ed25519_private(key, password=None)
static func serializePrivate(_ key: Curve25519.Signing.PrivateKey, password: Data? = nil) throws -> Data {
let raw = key.rawData // 32 bytes
if let password = password {
return try KeyEncryption.encrypt(raw, password: password)
}
return raw
}
/// Serialize Ed25519 public key to 32 raw bytes.
/// Matches Python: serialize_ed25519_public(key)
static func serializePublic(_ key: Curve25519.Signing.PublicKey) -> Data {
key.rawData // 32 bytes
}
// MARK: - Loading
/// Load Ed25519 private key. Auto-detects ECP1 / raw 32B.
/// Matches Python: load_ed25519_private(data, password=None)
static func loadPrivate(_ data: Data, password: Data? = nil) throws -> Curve25519.Signing.PrivateKey {
if KeyEncryption.isECP1Format(data) {
guard let pwd = password else {
throw CryptoError.invalidKeyData("ECP1 key requires password")
}
let raw = try KeyEncryption.decrypt(data, password: pwd)
return try Curve25519.Signing.PrivateKey(rawRepresentation: raw)
}
if data.count == 32 {
return try Curve25519.Signing.PrivateKey(rawRepresentation: data)
}
throw CryptoError.invalidKeyData("Cannot parse Ed25519 private key (\(data.count) bytes)")
}
/// Load Ed25519 public key from 32 raw bytes.
/// Matches Python: load_ed25519_public(data)
static func loadPublic(_ data: Data) throws -> Curve25519.Signing.PublicKey {
guard data.count == 32 else {
throw CryptoError.invalidKeyData("Ed25519 public key must be 32 bytes, got \(data.count)")
}
return try Curve25519.Signing.PublicKey(rawRepresentation: data)
}
// MARK: - Sign / Verify
/// Sign data with Ed25519. Returns 64-byte signature.
/// Matches Python: ed25519_sign(private_key, data)
static func sign(_ privateKey: Curve25519.Signing.PrivateKey, data: Data) throws -> Data {
Data(try privateKey.signature(for: data))
}
/// Verify Ed25519 signature.
/// Matches Python: ed25519_verify(public_key, signature, data)
static func verify(_ publicKey: Curve25519.Signing.PublicKey, signature: Data, data: Data) -> Bool {
publicKey.isValidSignature(signature, for: data)
}
}

View File

@@ -0,0 +1,231 @@
import Foundation
/// Pure Swift GF(2^255-19) arithmetic for Ed25519 X25519 public key conversion.
///
/// The conversion formula is: u = (1 + y) / (1 - y) mod p
/// where p = 2^255 - 19, and y is the Ed25519 public key's y-coordinate.
///
/// Uses 4-limb UInt64 representation (little-endian).
enum FieldArithmetic {
// p = 2^255 - 19
static let p: [UInt64] = [
0xFFFF_FFFF_FFFF_FFED, // limb 0 (least significant)
0xFFFF_FFFF_FFFF_FFFF, // limb 1
0xFFFF_FFFF_FFFF_FFFF, // limb 2
0x7FFF_FFFF_FFFF_FFFF, // limb 3 (most significant, 2^63 - 1 accounting for -19)
]
/// Load a 256-bit little-endian byte array into 4 UInt64 limbs
static func load(_ bytes: Data) -> [UInt64] {
precondition(bytes.count == 32)
var limbs = [UInt64](repeating: 0, count: 4)
for i in 0..<4 {
var val: UInt64 = 0
for j in 0..<8 {
val |= UInt64(bytes[i * 8 + j]) << (j * 8)
}
limbs[i] = val
}
return limbs
}
/// Store 4 UInt64 limbs as 32 little-endian bytes
static func store(_ limbs: [UInt64]) -> Data {
var bytes = Data(count: 32)
for i in 0..<4 {
for j in 0..<8 {
bytes[i * 8 + j] = UInt8((limbs[i] >> (j * 8)) & 0xFF)
}
}
return bytes
}
/// a + b mod p
static func add(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
var result = [UInt64](repeating: 0, count: 4)
var carry: UInt64 = 0
for i in 0..<4 {
let (sum1, c1) = a[i].addingReportingOverflow(b[i])
let (sum2, c2) = sum1.addingReportingOverflow(carry)
result[i] = sum2
carry = (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
// Reduce mod p
return reduceOnce(result, carry: carry)
}
/// a - b mod p
static func sub(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
var result = [UInt64](repeating: 0, count: 4)
var borrow: UInt64 = 0
for i in 0..<4 {
let (diff1, b1) = a[i].subtractingReportingOverflow(b[i])
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
result[i] = diff2
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
}
if borrow > 0 {
// Add p back
var c: UInt64 = 0
for i in 0..<4 {
let (s1, c1) = result[i].addingReportingOverflow(p[i])
let (s2, c2) = s1.addingReportingOverflow(c)
result[i] = s2
c = (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
}
return result
}
/// Multiply two 256-bit numbers mod p using schoolbook multiplication
static func mul(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
// Full 512-bit product in 8 limbs
var product = [UInt64](repeating: 0, count: 8)
for i in 0..<4 {
var carry: UInt64 = 0
for j in 0..<4 {
let (hi, lo) = a[i].multipliedFullWidth(by: b[j])
let (sum1, c1) = product[i + j].addingReportingOverflow(lo)
let (sum2, c2) = sum1.addingReportingOverflow(carry)
product[i + j] = sum2
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
product[i + 4] = carry
}
// Reduce mod p using Barrett-like reduction
// Since p = 2^255 - 19, for a 512-bit number we can use:
// x mod p = (x_low + x_high * 2^256) mod p
// Since 2^255 19 (mod p), 2^256 38 (mod p)
return reduceFull(product)
}
/// Reduce 512-bit product mod p using 2^256 38 (mod p)
private static func reduceFull(_ product: [UInt64]) -> [UInt64] {
// Split: low = product[0..3], high = product[4..7]
// result = low + high * 38
var result = [UInt64](repeating: 0, count: 5)
// Start with low part
for i in 0..<4 {
result[i] = product[i]
}
// Add high * 38
var carry: UInt64 = 0
for i in 0..<4 {
let (hi, lo) = product[i + 4].multipliedFullWidth(by: 38)
let (sum1, c1) = result[i].addingReportingOverflow(lo)
let (sum2, c2) = sum1.addingReportingOverflow(carry)
result[i] = sum2
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
result[4] = carry
// The result might still be >= p, so reduce once more
// result[4] * 2^256 result[4] * 38 (mod p)
var extra: UInt64 = result[4]
result[4] = 0
if extra > 0 {
let (hi, lo) = extra.multipliedFullWidth(by: 38)
let (sum1, c1) = result[0].addingReportingOverflow(lo)
result[0] = sum1
var c = hi + (c1 ? 1 : 0)
for i in 1..<4 {
let (s, cf) = result[i].addingReportingOverflow(c)
result[i] = s
c = cf ? 1 : 0
}
// One more round if carry
if c > 0 {
let (s, _) = result[0].addingReportingOverflow(c * 38)
result[0] = s
}
}
var out = Array(result[0..<4])
// Final reduction: if >= p, subtract p
out = reduceOnce(out, carry: 0)
return out
}
/// If the number >= p, subtract p
private static func reduceOnce(_ val: [UInt64], carry: UInt64) -> [UInt64] {
if carry > 0 || isGreaterOrEqual(val, p) {
var result = [UInt64](repeating: 0, count: 4)
var borrow: UInt64 = 0
for i in 0..<4 {
let (diff1, b1) = val[i].subtractingReportingOverflow(p[i])
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
result[i] = diff2
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
}
// If borrow after subtracting p, the original was fine (shouldn't happen with carry)
if borrow > 0 && carry == 0 {
return val
}
return result
}
return val
}
/// Compare a >= b
private static func isGreaterOrEqual(_ a: [UInt64], _ b: [UInt64]) -> Bool {
for i in stride(from: 3, through: 0, by: -1) {
if a[i] > b[i] { return true }
if a[i] < b[i] { return false }
}
return true // equal
}
/// Modular inverse using Fermat's little theorem: a^(-1) = a^(p-2) mod p
static func inverse(_ a: [UInt64]) -> [UInt64] {
// p - 2 = 2^255 - 21
let pMinus2 = sub(p, [2, 0, 0, 0])
return power(a, pMinus2)
}
/// Modular exponentiation using square-and-multiply
static func power(_ base: [UInt64], _ exp: [UInt64]) -> [UInt64] {
var result: [UInt64] = [1, 0, 0, 0] // 1
var b = base
for i in 0..<4 {
var limb = exp[i]
let bits = (i == 3) ? 63 : 64 // top limb has 63 bits for p-2
for _ in 0..<bits {
if limb & 1 == 1 {
result = mul(result, b)
}
b = mul(b, b)
limb >>= 1
}
}
return result
}
// MARK: - Ed25519 X25519 Public Key Conversion
/// Convert Ed25519 public key (32 bytes) to X25519 public key (32 bytes).
/// Formula: u = (1 + y) * inverse(1 - y) mod p
static func ed25519PublicToX25519(_ ed25519Pub: Data) -> Data {
precondition(ed25519Pub.count == 32)
// Ed25519 public key is the y-coordinate with sign bit in the top bit of byte 31
var keyBytes = ed25519Pub
// Clear the sign bit
keyBytes[31] &= 0x7F
let y = load(keyBytes)
let one: [UInt64] = [1, 0, 0, 0]
let onePlusY = add(one, y)
let oneMinusY = sub(one, y)
let inv = inverse(oneMinusY)
let u = mul(onePlusY, inv)
return store(u)
}
}

View File

@@ -0,0 +1,106 @@
import Foundation
import CryptoKit
import CommonCrypto
/// ECP1 key encryption format: PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM
/// Wire format: magic(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
enum KeyEncryption {
/// Encrypt raw key bytes with password using ECP1 format
static func encrypt(_ rawBytes: Data, password: Data) throws -> Data {
let salt = Data.randomBytes(16)
let derivedKey = try pbkdf2(password: password, salt: salt)
let nonce = Data.randomBytes(12)
let symmetricKey = SymmetricKey(data: derivedKey)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
// AAD = ECP1 magic bytes (matching Python)
let sealedBox = try AES.GCM.seal(
rawBytes,
using: symmetricKey,
nonce: gcmNonce,
authenticating: Constants.ecp1Magic
)
// ciphertext + tag concatenated (matches Python's AESGCM.encrypt output)
var result = Data()
result.append(Constants.ecp1Magic) // 4 bytes
result.append(salt) // 16 bytes
result.append(nonce) // 12 bytes
result.append(sealedBox.ciphertext) // N bytes
result.append(sealedBox.tag) // 16 bytes
return result
}
/// Decrypt ECP1-encrypted key bytes with password
static func decrypt(_ data: Data, password: Data) throws -> Data {
guard data.count >= 48 else { // 4 + 16 + 12 + 16 minimum
throw CryptoError.invalidECP1Format
}
guard data.prefix(4) == Constants.ecp1Magic else {
throw CryptoError.invalidECP1Format
}
let salt = data[4..<20]
let nonce = data[20..<32]
let ctWithTag = data[32...]
guard ctWithTag.count >= 16 else {
throw CryptoError.invalidECP1Format
}
let derivedKey = try pbkdf2(password: password, salt: Data(salt))
let symmetricKey = SymmetricKey(data: derivedKey)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
// Split ciphertext and tag
let ct = ctWithTag.prefix(ctWithTag.count - 16)
let tag = ctWithTag.suffix(16)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ct,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: Constants.ecp1Magic)
} catch {
throw CryptoError.decryptionFailed("ECP1 decryption failed - wrong password?")
}
}
/// Check if data starts with ECP1 magic
static func isECP1Format(_ data: Data) -> Bool {
data.count >= 4 && data.prefix(4) == Constants.ecp1Magic
}
// MARK: - PBKDF2
/// Derive 32-byte key using PBKDF2-HMAC-SHA256 with 600k iterations
static func pbkdf2(password: Data, salt: Data) throws -> Data {
var derivedKey = Data(count: 32)
let status = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
password.withUnsafeBytes { passwordPtr in
salt.withUnsafeBytes { saltPtr in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordPtr.baseAddress?.assumingMemoryBound(to: Int8.self),
password.count,
saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
Constants.pbkdf2Iterations,
derivedKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
32
)
}
}
}
guard status == kCCSuccess else {
throw CryptoError.pbkdf2Failed
}
return derivedKey
}
}

View File

@@ -0,0 +1,309 @@
import Foundation
import Security
/// RSA-4096 operations used for login challenge-response ONLY
enum RSACrypto {
// MARK: - Key Generation
/// Generate RSA-4096 keypair
static func generateKeypair() throws -> (privateKey: SecKey, publicKey: SecKey) {
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits as String: 4096,
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw CryptoError.rsaKeyGenerationFailed
}
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw CryptoError.rsaKeyGenerationFailed
}
return (privateKey, publicKey)
}
// MARK: - Serialization
/// Serialize RSA private key. With password: DER ECP1. Without: PEM PKCS#8.
static func serializePrivateKey(_ key: SecKey, password: Data? = nil) throws -> Data {
var error: Unmanaged<CFError>?
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
throw CryptoError.rsaOperationFailed("Failed to export private key")
}
// SecKey exports in PKCS#1 format on iOS wrap in PKCS#8 for Python compat
let pkcs8 = wrapRSAPrivateKeyPKCS8(derData)
if let password = password {
return try KeyEncryption.encrypt(pkcs8, password: password)
}
// PEM encode for Python compatibility
return pemEncode(pkcs8, label: "PRIVATE KEY")
}
/// Serialize RSA public key as PEM SubjectPublicKeyInfo (Python-compatible)
static func serializePublicKey(_ key: SecKey) throws -> Data {
var error: Unmanaged<CFError>?
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
throw CryptoError.rsaOperationFailed("Failed to export public key")
}
// SecKey exports PKCS#1 on iOS wrap in SubjectPublicKeyInfo
let spki = wrapRSAPublicKeySPKI(derData)
return pemEncode(spki, label: "PUBLIC KEY")
}
/// Load RSA private key. Auto-detects ECP1 vs PEM format.
static func loadPrivateKey(_ data: Data, password: Data? = nil) throws -> SecKey {
let derData: Data
if KeyEncryption.isECP1Format(data) {
guard let pwd = password else {
throw CryptoError.invalidKeyData("ECP1 key requires password")
}
let raw = try KeyEncryption.decrypt(data, password: pwd)
derData = unwrapPKCS8ToRSAPrivateKey(raw)
} else {
// PEM format
let pem = String(data: data, encoding: .utf8) ?? ""
derData = try pemDecode(pem, label: "PRIVATE KEY")
.flatMap { unwrapPKCS8ToRSAPrivateKey($0) }
?? pemDecode(pem, label: "RSA PRIVATE KEY")
?? { throw CryptoError.invalidKeyData("Cannot parse RSA private key PEM") }()
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
throw CryptoError.invalidKeyData("Failed to create RSA private key from DER")
}
return key
}
/// Load RSA public key from PEM
static func loadPublicKey(_ pemData: Data) throws -> SecKey {
let pem = String(data: pemData, encoding: .utf8) ?? ""
// Try SubjectPublicKeyInfo (PUBLIC KEY), unwrap to PKCS#1
let derData: Data
if let spki = pemDecode(pem, label: "PUBLIC KEY") {
derData = unwrapSPKIToRSAPublicKey(spki)
} else if let pkcs1 = pemDecode(pem, label: "RSA PUBLIC KEY") {
derData = pkcs1
} else {
throw CryptoError.invalidKeyData("Cannot parse RSA public key PEM")
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
throw CryptoError.invalidKeyData("Failed to create RSA public key from DER")
}
return key
}
// MARK: - Sign / Verify
/// Sign data with RSA-PSS SHA-256.
/// Note: iOS uses salt_length = hash_length (32). Server must use PSS.AUTO to verify.
static func sign(_ privateKey: SecKey, data: Data) throws -> Data {
var error: Unmanaged<CFError>?
guard let signature = SecKeyCreateSignature(
privateKey,
.rsaSignatureMessagePSSSHA256,
data as CFData,
&error
) as Data? else {
throw CryptoError.rsaOperationFailed("RSA signing failed")
}
return signature
}
/// Verify RSA-PSS SHA-256 signature
static func verify(_ publicKey: SecKey, signature: Data, data: Data) -> Bool {
SecKeyVerifySignature(
publicKey,
.rsaSignatureMessagePSSSHA256,
data as CFData,
signature as CFData,
nil
)
}
// MARK: - PEM Helpers
private static func pemEncode(_ der: Data, label: String) -> Data {
let base64 = der.base64EncodedString(options: .lineLength64Characters)
let pem = "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n"
return Data(pem.utf8)
}
private static func pemDecode(_ pem: String, label: String) -> Data? {
let beginMarker = "-----BEGIN \(label)-----"
let endMarker = "-----END \(label)-----"
guard let beginRange = pem.range(of: beginMarker),
let endRange = pem.range(of: endMarker) else {
return nil
}
let base64String = pem[beginRange.upperBound..<endRange.lowerBound]
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
return Data(base64Encoded: base64String)
}
// MARK: - ASN.1 PKCS#8 / SPKI Wrappers
// SecKey on iOS exports RSA keys in PKCS#1 format, but Python expects PKCS#8 / SPKI.
// These functions add/remove the ASN.1 wrapping.
// RSA OID: 1.2.840.113549.1.1.1
private static let rsaOID: [UInt8] = [0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
private static let nullParam: [UInt8] = [0x05, 0x00]
/// Wrap PKCS#1 RSA private key in PKCS#8 PrivateKeyInfo envelope
private static func wrapRSAPrivateKeyPKCS8(_ pkcs1: Data) -> Data {
// PrivateKeyInfo ::= SEQUENCE {
// version INTEGER (0),
// algorithm AlgorithmIdentifier,
// privateKey OCTET STRING (containing PKCS#1 key)
// }
let version = Data([0x02, 0x01, 0x00]) // INTEGER 0
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
let privateKeyOctet = asn1OctetString(pkcs1)
return asn1Sequence(version + algorithmSeq + privateKeyOctet)
}
/// Unwrap PKCS#8 to get PKCS#1 RSA private key
private static func unwrapPKCS8ToRSAPrivateKey(_ pkcs8: Data) -> Data {
// Parse SEQUENCE, skip version + algorithm, extract OCTET STRING
guard pkcs8.count > 2 else { return pkcs8 }
var offset = 0
// Outer SEQUENCE
guard pkcs8[offset] == 0x30 else { return pkcs8 }
offset += 1
offset = skipASN1Length(pkcs8, offset: offset)
// Version INTEGER
guard offset < pkcs8.count, pkcs8[offset] == 0x02 else { return pkcs8 }
offset += 1
let versionLen = readASN1Length(pkcs8, offset: &offset)
offset += versionLen
// Algorithm SEQUENCE
guard offset < pkcs8.count, pkcs8[offset] == 0x30 else { return pkcs8 }
offset += 1
let algoLen = readASN1Length(pkcs8, offset: &offset)
offset += algoLen
// Private key OCTET STRING
guard offset < pkcs8.count, pkcs8[offset] == 0x04 else { return pkcs8 }
offset += 1
let keyLen = readASN1Length(pkcs8, offset: &offset)
guard offset + keyLen <= pkcs8.count else { return pkcs8 }
return Data(pkcs8[offset..<(offset + keyLen)])
}
/// Wrap PKCS#1 RSA public key in SubjectPublicKeyInfo
private static func wrapRSAPublicKeySPKI(_ pkcs1: Data) -> Data {
// SubjectPublicKeyInfo ::= SEQUENCE {
// algorithm AlgorithmIdentifier,
// subjectPublicKey BIT STRING (containing PKCS#1 key)
// }
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
let bitString = asn1BitString(pkcs1)
return asn1Sequence(algorithmSeq + bitString)
}
/// Unwrap SubjectPublicKeyInfo to get PKCS#1 RSA public key
private static func unwrapSPKIToRSAPublicKey(_ spki: Data) -> Data {
guard spki.count > 2 else { return spki }
var offset = 0
// Outer SEQUENCE
guard spki[offset] == 0x30 else { return spki }
offset += 1
offset = skipASN1Length(spki, offset: offset)
// Algorithm SEQUENCE
guard offset < spki.count, spki[offset] == 0x30 else { return spki }
offset += 1
let algoLen = readASN1Length(spki, offset: &offset)
offset += algoLen
// BIT STRING
guard offset < spki.count, spki[offset] == 0x03 else { return spki }
offset += 1
let bitLen = readASN1Length(spki, offset: &offset)
// Skip the unused bits byte
guard offset < spki.count, spki[offset] == 0x00 else { return spki }
offset += 1
let keyLen = bitLen - 1
guard offset + keyLen <= spki.count else { return spki }
return Data(spki[offset..<(offset + keyLen)])
}
// MARK: - ASN.1 Primitives
private static func asn1Length(_ length: Int) -> Data {
if length < 0x80 {
return Data([UInt8(length)])
} else if length <= 0xFF {
return Data([0x81, UInt8(length)])
} else if length <= 0xFFFF {
return Data([0x82, UInt8(length >> 8), UInt8(length & 0xFF)])
} else {
return Data([0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)])
}
}
private static func asn1Sequence(_ content: Data) -> Data {
Data([0x30]) + asn1Length(content.count) + content
}
private static func asn1OctetString(_ content: Data) -> Data {
Data([0x04]) + asn1Length(content.count) + content
}
private static func asn1BitString(_ content: Data) -> Data {
// BIT STRING: tag + length + unused_bits(0) + content
Data([0x03]) + asn1Length(content.count + 1) + Data([0x00]) + content
}
private static func readASN1Length(_ data: Data, offset: inout Int) -> Int {
guard offset < data.count else { return 0 }
let first = data[offset]
offset += 1
if first < 0x80 {
return Int(first)
}
let numBytes = Int(first & 0x7F)
var length = 0
for _ in 0..<numBytes {
guard offset < data.count else { return length }
length = (length << 8) | Int(data[offset])
offset += 1
}
return length
}
private static func skipASN1Length(_ data: Data, offset: Int) -> Int {
var off = offset
_ = readASN1Length(data, offset: &off)
return off
}
}

View File

@@ -0,0 +1,175 @@
import Foundation
import CryptoKit
/// Sender key chain for group messaging.
/// Each sender in a group has their own chain. Others receive the initial key via pairwise ratchet.
/// Matches Python: SenderKeyState class in crypto_utils.py
class SenderKeyState {
let senderKey: Data
let chainId: Data
private(set) var chainKey: Data
private(set) var n: Int
private var knownKeys: [Int: Data]
/// Initialize with optional sender key (generates random 32B if nil).
/// Matches Python: SenderKeyState.__init__(sender_key=None)
init(senderKey: Data? = nil) {
let key = senderKey ?? Data.randomBytes(32)
self.senderKey = key
self.chainId = Data(SHA256.hash(data: key))
self.chainKey = CryptoUtils.hkdfDerive(
inputKey: key,
salt: Data(repeating: 0x00, count: 32),
info: Data(Constants.senderKeyChainInfo.utf8),
length: 32
)
self.n = 0
self.knownKeys = [:]
}
/// Private init for import
private init(senderKey: Data, chainId: Data, chainKey: Data, n: Int, knownKeys: [Int: Data]) {
self.senderKey = senderKey
self.chainId = chainId
self.chainKey = chainKey
self.n = n
self.knownKeys = knownKeys
}
// MARK: - Encrypt
/// Encrypt with current chain key.
/// Returns (chainId hex, n, ciphertext with tag, nonce).
/// Matches Python: SenderKeyState.encrypt(plaintext)
func encrypt(_ plaintext: Data) throws -> (chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) {
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: chainKey)
chainKey = newCK
let nonce = Data.randomBytes(12)
// AAD = chainId + bigEndian(UInt32(n))
let aad = chainId + UInt32(n).bigEndianData
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
let result = (chainIdHex: chainId.hexString, n: n, ciphertext: ctWithTag, nonce: nonce)
n += 1
return result
}
// MARK: - Decrypt
/// Decrypt a group message. Fast-forwards the chain if needed.
/// State is snapshotted before modification and restored on failure.
/// Matches Python: SenderKeyState.decrypt(chain_id_hex, n, ciphertext, nonce)
func decrypt(chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) throws -> Data {
guard let expectedChainId = Data(hexString: chainIdHex) else {
throw CryptoError.senderKeyError("Invalid chain ID hex")
}
guard expectedChainId == chainId else {
throw CryptoError.senderKeyError("Chain ID mismatch")
}
if n - self.n > Constants.maxSenderKeySkip {
throw CryptoError.senderKeyError("Sender key skip too large (\(n - self.n) > \(Constants.maxSenderKeySkip))")
}
// Snapshot before fast-forward
let snapChainKey = chainKey
let snapN = self.n
let snapKnown = knownKeys
do {
// Fast-forward the chain to reach message n
while self.n <= n {
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: chainKey)
chainKey = newCK
knownKeys[self.n] = mk
self.n += 1
}
guard let mk = knownKeys.removeValue(forKey: n) else {
throw CryptoError.senderKeyError("Message key for n=\(n) not available")
}
let aad = chainId + UInt32(n).bigEndianData
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
} catch {
// Restore state on failure
chainKey = snapChainKey
self.n = snapN
knownKeys = snapKnown
throw error
}
}
// MARK: - Key Export/Import
/// Export sender key for distribution to group members.
/// Matches Python: SenderKeyState.export_key()
func exportKey() -> Data {
let dict: [String: Any] = ["sender_key": senderKey.hexString]
return try! JSONSerialization.data(withJSONObject: dict)
}
/// Initialize a receiving SenderKeyState from an exported key.
/// Matches Python: SenderKeyState.from_key(exported_key)
static func fromKey(_ exportedKey: Data) throws -> SenderKeyState {
guard let dict = try JSONSerialization.jsonObject(with: exportedKey) as? [String: Any],
let senderKeyHex = dict["sender_key"] as? String,
let senderKey = Data(hexString: senderKeyHex) else {
throw CryptoError.stateImportFailed("Invalid sender key export")
}
return SenderKeyState(senderKey: senderKey)
}
// MARK: - Full State Export/Import
/// Serialize full state for persistent storage.
/// Matches Python: SenderKeyState.export_state()
func exportState() -> Data {
var knownKeysDict: [String: String] = [:]
for (k, v) in knownKeys {
knownKeysDict[String(k)] = v.hexString
}
let state: [String: Any] = [
"sender_key": senderKey.hexString,
"chain_id": chainId.hexString,
"chain_key": chainKey.hexString,
"n": n,
"known_keys": knownKeysDict,
]
return try! JSONSerialization.data(withJSONObject: state)
}
/// Deserialize full state.
/// Matches Python: SenderKeyState.import_state(data)
static func importState(_ data: Data) throws -> SenderKeyState {
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let senderKeyHex = state["sender_key"] as? String,
let senderKey = Data(hexString: senderKeyHex),
let chainIdHex = state["chain_id"] as? String,
let chainId = Data(hexString: chainIdHex),
let chainKeyHex = state["chain_key"] as? String,
let chainKey = Data(hexString: chainKeyHex),
let n = state["n"] as? Int else {
throw CryptoError.stateImportFailed("Invalid sender key state")
}
var knownKeys: [Int: Data] = [:]
if let knownKeysDict = state["known_keys"] as? [String: String] {
for (k, v) in knownKeysDict {
if let idx = Int(k), let data = Data(hexString: v) {
knownKeys[idx] = data
}
}
}
return SenderKeyState(
senderKey: senderKey,
chainId: chainId,
chainKey: chainKey,
n: n,
knownKeys: knownKeys
)
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import CryptoKit
/// X25519 Diffie-Hellman key agreement
enum X25519Crypto {
// MARK: - Key Generation
/// Generate X25519 keypair
static func generateKeypair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
let privateKey = Curve25519.KeyAgreement.PrivateKey()
return (privateKey, privateKey.publicKey)
}
// MARK: - Serialization
/// Serialize X25519 private key to 32 raw bytes
static func serializePrivate(_ key: Curve25519.KeyAgreement.PrivateKey) -> Data {
key.rawData // 32 bytes
}
/// Serialize X25519 public key to 32 raw bytes
static func serializePublic(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
key.rawData // 32 bytes
}
/// Load X25519 private key from 32 raw bytes
static func loadPrivate(_ data: Data) throws -> Curve25519.KeyAgreement.PrivateKey {
guard data.count == 32 else {
throw CryptoError.invalidKeyData("X25519 private key must be 32 bytes")
}
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)
}
/// Load X25519 public key from 32 raw bytes
static func loadPublic(_ data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
guard data.count == 32 else {
throw CryptoError.invalidKeyData("X25519 public key must be 32 bytes")
}
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
}
// MARK: - Diffie-Hellman
/// Perform X25519 DH key agreement. Returns 32-byte shared secret.
/// Matches Python: x25519_dh(private_key, public_key)
static func dh(_ privateKey: Curve25519.KeyAgreement.PrivateKey, _ publicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
// Extract raw bytes from SharedSecret
return sharedSecret.withUnsafeBytes { Data($0) }
}
// MARK: - Ed25519 X25519 Key Conversion
/// Convert Ed25519 private key to X25519 private key.
/// SHA-512(seed) take first 32 bytes clamp per RFC 7748
/// Matches Python: ed25519_private_to_x25519(ed_private)
static func fromEd25519Private(_ edPrivate: Curve25519.Signing.PrivateKey) throws -> Curve25519.KeyAgreement.PrivateKey {
let raw = edPrivate.rawData // 32 bytes seed
// SHA-512 of the seed
let hash = SHA512.hash(data: raw)
var clamped = Data(hash.prefix(32))
// Clamp per RFC 7748
clamped[0] &= 248
clamped[31] &= 127
clamped[31] |= 64
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: clamped)
}
/// Convert Ed25519 public key to X25519 public key.
/// Uses Montgomery birational map: u = (1+y)/(1-y) mod p
/// Matches Python: ed25519_public_to_x25519(ed_public)
static func fromEd25519Public(_ edPublic: Curve25519.Signing.PublicKey) throws -> Curve25519.KeyAgreement.PublicKey {
let x25519Bytes = FieldArithmetic.ed25519PublicToX25519(edPublic.rawData)
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: x25519Bytes)
}
}

View File

@@ -0,0 +1,118 @@
import Foundation
import CryptoKit
/// X3DH key agreement protocol (Signal Protocol)
enum X3DH {
// MARK: - Pre-Key Generation
/// Generate a signed pre-key (SPK).
/// Returns (private, public, signature, id).
/// Matches Python: generate_signed_prekey(identity_private)
static func generateSignedPrekey(
identityPrivate: Curve25519.Signing.PrivateKey
) throws -> (privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey,
signature: Data,
id: String) {
let (spkPriv, spkPub) = X25519Crypto.generateKeypair()
let spkPubBytes = X25519Crypto.serializePublic(spkPub)
let signature = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes)
return (spkPriv, spkPub, signature, UUID().uuidString)
}
/// Generate a batch of one-time pre-keys.
/// Matches Python: generate_one_time_prekeys(count=50)
static func generateOneTimePrekeys(count: Int = 50) -> [(privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey,
id: String)] {
(0..<count).map { _ in
let (priv, pub) = X25519Crypto.generateKeypair()
return (priv, pub, UUID().uuidString)
}
}
// MARK: - X3DH Initiate (Alice)
/// Initiator side of X3DH.
/// Returns (sharedSecret, ephemeralPrivate, ephemeralPublic).
/// Matches Python: x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?)
static func initiate(
ikPrivateEd: Curve25519.Signing.PrivateKey,
ikPublicRemoteEd: Curve25519.Signing.PublicKey,
spkRemote: Curve25519.KeyAgreement.PublicKey,
spkSignature: Data,
opkRemote: Curve25519.KeyAgreement.PublicKey? = nil
) throws -> (sharedSecret: Data,
ephemeralPrivate: Curve25519.KeyAgreement.PrivateKey,
ephemeralPublic: Curve25519.KeyAgreement.PublicKey) {
// Verify SPK signature
let spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
guard Ed25519Crypto.verify(ikPublicRemoteEd, signature: spkSignature, data: spkRemoteBytes) else {
throw CryptoError.x3dhFailed("Invalid SPK signature")
}
// Convert identity keys to X25519
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikPublicRemoteEd)
// Generate ephemeral keypair
let (ekPriv, ekPub) = X25519Crypto.generateKeypair()
// DH computations
let dh1 = try X25519Crypto.dh(ikX25519Private, spkRemote) // IK_A, SPK_B
let dh2 = try X25519Crypto.dh(ekPriv, ikX25519Remote) // EK_A, IK_B
let dh3 = try X25519Crypto.dh(ekPriv, spkRemote) // EK_A, SPK_B
var dhConcat = dh1 + dh2 + dh3
if let opk = opkRemote {
let dh4 = try X25519Crypto.dh(ekPriv, opk) // EK_A, OPK_B
dhConcat += dh4
}
// Derive shared secret
let sharedSecret = CryptoUtils.hkdfDerive(
inputKey: dhConcat,
salt: Data(repeating: 0x00, count: 32),
info: Data(Constants.x3dhInfo.utf8),
length: 32
)
return (sharedSecret, ekPriv, ekPub)
}
// MARK: - X3DH Respond (Bob)
/// Responder side of X3DH.
/// Returns sharedSecret.
/// Matches Python: x3dh_respond(ik_private_ed, spk_private, ik_remote_ed, ek_remote, opk_private?)
static func respond(
ikPrivateEd: Curve25519.Signing.PrivateKey,
spkPrivate: Curve25519.KeyAgreement.PrivateKey,
ikRemoteEd: Curve25519.Signing.PublicKey,
ekRemote: Curve25519.KeyAgreement.PublicKey,
opkPrivate: Curve25519.KeyAgreement.PrivateKey? = nil
) throws -> Data {
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikRemoteEd)
let dh1 = try X25519Crypto.dh(spkPrivate, ikX25519Remote) // SPK_B, IK_A
let dh2 = try X25519Crypto.dh(ikX25519Private, ekRemote) // IK_B, EK_A
let dh3 = try X25519Crypto.dh(spkPrivate, ekRemote) // SPK_B, EK_A
var dhConcat = dh1 + dh2 + dh3
if let opk = opkPrivate {
let dh4 = try X25519Crypto.dh(opk, ekRemote) // OPK_B, EK_A
dhConcat += dh4
}
let sharedSecret = CryptoUtils.hkdfDerive(
inputKey: dhConcat,
salt: Data(repeating: 0x00, count: 32),
info: Data(Constants.x3dhInfo.utf8),
length: 32
)
return sharedSecret
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
struct Conversation: Identifiable, Equatable {
let id: String
var name: String?
var members: [ConversationMember]
var createdBy: String?
var avatarFile: String?
var unreadCount: Int
var isFavorite: Bool
var lastMessageTime: Date?
var isGroup: Bool {
name != nil || members.count > 2
}
/// Display name: group name, or DM partner username
func displayName(currentUserId: String) -> String {
if let name = name, !name.isEmpty {
return name
}
// DM: show the other person's name
if let other = members.first(where: { $0.userId != currentUserId }) {
return other.username
}
return "Unknown"
}
/// DM partner user ID (nil for groups)
func dmPartnerId(currentUserId: String) -> String? {
guard !isGroup else { return nil }
return members.first(where: { $0.userId != currentUserId })?.userId
}
static func == (lhs: Conversation, rhs: Conversation) -> Bool {
lhs.id == rhs.id
}
}
struct ConversationMember: Identifiable, Equatable, Codable {
let userId: String
var username: String
var email: String
var id: String { userId }
}

View File

@@ -0,0 +1,43 @@
import Foundation
/// Key bundle for one device, used in X3DH
struct DeviceBundle {
let deviceId: String
let identityKey: Data // Ed25519 public key (32 bytes)
let spk: Data // X25519 public key (32 bytes)
let spkSignature: Data // Ed25519 signature (64 bytes)
let spkId: String
let opk: Data? // X25519 public key (32 bytes), optional
let opkId: String?
/// Parse from server response dictionary
static func fromDict(_ dict: [String: Any]) throws -> DeviceBundle {
guard let deviceId = dict["device_id"] as? String,
let ikHex = dict["identity_key"] as? String,
let ik = Data(hexString: ikHex),
let spkHex = dict["spk"] as? String,
let spk = Data(hexString: spkHex),
let spkSigHex = dict["spk_signature"] as? String,
let spkSig = Data(hexString: spkSigHex),
let spkId = dict["spk_id"] as? String else {
throw ChatError.invalidData("Invalid device bundle")
}
var opk: Data?
var opkId: String?
if let opkHex = dict["opk"] as? String, let opkData = Data(hexString: opkHex) {
opk = opkData
opkId = dict["opk_id"] as? String
}
return DeviceBundle(
deviceId: deviceId,
identityKey: ik,
spk: spk,
spkSignature: spkSig,
spkId: spkId,
opk: opk,
opkId: opkId
)
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
struct Invitation: Identifiable {
let id: String // invitation id (from server) or conversationId
let conversationId: String
let conversationName: String
let invitedBy: String
let invitedByUsername: String
}

View File

@@ -0,0 +1,33 @@
import Foundation
struct Message: Identifiable, Equatable {
let id: String
let conversationId: String
let senderId: String
var senderUsername: String
let createdAt: Date
var text: String?
var replyTo: String?
var imageFileId: String?
var file: FileInfo?
var isDeleted: Bool
var readBy: Set<String>
/// Whether this is a self-sent message
func isMine(currentUserId: String) -> Bool {
senderId == currentUserId
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
}
struct FileInfo: Equatable, Codable {
let fileId: String
let aesKey: String // hex
let iv: String // hex
let filename: String
let size: Int
let mimeType: String
}

View File

@@ -0,0 +1,19 @@
import Foundation
struct User: Identifiable, Equatable {
let id: String
var username: String
var email: String
var identityKey: Data? // Ed25519 public key (32 bytes)
}
struct UserProfile: Equatable {
var userId: String
var username: String?
var email: String?
var phone: String?
var phoneVisible: Bool
var location: String?
var locationVisible: Bool
var avatarFile: String?
}

View File

@@ -0,0 +1,188 @@
import Foundation
import Network
/// TCP connection manager using Network.framework.
/// Handles connection lifecycle, TLS, buffered reading (newline-delimited), and writing.
actor ConnectionManager {
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case failed(String)
}
private var connection: NWConnection?
private var receiveBuffer = Data()
private(set) var state: ConnectionState = .disconnected
private var stateCallback: ((ConnectionState) -> Void)?
private var messageStream: AsyncStream<[String: Any]>.Continuation?
/// Set a callback for connection state changes
func onStateChange(_ callback: @escaping (ConnectionState) -> Void) {
stateCallback = callback
}
// MARK: - Connect / Disconnect
/// Connect to server
func connect(host: String, port: UInt16, useTLS: Bool = false, tlsInsecure: Bool = false) async throws {
guard state == .disconnected || state != .connected else {
throw NetworkError.alreadyConnected
}
updateState(.connecting)
let nwHost = NWEndpoint.Host(host)
let nwPort = NWEndpoint.Port(rawValue: port)!
let params: NWParameters
if useTLS {
let tlsOptions = NWProtocolTLS.Options()
if tlsInsecure {
// Skip certificate verification (dev only)
sec_protocol_options_set_verify_block(
tlsOptions.securityProtocolOptions,
{ _, _, completionHandler in completionHandler(true) },
.main
)
}
params = NWParameters(tls: tlsOptions, tcp: .init())
} else {
params = .tcp
}
let conn = NWConnection(host: nwHost, port: nwPort, using: params)
self.connection = conn
self.receiveBuffer = Data()
return try await withCheckedThrowingContinuation { continuation in
conn.stateUpdateHandler = { [weak self] newState in
Task { [weak self] in
guard let self = self else { return }
switch newState {
case .ready:
await self.updateState(.connected)
continuation.resume()
case .failed(let error):
await self.updateState(.failed(error.localizedDescription))
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
case .cancelled:
await self.updateState(.disconnected)
case .waiting(let error):
await self.updateState(.failed(error.localizedDescription))
continuation.resume(throwing: NetworkError.connectionFailed("Waiting: \(error.localizedDescription)"))
default:
break
}
}
}
conn.start(queue: .global(qos: .userInitiated))
}
}
/// Disconnect from server
func disconnect() {
connection?.cancel()
connection = nil
receiveBuffer = Data()
updateState(.disconnected)
messageStream?.finish()
messageStream = nil
}
// MARK: - Send
/// Send raw data over the connection
func send(_ data: Data) async throws {
guard let connection = connection, state == .connected else {
throw NetworkError.notConnected
}
return try await withCheckedThrowingContinuation { continuation in
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
} else {
continuation.resume()
}
})
}
}
/// Send a protocol message (builds JSON + newline, sends)
func sendMessage(type: String, requestId: String? = nil, params: [String: Any] = [:]) async throws {
let data = try ProtocolHandler.buildRequest(type: type, requestId: requestId, params: params)
try await send(data)
}
// MARK: - Receive
/// Read one newline-delimited JSON message.
/// Returns nil on EOF / connection close.
func readMessage() async throws -> [String: Any]? {
while true {
// Check buffer for a complete line
if let newlineIndex = receiveBuffer.firstIndex(of: 0x0A) {
let lineData = receiveBuffer.prefix(through: newlineIndex)
receiveBuffer.removeSubrange(...newlineIndex)
// Check size
if lineData.count > Constants.maxMessageBytes {
throw NetworkError.messageTooLarge
}
return try ProtocolHandler.parseMessage(Data(lineData))
}
// Buffer doesn't have a complete line read more from the connection
guard let connection = connection else {
return nil
}
let chunk = try await receiveChunk(connection: connection)
guard let chunk = chunk else {
return nil // EOF
}
receiveBuffer.append(chunk)
// Safety: if buffer exceeds max without a newline, drop it
if receiveBuffer.count > Constants.maxMessageBytes * 2 {
receiveBuffer = Data()
throw NetworkError.messageTooLarge
}
}
}
/// Read a chunk of data from the connection
private func receiveChunk(connection: NWConnection) async throws -> Data? {
return try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in
if let error = error {
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
return
}
if let content = content, !content.isEmpty {
continuation.resume(returning: content)
} else if isComplete {
continuation.resume(returning: nil)
} else {
// No data and not complete shouldn't happen but return nil
continuation.resume(returning: nil)
}
}
}
}
// MARK: - State
var isConnected: Bool {
state == .connected
}
private func updateState(_ newState: ConnectionState) {
state = newState
stateCallback?(newState)
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
/// Newline-delimited JSON protocol handler.
/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary
enum ProtocolHandler {
/// Build a request message (newline-terminated JSON).
/// Matches Python: build_request(msg_type, request_id=None, **kwargs)
static func buildRequest(type: String, requestId: String? = nil, params: [String: Any] = [:]) throws -> Data {
var msg: [String: Any] = ["type": type]
if let requestId = requestId {
msg["request_id"] = requestId
}
// Merge params into msg
for (key, value) in params {
msg[key] = value
}
let jsonData = try JSONSerialization.data(withJSONObject: msg)
guard jsonData.count < Constants.maxMessageBytes else {
throw NetworkError.messageTooLarge
}
return jsonData + Data([0x0A]) // newline
}
/// Build a response message (newline-terminated JSON).
static func buildResponse(type: String, status: String, data: [String: Any]? = nil, requestId: String? = nil) throws -> Data {
var msg: [String: Any] = ["type": type, "status": status]
if let data = data {
msg["data"] = data
}
if let requestId = requestId {
msg["request_id"] = requestId
}
let jsonData = try JSONSerialization.data(withJSONObject: msg)
guard jsonData.count < Constants.maxMessageBytes else {
throw NetworkError.messageTooLarge
}
return jsonData + Data([0x0A])
}
/// Parse a single protocol message from bytes.
/// Matches Python: parse_message(line)
static func parseMessage(_ data: Data) throws -> [String: Any] {
let trimmed = data.trimmingNewlines()
guard !trimmed.isEmpty else {
throw NetworkError.protocolError("Empty message")
}
guard let obj = try JSONSerialization.jsonObject(with: trimmed) as? [String: Any] else {
throw NetworkError.protocolError("Message is not a JSON object")
}
return obj
}
/// Encode bytes to base64 string.
/// Matches Python: encode_binary(data)
static func encodeBinary(_ data: Data) -> String {
data.base64EncodedString(options: [])
}
/// Decode base64 string to bytes.
/// Matches Python: decode_binary(data)
static func decodeBinary(_ string: String) throws -> Data {
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
throw CryptoError.invalidBase64
}
return data
}
/// Generate a new request ID (UUID string).
static func newRequestId() -> String {
UUID().uuidString
}
}
// MARK: - Data Helpers
private extension Data {
func trimmingNewlines() -> Data {
var data = self
while let last = data.last, last == 0x0A || last == 0x0D {
data.removeLast()
}
while let first = data.first, first == 0x0A || first == 0x0D {
data.removeFirst()
}
return data
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
enum Constants {
static let version = "0.8.2"
static let maxMessageBytes = 65536
static let maxImageBytes = 5 * 1024 * 1024 // 5 MB
static let maxFileBytes = 50 * 1024 * 1024 // 50 MB
static let imageChunkSize = 32768 // 32 KB
static let selfDeviceId = "00000000-0000-0000-0000-000000000000"
static let opkReplenishThreshold = 20
static let opkBatchSize = 50
static let spkRotationDays = 7
static let maxSkip = 256
static let maxSenderKeySkip = 256
static let deviceBundleCacheTTL: TimeInterval = 300 // 5 minutes
static let sendReceiveTimeout: TimeInterval = 30
static let reconnectBaseDelay: TimeInterval = 1
static let reconnectMaxDelay: TimeInterval = 30
static let pbkdf2Iterations: UInt32 = 600_000
static let ecp1Magic = Data([0x45, 0x43, 0x50, 0x31]) // "ECP1"
// HKDF info/salt strings matching Python
static let x3dhInfo = "EncryptedChat_X3DH"
static let rootKeyInfo = "EncryptedChat_RootKey"
static let selfEncryptionSalt = "self_encryption"
static let selfEncryptionInfo = "EncryptedChat_SelfKey"
static let localStorageSalt = "local_storage"
static let localStorageInfo = "EncryptedChat_LocalStorage"
static let senderKeyChainInfo = "SenderKeyChain"
// Server connection defaults
static let defaultHost = "127.0.0.1"
static let defaultPort: UInt16 = 9999
}

View File

@@ -0,0 +1,132 @@
import Foundation
import CryptoKit
// MARK: - Data Hex
extension Data {
/// Convert data to lowercase hex string
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string
init?(hexString: String) {
let hex = hexString.lowercased()
guard hex.count % 2 == 0 else { return nil }
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
self = data
}
/// Generate random bytes
static func randomBytes(_ count: Int) -> Data {
var data = Data(count: count)
data.withUnsafeMutableBytes { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
}
return data
}
}
// MARK: - Data Base64 (Protocol Wire Format)
extension Data {
/// Encode to standard base64 string (matching Python's base64.b64encode)
func base64EncodedString() -> String {
self.base64EncodedString(options: [])
}
/// Decode from base64 string
static func fromBase64(_ string: String) throws -> Data {
// Try standard base64 first, then URL-safe
if let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) {
return data
}
throw CryptoError.invalidBase64
}
}
// MARK: - UInt32 Big-Endian
extension UInt32 {
var bigEndianData: Data {
var value = self.bigEndian
return Data(bytes: &value, count: 4)
}
}
// MARK: - CryptoKit Key Data
extension Curve25519.KeyAgreement.PublicKey {
var rawData: Data {
Data(rawRepresentation)
}
}
extension Curve25519.KeyAgreement.PrivateKey {
var rawData: Data {
Data(rawRepresentation)
}
}
extension Curve25519.Signing.PublicKey {
var rawData: Data {
Data(rawRepresentation)
}
}
extension Curve25519.Signing.PrivateKey {
var rawData: Data {
Data(rawRepresentation)
}
}
// MARK: - String helpers
extension String {
/// Trim whitespace and newlines
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// MARK: - Dictionary merge helper
extension Dictionary where Key == String, Value == Any {
func string(for key: String) -> String? {
self[key] as? String
}
func int(for key: String) -> Int? {
if let i = self[key] as? Int { return i }
if let s = self[key] as? String, let i = Int(s) { return i }
return nil
}
func dict(for key: String) -> [String: Any]? {
self[key] as? [String: Any]
}
func array(for key: String) -> [[String: Any]]? {
self[key] as? [[String: Any]]
}
func data(for key: String) -> Data? {
if let hex = self[key] as? String {
return Data(hexString: hex)
}
return nil
}
func bool(for key: String) -> Bool? {
if let b = self[key] as? Bool { return b }
if let i = self[key] as? Int { return i != 0 }
return nil
}
}

View File

@@ -0,0 +1,114 @@
import Foundation
import SwiftUI
@Observable
final class AuthViewModel {
var email = ""
var password = ""
var confirmPassword = ""
var username = ""
var confirmationCode = ""
var isLoading = false
var errorMessage: String?
var showConfirmation = false
var registrationMessage: String?
var serverHost = Constants.defaultHost
var serverPort = String(Constants.defaultPort)
var useTLS = false
enum AuthMode {
case login, register, pairing
}
var mode: AuthMode = .login
func login(appState: AppState) async {
guard !email.isEmpty, !password.isEmpty else {
errorMessage = "Email and password are required"
return
}
isLoading = true
errorMessage = nil
do {
let port = UInt16(serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(host: serverHost, port: port, useTLS: useTLS)
} catch {
isLoading = false
errorMessage = "Connection failed: \(error.localizedDescription)"
return
}
let (success, message) = await appState.chatClient.login(email: email, password: password)
isLoading = false
if success {
appState.email = email
appState.isLoggedIn = true
appState.connectionStatus = .connected
if let userId = await appState.chatClient.userId {
appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email)
}
} else {
errorMessage = message
}
}
func register(appState: AppState) async {
guard !email.isEmpty, !password.isEmpty, !username.isEmpty else {
errorMessage = "All fields are required"
return
}
guard password == confirmPassword else {
errorMessage = "Passwords don't match"
return
}
isLoading = true
errorMessage = nil
do {
let port = UInt16(serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(host: serverHost, port: port, useTLS: useTLS)
} catch {
isLoading = false
errorMessage = "Connection failed: \(error.localizedDescription)"
return
}
let (success, message) = await appState.chatClient.register(username: username, password: password, email: email)
isLoading = false
if success {
registrationMessage = message
showConfirmation = true
} else {
errorMessage = message
}
}
func confirmRegistration(appState: AppState) async {
guard !confirmationCode.isEmpty else {
errorMessage = "Enter the confirmation code"
return
}
isLoading = true
errorMessage = nil
let (success, message) = await appState.chatClient.confirmRegistration(
email: email, username: username, code: confirmationCode
)
isLoading = false
if success {
registrationMessage = message
// Auto-login after registration
await login(appState: appState)
} else {
errorMessage = message
}
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import SwiftUI
@Observable
final class ChatViewModel {
var messages: [Message] = []
var isLoading = false
var isSending = false
var errorMessage: String?
var searchQuery = ""
var searchResults: [String] = [] // message IDs matching search
var currentSearchIndex = 0
private var notificationTask: Task<Void, Never>?
func loadMessages(convId: String, chatClient: ChatClient) async {
isLoading = true
messages = await chatClient.getMessages(convId: convId, limit: 50)
isLoading = false
// Mark as read
let unreadIds = messages.filter { !$0.isMine(currentUserId: await chatClient.userId ?? "") }.map(\.id)
if !unreadIds.isEmpty {
await chatClient.markRead(convId: convId, messageIds: unreadIds)
}
}
func loadOlderMessages(convId: String, chatClient: ChatClient) async {
let older = await chatClient.getMessages(convId: convId, limit: 50, offset: messages.count)
messages.insert(contentsOf: older, at: 0)
}
func sendMessage(convId: String, text: String, members: [ConversationMember],
chatClient: ChatClient, replyTo: String? = nil) async {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isSending = true
errorMessage = nil
let (success, msg) = await chatClient.sendMessage(
convId: convId, text: text, members: members, replyTo: replyTo
)
isSending = false
if !success {
errorMessage = msg
} else {
// Reload messages to get the sent message
await loadMessages(convId: convId, chatClient: chatClient)
}
}
func deleteMessage(messageId: String, convId: String, chatClient: ChatClient) async {
let success = await chatClient.deleteMessage(messageId: messageId, convId: convId)
if success {
messages.removeAll { $0.id == messageId }
}
}
func search(query: String) {
searchQuery = query
if query.isEmpty {
searchResults = []
currentSearchIndex = 0
return
}
let lower = query.lowercased()
searchResults = messages.filter { $0.text?.lowercased().contains(lower) == true }.map(\.id)
currentSearchIndex = searchResults.isEmpty ? 0 : searchResults.count - 1
}
func nextSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
}
func prevSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
}
func startNotificationListener(convId: String, chatClient: ChatClient) {
notificationTask?.cancel()
notificationTask = Task {
for await notification in await chatClient.notifications {
await handleNotification(notification, convId: convId, chatClient: chatClient)
}
}
}
@MainActor
private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) {
switch notification {
case .newMessage(let data):
if data["conversation_id"] as? String == convId {
if let msg = Task.detached(priority: .userInitiated, operation: {
await chatClient.decryptNotification(data)
}) as? Task<Message?, Never> {
Task {
if let message = await msg.value {
messages.append(message)
// Mark as read immediately since we're viewing this conv
await chatClient.markRead(convId: convId, messageIds: [message.id])
}
}
}
}
case .messageDeleted(let data):
if let msgId = data["message_id"] as? String {
messages.removeAll { $0.id == msgId }
}
case .messagesRead(let data):
if let readUserId = data["user_id"] as? String,
let msgIds = data["message_ids"] as? [String] {
for i in messages.indices {
if msgIds.contains(messages[i].id) {
messages[i].readBy.insert(readUserId)
}
}
}
default:
break
}
}
func stop() {
notificationTask?.cancel()
notificationTask = nil
}
}

View File

@@ -0,0 +1,127 @@
import Foundation
import SwiftUI
@Observable
final class ConversationListVM {
var conversations: [Conversation] = []
var invitations: [Invitation] = []
var onlineUsers: Set<String> = []
var unreadCounts: [String: Int] = [:]
var favorites: Set<String> = []
var isLoading = false
private var notificationTask: Task<Void, Never>?
func load(chatClient: ChatClient, email: String) async {
isLoading = true
// Load favorites from disk
favorites = KeyStorage.loadFavorites(email: email)
// Fetch conversations
let convs = await chatClient.listConversations()
conversations = sortConversations(convs, currentUserId: await chatClient.userId ?? "")
// Populate unread counts from server
for conv in conversations where conv.unreadCount > 0 {
unreadCounts[conv.id] = conv.unreadCount
}
// Fetch invitations
invitations = await chatClient.listInvitations()
isLoading = false
// Start notification listener
startNotificationListener(chatClient: chatClient, email: email)
}
func refresh(chatClient: ChatClient) async {
let convs = await chatClient.listConversations()
conversations = sortConversations(convs, currentUserId: await chatClient.userId ?? "")
invitations = await chatClient.listInvitations()
}
func toggleFavorite(convId: String, email: String) {
if favorites.contains(convId) {
favorites.remove(convId)
} else {
favorites.insert(convId)
}
try? KeyStorage.saveFavorites(email: email, favorites: favorites)
// Re-sort
let userId = conversations.first?.createdBy ?? ""
conversations = sortConversations(conversations, currentUserId: userId)
}
func markConversationRead(convId: String) {
unreadCounts[convId] = 0
}
func incrementUnread(convId: String) {
unreadCounts[convId, default: 0] += 1
}
private func sortConversations(_ convs: [Conversation], currentUserId: String) -> [Conversation] {
var result = convs.map { conv -> Conversation in
var c = conv
c.isFavorite = favorites.contains(conv.id)
c.unreadCount = unreadCounts[conv.id] ?? conv.unreadCount
return c
}
result.sort { a, b in
// Favorites first
if a.isFavorite != b.isFavorite { return a.isFavorite }
// Online DMs next
let aOnline = a.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
let bOnline = b.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
if aOnline != bOnline { return aOnline }
// Alphabetical
return a.displayName(currentUserId: currentUserId).lowercased() < b.displayName(currentUserId: currentUserId).lowercased()
}
return result
}
private func startNotificationListener(chatClient: ChatClient, email: String) {
notificationTask?.cancel()
notificationTask = Task {
for await notification in await chatClient.notifications {
await handleNotification(notification, chatClient: chatClient, email: email)
}
}
}
@MainActor
private func handleNotification(_ notification: ChatNotification, chatClient: ChatClient, email: String) {
switch notification {
case .newMessage(let data):
if let convId = data["conversation_id"] as? String {
incrementUnread(convId: convId)
}
case .onlineUsers(let userIds):
onlineUsers = Set(userIds)
case .userOnline(let userId):
onlineUsers.insert(userId)
case .userOffline(let userId):
onlineUsers.remove(userId)
case .conversationCreated, .memberAdded, .memberRemoved, .conversationRenamed:
Task { await refresh(chatClient: chatClient) }
case .groupInvitation:
Task { invitations = await chatClient.listInvitations() }
case .connectionStateChanged(let connected):
if !connected {
// Could trigger auto-reconnect here
}
default:
break
}
}
func stop() {
notificationTask?.cancel()
notificationTask = nil
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
import SwiftUI
@Observable
final class ProfileViewModel {
var profile: UserProfile?
var avatarData: Data?
var isLoading = false
var isSaving = false
var errorMessage: String?
// Editable fields
var phone = ""
var phoneVisible = false
var location = ""
var locationVisible = false
func loadProfile(userId: String? = nil, chatClient: ChatClient) async {
isLoading = true
profile = await chatClient.getProfile(userId: userId)
isLoading = false
if let p = profile {
phone = p.phone ?? ""
phoneVisible = p.phoneVisible
location = p.location ?? ""
locationVisible = p.locationVisible
}
// Load avatar
let uid = userId ?? await chatClient.userId ?? ""
if !uid.isEmpty {
avatarData = await chatClient.getAvatar(userId: uid)
}
}
func saveProfile(chatClient: ChatClient) async {
isSaving = true
errorMessage = nil
let success = await chatClient.updateProfile(
phone: phone.isEmpty ? nil : phone,
phoneVisible: phoneVisible,
location: location.isEmpty ? nil : location,
locationVisible: locationVisible
)
isSaving = false
if !success {
errorMessage = "Failed to update profile"
}
}
func uploadAvatar(imageData: Data, chatClient: ChatClient) async {
isSaving = true
let success = await chatClient.updateAvatar(imageData: imageData)
isSaving = false
if success {
avatarData = imageData
} else {
errorMessage = "Failed to upload avatar"
}
}
}

View File

@@ -0,0 +1,134 @@
import SwiftUI
struct LoginView: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
.padding(.top, 40)
Text("Encrypted Chat")
.font(.largeTitle.bold())
Text("End-to-end encrypted messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $viewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $viewModel.serverPort)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: $viewModel.useTLS)
}
.padding(.horizontal)
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $viewModel.password)
.textContentType(.password)
.textFieldStyle(.roundedBorder)
if viewModel.mode == .register {
TextField("Username", text: $viewModel.username)
.textContentType(.username)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Confirm Password", text: $viewModel.confirmPassword)
.textFieldStyle(.roundedBorder)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
}
Button(action: {
Task {
if viewModel.mode == .login {
await viewModel.login(appState: appState)
} else {
await viewModel.register(appState: appState)
}
}
}) {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(viewModel.mode == .login ? "Login" : "Register")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
viewModel.mode = viewModel.mode == .login ? .register : .login
viewModel.errorMessage = nil
}
.font(.caption)
}
.padding(.horizontal, 32)
}
}
.sheet(isPresented: $viewModel.showConfirmation) {
ConfirmationSheet(viewModel: viewModel, appState: appState)
}
}
}
}
struct ConfirmationSheet: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
var body: some View {
VStack(spacing: 20) {
Text("Confirm Registration")
.font(.title2.bold())
if let msg = viewModel.registrationMessage {
Text(msg)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
TextField("Confirmation Code", text: $viewModel.confirmationCode)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button("Confirm") {
Task {
await viewModel.confirmRegistration(appState: appState)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
.padding(32)
}
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
struct PairingView: View {
var appState: AppState
@State private var pairingCode = ""
@State private var isWaiting = false
@State private var statusMessage: String?
var body: some View {
VStack(spacing: 24) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Device Pairing")
.font(.title2.bold())
Text("Enter the 8-digit pairing code shown on your other device.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("Pairing Code", text: $pairingCode)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.frame(maxWidth: 200)
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(status.contains("Error") ? .red : .secondary)
}
if isWaiting {
ProgressView("Waiting for authorization...")
}
Button("Start Pairing") {
Task {
// Pairing implementation would go here
statusMessage = "Pairing not yet implemented"
}
}
.buttonStyle(.borderedProminent)
.disabled(pairingCode.count != 8 || isWaiting)
}
.padding(32)
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Registration is handled within LoginView via mode toggle.
// This file exists for potential future separation.

View File

@@ -0,0 +1,164 @@
import SwiftUI
struct ChatView: View {
let conversation: Conversation
var appState: AppState
@State private var viewModel = ChatViewModel()
@State private var inputText = ""
@State private var replyTo: Message?
@State private var showGroupInfo = false
@State private var showSearch = false
@State private var showDeleteConfirm = false
var body: some View {
VStack(spacing: 0) {
// Search bar
if showSearch {
SearchOverlayView(
query: $viewModel.searchQuery,
matchCount: viewModel.searchResults.count,
currentIndex: viewModel.currentSearchIndex,
onSearch: { viewModel.search(query: $0) },
onNext: { viewModel.nextSearchResult() },
onPrev: { viewModel.prevSearchResult() },
onClose: { showSearch = false; viewModel.search(query: "") }
)
}
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if viewModel.messages.count >= 50 {
Button("Load older messages") {
Task {
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
}
}
.font(.caption)
.padding()
}
ForEach(viewModel.messages) { message in
MessageBubbleView(
message: message,
isMine: message.isMine(currentUserId: appState.currentUser?.id ?? ""),
isHighlighted: viewModel.searchResults.contains(message.id),
isCurrentSearchResult: viewModel.searchResults.indices.contains(viewModel.currentSearchIndex) &&
viewModel.searchResults[viewModel.currentSearchIndex] == message.id,
onReply: { replyTo = message },
onDelete: {
Task {
await viewModel.deleteMessage(messageId: message.id, convId: conversation.id, chatClient: appState.chatClient)
}
}
)
.id(message.id)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.onChange(of: viewModel.messages.count) {
if let lastId = viewModel.messages.last?.id {
withAnimation {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
}
}
// Reply preview
if let reply = replyTo {
HStack {
Rectangle()
.fill(.blue)
.frame(width: 3)
VStack(alignment: .leading) {
Text(reply.senderUsername)
.font(.caption.bold())
Text(reply.text ?? "")
.font(.caption)
.lineLimit(1)
}
Spacer()
Button(action: { replyTo = nil }) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
// Input
MessageInputView(
text: $inputText,
isSending: viewModel.isSending,
onSend: {
Task {
let text = inputText
inputText = ""
let reply = replyTo?.id
replyTo = nil
await viewModel.sendMessage(
convId: conversation.id,
text: text,
members: conversation.members,
chatClient: appState.chatClient,
replyTo: reply
)
}
}
)
}
.navigationTitle(conversation.displayName(currentUserId: appState.currentUser?.id ?? ""))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
Button(action: { showSearch.toggle() }) {
Image(systemName: "magnifyingglass")
}
if conversation.isGroup {
Button(action: { showGroupInfo = true }) {
Image(systemName: "info.circle")
}
}
// Delete button
if !conversation.isGroup || conversation.createdBy == appState.currentUser?.id {
Button(action: { showDeleteConfirm = true }) {
Image(systemName: "trash")
.foregroundStyle(.red)
}
}
}
}
}
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await appState.chatClient.deleteConversation(convId: conversation.id)
}
}
} message: {
Text(conversation.isGroup
? "This will remove all members and delete the conversation."
: "This will remove you from the conversation.")
}
.sheet(isPresented: $showGroupInfo) {
GroupInfoView(conversation: conversation, appState: appState)
}
.task {
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
}
.onDisappear {
viewModel.stop()
}
}
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct ImageViewerView: View {
let imageData: Data
@State private var scale: CGFloat = 1.0
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
GeometryReader { geo in
if let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, min(scale, 5.0))
}
}
)
.onTapGesture(count: 2) {
withAnimation {
scale = scale > 1 ? 1 : 2
}
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.background(.black)
}
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
struct MessageBubbleView: View {
let message: Message
let isMine: Bool
var isHighlighted: Bool = false
var isCurrentSearchResult: Bool = false
var onReply: (() -> Void)?
var onDelete: (() -> Void)?
var body: some View {
HStack {
if isMine { Spacer(minLength: 60) }
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if !isMine {
Text(message.senderUsername)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
if message.isDeleted {
Text("Message deleted")
.font(.body.italic())
.foregroundStyle(.secondary)
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 16))
} else {
// Reply reference
if let replyTo = message.replyTo {
HStack(spacing: 4) {
Rectangle()
.fill(.blue.opacity(0.5))
.frame(width: 2)
Text("Reply to message")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
// File card
if let file = message.file {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "paperclip")
Text(file.filename)
.lineLimit(1)
}
.font(.subheadline)
Text(formatFileSize(file.size))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray5))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// Text content
if let text = message.text {
Text(text)
.padding(12)
.background(
isMine ? Color.blue : Color(.systemGray5)
)
.foregroundStyle(isMine ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// Timestamp
Text(formatTime(message.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.background(
isCurrentSearchResult ? Color.orange.opacity(0.3) :
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 16))
.contextMenu {
if !message.isDeleted {
Button(action: { onReply?() }) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Button(action: {
UIPasteboard.general.string = message.text ?? ""
}) {
Label("Copy", systemImage: "doc.on.doc")
}
if isMine {
Button(role: .destructive, action: { onDelete?() }) {
Label("Delete", systemImage: "trash")
}
}
}
}
if !isMine { Spacer(minLength: 60) }
}
}
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}

View File

@@ -0,0 +1,55 @@
import SwiftUI
import PhotosUI
struct MessageInputView: View {
@Binding var text: String
let isSending: Bool
let onSend: () -> Void
@State private var showAttachMenu = false
@State private var selectedPhoto: PhotosPickerItem?
var body: some View {
HStack(spacing: 8) {
// Attach button
Menu {
Button(action: {}) {
Label("Photo", systemImage: "photo")
}
Button(action: {}) {
Label("File", systemImage: "doc")
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
// Text field
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.onSubmit {
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
onSend()
}
}
// Send button
Button(action: onSend) {
if isSending {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct SearchOverlayView: View {
@Binding var query: String
let matchCount: Int
let currentIndex: Int
let onSearch: (String) -> Void
let onNext: () -> Void
let onPrev: () -> Void
let onClose: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search messages", text: $query)
.textFieldStyle(.roundedBorder)
.onChange(of: query) { _, newValue in
onSearch(newValue)
}
if matchCount > 0 {
Text("\(currentIndex + 1)/\(matchCount)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize()
Button(action: onPrev) {
Image(systemName: "chevron.up")
}
Button(action: onNext) {
Image(systemName: "chevron.down")
}
}
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct CircularAvatarView: View {
let name: String
var imageData: Data?
var size: CGFloat = 32
var isGroup: Bool = false
var body: some View {
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(Circle())
} else {
// Default: colored circle with initial letter
ZStack {
Circle()
.fill(avatarColor)
.frame(width: size, height: size)
Text(initial)
.font(.system(size: size * 0.4, weight: .semibold))
.foregroundStyle(.white)
}
}
}
private var initial: String {
String(name.prefix(1)).uppercased()
}
/// Deterministic color from name hash (matching Python gui_client behavior)
private var avatarColor: Color {
let colors: [Color] = [
.red, .orange, .yellow, .green, .mint,
.teal, .cyan, .blue, .indigo, .purple, .pink
]
var hash = 0
for char in name.unicodeScalars {
hash = hash &* 31 &+ Int(char.value)
}
return colors[abs(hash) % colors.count]
}
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
struct ConnectionIndicator: View {
let status: ConnectionStatus
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
if status != .connected {
Text(statusText)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var statusColor: Color {
switch status {
case .connected: return .green
case .connecting: return .orange
case .disconnected: return .red
}
}
private var statusText: String {
switch status {
case .connected: return ""
case .connecting: return "Connecting..."
case .disconnected: return "Disconnected"
}
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct OnlineDotOverlay: View {
var size: CGFloat = 12
var body: some View {
Circle()
.fill(.green)
.frame(width: size, height: size)
.overlay(
Circle()
.stroke(.white, lineWidth: 2)
)
}
}

View File

@@ -0,0 +1,99 @@
import SwiftUI
struct ConversationListView: View {
var appState: AppState
@Bindable var viewModel: ConversationListVM
@State private var showNewConversation = false
@State private var showProfile = false
@State private var selectedConversation: Conversation?
var body: some View {
NavigationStack {
List {
// Invitations section
if !viewModel.invitations.isEmpty {
Section {
ForEach(viewModel.invitations) { invitation in
InvitationBanner(
invitation: invitation,
onAccept: {
Task {
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
if success {
await viewModel.refresh(chatClient: appState.chatClient)
}
}
},
onDecline: {
Task {
await appState.chatClient.declineInvitation(convId: invitation.conversationId)
await viewModel.refresh(chatClient: appState.chatClient)
}
}
)
}
} header: {
Text("Invitations")
}
}
// Conversations section
Section {
ForEach(viewModel.conversations) { conversation in
NavigationLink(value: conversation) {
ConversationRowView(
conversation: conversation,
currentUserId: appState.currentUser?.id ?? "",
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
.map { viewModel.onlineUsers.contains($0) } ?? false,
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0
)
}
.contextMenu {
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
}
}
}
}
}
.navigationTitle("Chats")
.navigationDestination(for: Conversation.self) { conversation in
ChatView(
conversation: conversation,
appState: appState
)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ConnectionIndicator(status: appState.connectionStatus)
}
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button(action: { showProfile = true }) {
Image(systemName: "person.circle")
}
Button(action: { showNewConversation = true }) {
Image(systemName: "square.and.pencil")
}
}
}
}
.refreshable {
await viewModel.refresh(chatClient: appState.chatClient)
}
.sheet(isPresented: $showNewConversation) {
NewConversationSheet(appState: appState) { convId in
showNewConversation = false
await viewModel.refresh(chatClient: appState.chatClient)
}
}
.sheet(isPresented: $showProfile) {
ProfileView(appState: appState, isOwnProfile: true)
}
.task {
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
}
}
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct ConversationRowView: View {
let conversation: Conversation
let currentUserId: String
let isOnline: Bool
let unreadCount: Int
var body: some View {
HStack(spacing: 12) {
// Avatar
ZStack(alignment: .bottomTrailing) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
size: 44,
isGroup: conversation.isGroup
)
if isOnline && !conversation.isGroup {
OnlineDotOverlay(size: 12)
}
}
VStack(alignment: .leading, spacing: 2) {
HStack {
if conversation.isFavorite {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Text(conversation.displayName(currentUserId: currentUserId))
.font(unreadCount > 0 ? .body.bold() : .body)
.lineLimit(1)
}
if conversation.isGroup {
Text("\(conversation.members.count) members")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if unreadCount > 0 {
Text("\(unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
struct NewConversationSheet: View {
var appState: AppState
var onCreated: (String) async -> Void
@State private var email = ""
@State private var groupName = ""
@State private var isGroup = false
@State private var memberEmails: [String] = [""]
@State private var isLoading = false
@State private var errorMessage: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section {
Toggle("Create Group", isOn: $isGroup)
if isGroup {
TextField("Group Name", text: $groupName)
}
}
Section(isGroup ? "Members" : "Recipient") {
if isGroup {
ForEach(memberEmails.indices, id: \.self) { index in
TextField("Email", text: $memberEmails[index])
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Button("Add Member") {
memberEmails.append("")
}
} else {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
if let error = errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.navigationTitle("New Conversation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task { await create() }
}
.disabled(isLoading)
}
}
}
}
private func create() async {
isLoading = true
errorMessage = nil
let emails: [String]
if isGroup {
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
guard !emails.isEmpty else {
errorMessage = "Add at least one member"
isLoading = false
return
}
} else {
guard !email.trimmed.isEmpty else {
errorMessage = "Enter an email address"
isLoading = false
return
}
emails = [email.trimmed]
}
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
isLoading = false
if let convId = convId {
await onCreated(convId)
} else {
errorMessage = message
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Group creation is handled within NewConversationSheet via the isGroup toggle.
// This file exists for potential future separation.

View File

@@ -0,0 +1,123 @@
import SwiftUI
struct GroupInfoView: View {
let conversation: Conversation
var appState: AppState
@State private var showRenameSheet = false
@State private var showLeaveConfirm = false
@State private var newName = ""
@Environment(\.dismiss) private var dismiss
private var isCreator: Bool {
conversation.createdBy == appState.currentUser?.id
}
var body: some View {
NavigationStack {
List {
// Avatar section
Section {
HStack {
Spacer()
VStack(spacing: 8) {
CircularAvatarView(
name: conversation.name ?? "Group",
size: 64,
isGroup: true
)
Text(conversation.name ?? "Group")
.font(.title2.bold())
Text("\(conversation.members.count) members")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Actions
if isCreator {
Section {
Button("Rename Group") {
newName = conversation.name ?? ""
showRenameSheet = true
}
Button("Change Avatar") {
// Photo picker would go here
}
}
}
// Members
Section("Members") {
ForEach(conversation.members) { member in
HStack {
CircularAvatarView(name: member.username, size: 32, isGroup: false)
VStack(alignment: .leading) {
Text(member.username)
.font(.body)
Text(member.email)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if member.userId == conversation.createdBy {
Text("Admin")
.font(.caption)
.foregroundStyle(.blue)
}
}
}
}
// Leave / Delete
Section {
Button("Leave Group", role: .destructive) {
showLeaveConfirm = true
}
if isCreator {
Button("Delete Group", role: .destructive) {
Task {
await appState.chatClient.deleteConversation(convId: conversation.id)
dismiss()
}
}
}
}
}
.navigationTitle("Group Info")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Leave", role: .destructive) {
Task {
await appState.chatClient.leaveGroup(convId: conversation.id)
dismiss()
}
}
}
.alert("Rename Group", isPresented: $showRenameSheet) {
TextField("Group Name", text: $newName)
Button("Cancel", role: .cancel) {}
Button("Rename") {
Task {
await appState.chatClient.renameConversation(convId: conversation.id, name: newName)
}
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct InvitationBanner: View {
let invitation: Invitation
let onAccept: () -> Void
let onDecline: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "envelope.badge")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text(invitation.conversationName)
.font(.body.bold())
Text("Invited by \(invitation.invitedByUsername)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
HStack(spacing: 12) {
Button("Accept") {
onAccept()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Decline") {
onDecline()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Profile editing is handled within ProfileView when isOwnProfile = true.
// This file exists for potential future separation.

View File

@@ -0,0 +1,111 @@
import SwiftUI
struct ProfileView: View {
var appState: AppState
var isOwnProfile: Bool
var userId: String?
@State private var viewModel = ProfileViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
// Avatar
Section {
HStack {
Spacer()
VStack(spacing: 8) {
if let avatarData = viewModel.avatarData,
let uiImage = UIImage(data: avatarData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(Circle())
} else {
CircularAvatarView(
name: viewModel.profile?.username ?? "?",
size: 80,
isGroup: false
)
}
if isOwnProfile {
Button("Change Photo") {
// Photo picker would go here
}
.font(.caption)
}
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Info
Section("Info") {
if let username = viewModel.profile?.username {
LabeledContent("Username", value: username)
}
if let email = viewModel.profile?.email {
LabeledContent("Email", value: email)
}
}
if isOwnProfile {
// Editable fields
Section("Contact") {
TextField("Phone", text: $viewModel.phone)
.keyboardType(.phonePad)
Toggle("Phone visible to contacts", isOn: $viewModel.phoneVisible)
TextField("Location", text: $viewModel.location)
Toggle("Location visible to contacts", isOn: $viewModel.locationVisible)
}
} else {
// Read-only view
if let phone = viewModel.profile?.phone, viewModel.profile?.phoneVisible == true {
Section("Contact") {
LabeledContent("Phone", value: phone)
}
}
if let location = viewModel.profile?.location, viewModel.profile?.locationVisible == true {
Section("Location") {
LabeledContent("Location", value: location)
}
}
}
if let error = viewModel.errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.navigationTitle(isOwnProfile ? "My Profile" : "Profile")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if isOwnProfile {
Button("Save") {
Task {
await viewModel.saveProfile(chatClient: appState.chatClient)
dismiss()
}
}
.disabled(viewModel.isSaving)
} else {
Button("Done") { dismiss() }
}
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.task {
await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient)
}
}
}
}

View File

@@ -0,0 +1,239 @@
# iOS Client — Inkrementální sync zpráv
## Problém
Klient při každém otevření konverzace posílá `get_messages` a server vrací 50 zpráv (šifrované bloby + metadata). I když klient 49 z nich už má. Zbytečný přenos dat a zátěž serveru.
## Řešení
Server už podporuje parametr `after_ts` v `get_messages`. Klient si pamatuje timestamp poslední zprávy a posílá jen dotaz na novější.
---
## Protokol — co posílat serveru
### `get_messages` — nový volitelný parametr `after_ts`
**Request:**
```json
{
"type": "get_messages",
"request_id": "uuid",
"conversation_id": "conv-uuid",
"limit": 50,
"offset": 0,
"after_ts": "2026-02-15T22:15:45"
}
```
- `after_ts` (string, ISO 8601, volitelný) — server vrátí jen zprávy s `created_at > after_ts`
- Pokud `after_ts` chybí nebo je null, chová se jako dřív (vrátí posledních `limit` zpráv)
**Response** — beze změny, jen méně zpráv:
```json
{
"type": "get_messages",
"status": "ok",
"data": {
"messages": [...],
"total_count": 123
}
}
```
### `get_deleted_since` — sync smazaných zpráv
Po inkrementálním fetchi je nutné zjistit co bylo smazáno od posledního syncu.
**Request:**
```json
{
"type": "get_deleted_since",
"request_id": "uuid",
"conversation_id": "conv-uuid",
"since": "2026-02-15T22:15:45"
}
```
**Response:**
```json
{
"type": "get_deleted_since",
"status": "ok",
"data": {
"message_ids": ["msg-uuid-1", "msg-uuid-2"]
}
}
```
### `mark_read` — optimalizace
**Request** — beze změny, jen posílat méně ID:
```json
{
"type": "mark_read",
"request_id": "uuid",
"conversation_id": "conv-uuid",
"message_ids": ["only-unread-msg-id-1"]
}
```
Filtrovat na klientovi: jen zprávy kde `sender_id != myId` **a** `myId` není v `read_by`.
---
## Implementace na iOS klientovi
### 1. Lokální cache zpráv
Ukládat dešifrované zprávy na disk per konverzace. Klíč = `message_id`, hodnota = dešifrovaný payload (bez `read_by` — ten se mění).
```swift
// MessageCache.swift nebo rozšíření ChatClient
/// Uložit zprávu do lokální cache
func cacheMessage(convId: String, msgId: String, payload: [String: Any])
/// Načíst cache pro konverzaci [msgId: payload]
func loadCache(convId: String) -> [String: [String: Any]]
/// Smazat zprávu z cache
func removeCachedMessage(convId: String, msgId: String)
```
Formát na disku: JSON soubor v app sandbox, šifrovaný identity key (stejně jako Python klient).
### 2. Logika v `getMessages()`
```
1. Načíst lokální cache pro conv_id
2. Pokud cache je neprázdná A offset == 0:
a. Najít nejnovější created_at v cache → after_ts
b. Poslat get_messages s after_ts (server vrátí jen nové)
c. Dešifrovat nové zprávy, přidat do cache
d. Poslat get_deleted_since s after_ts → smazat z cache
e. Sestavit výsledek z cache (seřadit, vzít posledních limit)
3. Pokud cache je prázdná NEBO offset > 0:
a. Plný fetch jako dřív (bez after_ts)
b. Dešifrovat, uložit do cache
c. Vrátit
4. mark_read: filtrovat jen sender_id != myId a myId not in read_by
```
### 3. Pseudokód
```swift
func getMessages(convId: String, limit: Int = 50, offset: Int = 0) async -> [Message] {
var cache = loadCache(convId: convId)
let myId = userId ?? ""
// Rozhodnout: inkrementální vs plný fetch
var afterTs: String? = nil
if !cache.isEmpty && offset == 0 {
afterTs = cache.values
.compactMap { $0["created_at"] as? String }
.filter { !($0.isEmpty) }
.max()
}
// Fetch ze serveru
var params: [String: Any] = [
"conversation_id": convId,
"limit": limit,
"offset": offset,
]
if let ts = afterTs {
params["after_ts"] = ts
}
let resp = await sendAndReceive(type: "get_messages", params: params)
guard resp.string(for: "status") == "ok",
let data = resp.dict(for: "data"),
let rawMessages = data["messages"] as? [[String: Any]] else {
// Offline fallback vrátit z cache
if !cache.isEmpty && offset == 0 {
return buildFromCache(cache, limit: limit)
}
return []
}
// Dešifrovat nové zprávy (existující logika)
let newMessages = decryptRawMessages(rawMessages, cache: &cache, convId: convId)
// mark_read jen pro nepřečtené
let unreadIds = rawMessages.filter { msg in
let senderId = msg["sender_id"] as? String ?? ""
if senderId == myId { return false }
let readBy = msg["read_by"] as? [[String: Any]] ?? []
return !readBy.contains { ($0["user_id"] as? String) == myId }
}.compactMap { $0["message_id"] as? String }
if !unreadIds.isEmpty {
await markRead(convId: convId, messageIds: unreadIds)
}
if afterTs != nil {
// Inkrementální: sync smazaných
let delResp = await sendAndReceive(type: "get_deleted_since", params: [
"conversation_id": convId,
"since": afterTs!,
])
if let delData = delResp.dict(for: "data"),
let delIds = delData["message_ids"] as? [String] {
for id in delIds {
cache.removeValue(forKey: id)
removeCachedMessage(convId: convId, msgId: id)
}
}
return buildFromCache(cache, limit: limit)
}
return newMessages
}
/// Sestavit seřazený seznam z cache
func buildFromCache(_ cache: [String: [String: Any]], limit: Int) -> [Message] {
var messages: [Message] = []
for (msgId, payload) in cache {
guard payload["_control"] == nil else { continue }
// Vytvořit Message z payload...
messages.append(messageFromPayload(msgId: msgId, payload: payload))
}
messages.sort { $0.createdAt < $1.createdAt }
if messages.count > limit {
messages = Array(messages.suffix(limit))
}
return messages
}
```
### 4. Co se změní v praxi
| Situace | Dřív | Teď |
|---------|------|-----|
| Otevření konverzace kde jsem byl před 5 min | Server vrátí 50 zpráv (vše) | Server vrátí 0-2 nové zprávy |
| Otevření konverzace poprvé | Server vrátí 50 zpráv | Stejné (plný fetch) |
| Load older (scroll nahoru) | Server vrátí 50 starších | Stejné (offset > 0, plný fetch) |
| Po reconnectu | Server vrátí 50 zpráv | Server vrátí jen zprávy od odpojení |
| Offline | Nic (chyba) | Zobrazí cache |
### 5. Metadata (read_by, reactions, pins)
- **read_by** — neukládá se do cache (mění se často). Přichází v reálném čase přes `messages_read` notifikaci. Po reconnectu může být chvilku stale — přijatelné.
- **reactions** — server je vrací u každé zprávy. V cache se ukládají. Aktualizace přes `message_reacted` notifikaci v reálném čase.
- **pins** — stejně jako reactions. `message_pinned`/`message_unpinned` notifikace.
- Po inkrementálním fetchi jsou metadata aktuální jen pro NOVÉ zprávy. Starší mají stav z cache + real-time notifikací. Při plném fetchi (scroll nahoru / první load) jsou vždy aktuální.
### 6. `ChatViewModel.loadMessages` — úprava
```swift
func loadMessages(convId: String, chatClient: ChatClient) async {
isLoading = true
messages = await chatClient.getMessages(convId: convId, limit: 50)
isLoading = false
// mark_read se teď řeší uvnitř getMessages tady nic
updatePinnedBanner()
}
```
`mark_read` volání se přesune z ViewModelu do `getMessages()` v ChatClientu (tam kde má přístup k `read_by` z response).

33
ios_client/project.yml Normal file
View File

@@ -0,0 +1,33 @@
name: EncryptedChat
options:
bundleIdPrefix: com.encryptedchat
deploymentTarget:
iOS: "16.0"
xcodeVersion: "15.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
ENABLE_PREVIEWS: "YES"
targets:
EncryptedChat:
type: application
platform: iOS
sources:
- path: EncryptedChat
settings:
base:
GENERATE_INFOPLIST_FILE: "YES"
PRODUCT_BUNDLE_IDENTIFIER: com.encryptedchat.app
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: "YES"
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: "YES"
INFOPLIST_KEY_UILaunchScreen_Generation: "YES"
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: "Select photos to share in chat"
INFOPLIST_KEY_NSCameraUsageDescription: "Take photos to share in chat"
INFOPLIST_KEY_CFBundleDisplayName: "Encrypted Chat"
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.social-networking"
CODE_SIGN_STYLE: Automatic

1036
ios_client/v0.8.4_changes.md Normal file

File diff suppressed because it is too large Load Diff

142
protocol.py Normal file
View File

@@ -0,0 +1,142 @@
"""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, validate=True)
except (TypeError, binascii.Error) as e:
raise ValueError(f"Invalid base64: {e}")
VERSION = "0.8.5"
MIN_CLIENT_VERSION = "0.8.5" # 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').
Returns False for malformed version strings (instead of silently treating them as 0).
"""
def _parse(v: str) -> tuple[int, ...] | None:
if not isinstance(v, str) or not v:
return None
parts = v.split(".")
try:
return tuple(int(x) for x in parts)
except (ValueError, AttributeError):
return None
parsed_ver = _parse(version)
parsed_min = _parse(minimum)
if parsed_ver is None or parsed_min is None:
return False
return parsed_ver >= parsed_min
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 as e:
# Message exceeded StreamReader limit — drain oversized data
# using public read() API (consumed=e.consumed bytes before limit).
# Read in chunks until newline found or EOF, then signal error.
remaining = e.consumed
while True:
chunk = await self._reader.read(max(remaining, 4096))
if not chunk:
return None # EOF while draining
if b"\n" in chunk:
break # found delimiter, oversized message fully drained
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()

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
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
# QR code generation for contact verification (optional)
qrcode[pil]>=7.4
# QR code scanning (needed for gui_client.py QR scan feature)
pyzbar>=0.1.9

252
scaling.md Normal file
View File

@@ -0,0 +1,252 @@
# Škálování serveru — plán kapacitního růstu
## Cílový hardware
- **CPU:** Intel Xeon E5-2630v4 (10 cores / 20 threads, 2.2 GHz)
- **RAM:** 256 GB REG ECC
- **Disk:** 500 GB SSD (boot/OS/DB) + 4 TB HDD (soubory)
- **Síť:** 1 Gbit
Odhadovaná kapacita po optimalizaci: **10 00020 000 uživatelů**, **20005000 zpráv/s**
---
## Krok 1: Okamžité změny (hotovo v kódu)
### 1a. Thread pool — `server.py`
```env
THREAD_POOL_SIZE=40
```
Nastavuje `ThreadPoolExecutor(max_workers=40)` jako default executor pro `asyncio.to_thread()`.
S 20 HW thready a DB latencí ~25ms je 40 workerů optimální (2x HW threads — workery čekají na I/O).
### 1b. DB pool — `.env`
```env
DB_POOL_SIZE=30
```
30 simultánních MySQL spojení. S 40 thread workers a ~2ms query je 30 pool konexí dostatek.
### 1c. Chybějící DB indexy — `schema.sql`
Přidány 5 nových indexů pro nejčastější dotazy:
| Index | Tabulka | Dotaz který zrychlí |
|-------|---------|---------------------|
| `idx_cm_user (user_id)` | `conversation_members` | `list_user_conversations`**kritický**, bez něj full table scan |
| `idx_inv_user (user_id)` | `group_invitations` | `get_pending_invitations` |
| `idx_messages_deleted (conversation_id, deleted_at)` | `messages` | `get_deleted_messages_since` |
| `idx_messages_pinned (conversation_id, pinned_at)` | `messages` | `get_pinned_messages` |
| `idx_reads_user (user_id)` | `message_reads` | `get_unread_counts` |
**SQL migrace pro existující databázi:**
```sql
ALTER TABLE conversation_members ADD INDEX idx_cm_user (user_id);
ALTER TABLE group_invitations ADD INDEX idx_inv_user (user_id);
ALTER TABLE messages ADD INDEX idx_messages_deleted (conversation_id, deleted_at);
ALTER TABLE messages ADD INDEX idx_messages_pinned (conversation_id, pinned_at);
ALTER TABLE message_reads ADD INDEX idx_reads_user (user_id);
```
### 1d. Upload adresář na HDD
```env
UPLOAD_DIR=/mnt/hdd/encrypted_chat/uploads
```
Šifrované soubory a avatary na 4TB HDD — SSD zůstane pro OS a MySQL data.
```bash
mkdir -p /mnt/hdd/encrypted_chat/uploads
chmod 700 /mnt/hdd/encrypted_chat/uploads
```
---
## Krok 2: MySQL tuning pro 256 GB RAM
### `/etc/mysql/mysql.conf.d/tuning.cnf` (nebo ekvivalent v Dockeru)
```ini
[mysqld]
# === Buffer Pool — hlavní cache pro data + indexy ===
# 96 GB = ~37% RAM (MySQL + app na stejném stroji)
innodb_buffer_pool_size = 96G
innodb_buffer_pool_instances = 16
# === Redo Log — větší = méně I/O, rychlejší zápisy ===
innodb_redo_log_capacity = 4G
# === Flush strategie ===
# 2 = flush do OS cache každou sekundu (ne každý commit)
# Ztráta max 1s dat při pádu OS, ale 10x rychlejší zápisy
innodb_flush_log_at_trx_commit = 2
# O_DIRECT = bypass OS page cache (InnoDB má vlastní)
innodb_flush_method = O_DIRECT
# === I/O kapacita (SSD) ===
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
# === Connections ===
max_connections = 200
# === Sort/Join buffery ===
sort_buffer_size = 4M
join_buffer_size = 4M
read_buffer_size = 2M
read_rnd_buffer_size = 2M
# === Temporary tables ===
tmp_table_size = 256M
max_heap_table_size = 256M
# === Query cache (MySQL 8.0+ nemá, pro 5.7) ===
# query_cache_type = 0
# === Thread cache ===
thread_cache_size = 64
# === Binary logging (pro budoucí repliky) ===
# server-id = 1
# log_bin = /var/log/mysql/mysql-bin
# binlog_expire_logs_seconds = 604800
# max_binlog_size = 256M
```
**Pokud MySQL běží v Dockeru:**
```yaml
# docker-compose.yml
services:
mysql:
image: mysql:8.0
volumes:
- /var/lib/mysql:/var/lib/mysql # data na SSD
- ./tuning.cnf:/etc/mysql/conf.d/tuning.cnf
deploy:
resources:
limits:
memory: 128G # limitovat aby zbylo pro app
environment:
MYSQL_DATABASE: encrypted_chat
```
### Po aplikaci restartovat MySQL a ověřit:
```sql
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
SHOW ENGINE INNODB STATUS\G
```
---
## Krok 3: Doporučená `.env` pro produkci
```env
# Server
SERVER_HOST=0.0.0.0
SERVER_PORT=9999
# MySQL
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=sifrator
MYSQL_PASSWORD=<silne-heslo>
MYSQL_DATABASE=encrypted_chat
DB_POOL_SIZE=30
# Performance
THREAD_POOL_SIZE=40
# Storage
UPLOAD_DIR=/mnt/hdd/encrypted_chat/uploads
# TLS (zapnout pro produkci)
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
# Logging
LOG_LEVEL=INFO
```
---
## Krok 4: Monitoring (doporučeno)
### Jednoduché metriky bez externích nástrojů
Přidat do serveru periodické logování:
```python
# V _periodic_cleanup() (každých 10 min):
async with _clients_lock:
total_connections = sum(len(v) for v in connected_clients.values())
unique_users = len(connected_clients)
logger.info("[STATS] users=%d connections=%d", unique_users, total_connections)
```
### S externími nástroji (volitelně)
- **htop** — CPU / RAM využití procesu
- **mysqladmin status** — queries/s, slow queries, connections
- **Prometheus + Grafana** — dlouhodobé trendy (přidat až při potřebě)
---
## Budoucí škálování
### Fáze A: Separace MySQL (15K+ uživatelů)
MySQL na separátní stroj (nebo managed DB). App server + Redis na jednom, DB na druhém.
```
[Server: App + Redis] ──TCP──▶ [Server: MySQL]
└──▶ [HDD/S3: soubory]
```
### Fáze B: Horizontální škálování (50K+ uživatelů)
Více app serverů za load balancerem + Redis Pub/Sub pro cross-server notifikace.
```
┌─── App server 1 ───┐
Client ──▶ │ connected_clients │──┐
└─────────────────────┘ │
├──▶ Redis Pub/Sub ──▶ MySQL
┌─── App server 2 ───┐ │
Client ──▶ │ connected_clients │──┘
└─────────────────────┘
Load Balancer (HAProxy / nginx stream)
(sticky sessions by user_id)
```
Hlavní změna: `_notify_users()` posílá do Redis místo lokálního `connected_clients` pokud uživatel není na tomto serveru.
### Fáze C: DB škálování (100K+ uživatelů)
- Read replicas pro SELECT dotazy
- Partitioning tabulky `messages` podle měsíce
- Sharding podle `conversation_id`
---
## Přehled — co je hotovo
| Krok | Stav | Popis |
|------|------|-------|
| asyncio.to_thread() pro DB | **Hotovo** | 131 DB volání offloadováno do thread poolu |
| ThreadPoolExecutor(40) | **Hotovo** | Konfigurovatelný přes `THREAD_POOL_SIZE` |
| DB indexy (5 nových) | **Hotovo** | Schema + SQL migrace připraveny |
| UPLOAD_DIR na HDD | **Konfigurace** | Nastavit v `.env` |
| MySQL tuning | **Konfigurace** | Aplikovat `tuning.cnf` |
| TLS certifikát | **TODO** | Let's Encrypt nebo vlastní CA |
| Monitoring | **Volitelné** | Periodické logování stats |

189
schema.sql Normal file
View File

@@ -0,0 +1,189 @@
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,
INDEX idx_cm_user (user_id)
) 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,
INDEX idx_inv_user (user_id)
) 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,
pinned_at DATETIME DEFAULT NULL,
pinned_by 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),
INDEX idx_messages_deleted (conversation_id, deleted_at),
INDEX idx_messages_pinned (conversation_id, pinned_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,
INDEX idx_reads_user (user_id),
INDEX idx_reads_read_at (read_at)
) ENGINE=InnoDB;
-- Delivery receipts
CREATE TABLE IF NOT EXISTS message_deliveries (
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
delivered_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;
-- Message reactions (emoji reactions on messages)
CREATE TABLE IF NOT EXISTS message_reactions (
id CHAR(36) NOT NULL PRIMARY KEY,
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
reaction VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_reaction (message_id, user_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_reactions_created_at (created_at)
) 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;

2933
server.py Normal file

File diff suppressed because it is too large Load Diff

79
tests/PENTEST_CLIENT.md Normal file
View File

@@ -0,0 +1,79 @@
# `tests/pentest_client.py`
Automatizovaný pentest/integration harness nad živým serverem s reálnými účty.
## Co test dělá
1. **Conversation Isolation (AuthZ)**
- Účet `outsider` zkouší `get_messages`, `mark_read` a `send_message` do konverzace, kde není členem.
- Očekávání: server vrátí `error` + `"Not a member"`.
2. **Malformed Header Rejection**
- Platný člen konverzace pošle `send_message` s obřím `ratchet_header`.
- Očekávání: server odmítne request (`Invalid ratchet_header format`), tj. funguje `_validate_header` limit.
3. **Session Reset Authorization**
- `outsider` pošle `session_reset` na `peer_user_id`.
- Očekávání: `error` + `"No shared conversation"`.
- Pokud účty sdílenou konverzaci opravdu mají, test se označí jako `SKIP` (setup issue, ne nutně bezpečnostní chyba).
4. **Login Rate Limits (volitelné)**
- Anonymní klient spamuje `login_start`:
- stejný email v různých kombinacích velikosti písmen (test case-normalization bucketu),
- potom rotace různých emailů ze stejné IP (test per-IP bucketu).
- Očekávání: aktivuje se jak per-email limit, tak per-IP limit.
## Požadavky
- Běžící server (`server.py`).
- Existující lokální klíče pro účty v `~/.encrypted_chat/<email>/` (stejné jako pro běžného CLI klienta).
- 3 různé účty:
- `member` (A),
- `peer` (B),
- `outsider` (C).
## Spuštění
```bash
python3 tests/pentest_client.py \
--server-host localhost \
--member-email alice@example.com \
--peer-email bob@example.com \
--outsider-email mallory@example.com
```
Skript si vyžádá hesla interaktivně. Lze je předat i argumenty:
```bash
python3 tests/pentest_client.py \
--server-host localhost \
--member-email alice@example.com --member-password '***' \
--peer-email bob@example.com --peer-password '***' \
--outsider-email mallory@example.com --outsider-password '***'
```
Volby:
- `--conversation-id <uuid>`: použije konkrétní konverzaci místo auto member<->peer DM.
- `--skip-login-rate-limit`: přeskočí test `login_start` limiteru.
- `--server-host <host>`: přepíše `SERVER_HOST` pro tento běh.
- `--server-port <port>`: přepíše `SERVER_PORT` pro tento běh.
Poznámka k TLS:
- Pokud máš v `.env` `SERVER_HOST=0.0.0.0`, je to správně pro server bind, ale klient na to nesmí přistupovat přes TLS.
- Pro klienta použij `--server-host` s hodnotou, která je v certifikátu (SAN/CN), typicky `localhost` nebo konkrétní IP.
## Výstup
Skript tiskne souhrn:
- `[PASS]` test prošel,
- `[FAIL]` test selhal (potenciální regrese),
- `[SKIP]` test nelze vyhodnotit kvůli dataset/setup podmínkám.
Návratový kód:
- `0` = bez failu,
- `1` = alespoň jeden fail,
- `2` = chyba vstupních parametrů.

338
tests/pentest_client.py Normal file
View File

@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Security regression harness for encrypted_chat server.
Runs focused pentest/integration checks against a live server using real accounts.
"""
from __future__ import annotations
import argparse
import asyncio
import getpass
import os
import ssl
import sys
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if TYPE_CHECKING:
from chat_core import ChatClient
@dataclass
class TestResult:
name: str
outcome: str # PASS | FAIL | SKIP
details: str
def _msg(resp: dict) -> str:
data = resp.get("data") or {}
return str(data.get("message", ""))
async def _connect_client() -> "ChatClient":
from chat_core import ChatClient # Imported lazily so --help works without full deps
client = ChatClient()
await client.connect()
client._listener_task = asyncio.create_task(client._background_listener())
return client
async def _login_client(email: str, password: str) -> tuple["ChatClient", str]:
client = await _connect_client()
ok, message = await client.login(email, password)
if not ok:
await client.close()
raise RuntimeError(f"Login failed for {email}: {message}")
return client, message
async def _close_client(client: "ChatClient | None"):
if not client:
return
try:
await client.close()
except Exception:
pass
def _too_many_attempts(resp: dict) -> bool:
return resp.get("status") == "error" and "Too many attempts" in _msg(resp)
async def test_conversation_isolation(outsider: "ChatClient", conv_id: str) -> TestResult:
"""Outsider must not access a conversation they are not a member of."""
fake_mid = str(uuid.uuid4())
checks: list[tuple[str, dict]] = [
(
"get_messages",
await outsider.send_and_recv("get_messages", conversation_id=conv_id, limit=5, offset=0),
),
(
"mark_read",
await outsider.send_and_recv("mark_read", conversation_id=conv_id, message_ids=[fake_mid]),
),
(
"send_message",
await outsider.send_and_recv("send_message", conversation_id=conv_id),
),
]
failures: list[str] = []
for endpoint, resp in checks:
if resp.get("status") != "error" or "Not a member" not in _msg(resp):
failures.append(f"{endpoint} -> status={resp.get('status')} message={_msg(resp)!r}")
if failures:
return TestResult("Conversation Isolation (AuthZ)", "FAIL", "; ".join(failures))
return TestResult(
"Conversation Isolation (AuthZ)",
"PASS",
"Outsider got 'Not a member' for get_messages, mark_read, send_message.",
)
async def test_session_reset_no_shared(outsider: "ChatClient", peer_user_id: str) -> TestResult:
"""session_reset must be rejected without shared conversation."""
resp = await outsider.send_and_recv("session_reset", peer_user_id=peer_user_id)
if resp.get("status") == "error" and "No shared conversation" in _msg(resp):
return TestResult("Session Reset Authorization", "PASS", "Rejected with 'No shared conversation'.")
if resp.get("status") == "ok":
return TestResult(
"Session Reset Authorization",
"SKIP",
"Outsider appears to share a conversation with peer in current dataset.",
)
return TestResult(
"Session Reset Authorization",
"FAIL",
f"Unexpected response: status={resp.get('status')} message={_msg(resp)!r}",
)
async def test_malformed_header_rejected(member: "ChatClient", conv_id: str) -> TestResult:
"""Oversized ratchet header should be rejected by server-side validation."""
huge_header = {"dh_pub": "A" * 5000, "n": 1, "pn": 0}
resp = await member.send_and_recv(
"send_message",
conversation_id=conv_id,
ratchet_header=huge_header,
recipients=[{}],
)
if resp.get("status") == "error" and "Invalid ratchet_header format" in _msg(resp):
return TestResult("Malformed Header Rejection", "PASS", "Oversized ratchet_header rejected.")
return TestResult(
"Malformed Header Rejection",
"FAIL",
f"Unexpected response: status={resp.get('status')} message={_msg(resp)!r}",
)
async def test_login_rate_limits() -> TestResult:
"""Validate login_start per-email(case-insensitive) and per-IP limits."""
probe = await _connect_client()
try:
stamp = int(time.time())
base_local = f"pentest-login-{stamp}"
base_domain = "example.invalid"
base_email = f"{base_local}@{base_domain}"
case_variants = [
base_email,
f"{base_local.upper()}@{base_domain}",
f"{base_local.capitalize()}@{base_domain}",
f"{base_local}@{base_domain.upper()}",
f"{base_local.swapcase()}@{base_domain}",
base_email,
f"{base_local.upper()}@{base_domain}",
f"{base_local.capitalize()}@{base_domain}",
f"{base_local}@{base_domain.upper()}",
f"{base_local.swapcase()}@{base_domain}",
base_email, # should exceed per-email bucket (10/min)
]
email_bucket_triggered = False
phase1_last = ""
for e in case_variants:
resp = await probe.send_and_recv("login_start", email=e)
phase1_last = _msg(resp)
if _too_many_attempts(resp):
email_bucket_triggered = True
await asyncio.sleep(0.12) # stay under per-connection 20 req/s limiter
ip_bucket_triggered = False
phase2_last = ""
for i in range(1, 16):
unique_email = f"{base_local}-{i}@{base_domain}"
resp = await probe.send_and_recv("login_start", email=unique_email)
phase2_last = _msg(resp)
if _too_many_attempts(resp):
ip_bucket_triggered = True
break
await asyncio.sleep(0.12)
if email_bucket_triggered and ip_bucket_triggered:
return TestResult(
"Login Rate Limits (case + per-IP)",
"PASS",
"Per-email(case-insensitive) and per-IP login_start limits both triggered.",
)
return TestResult(
"Login Rate Limits (case + per-IP)",
"FAIL",
(
f"email_bucket_triggered={email_bucket_triggered}, "
f"ip_bucket_triggered={ip_bucket_triggered}, "
f"phase1_last={phase1_last!r}, phase2_last={phase2_last!r}"
),
)
finally:
await _close_client(probe)
def _pick_password(flag_value: str | None, prompt: str) -> str:
if flag_value is not None:
return flag_value
return getpass.getpass(prompt)
async def run(args: argparse.Namespace) -> int:
if len({args.member_email.lower(), args.peer_email.lower(), args.outsider_email.lower()}) != 3:
print("ERROR: member/peer/outsider emails must be three different accounts.", file=sys.stderr)
return 2
if args.server_host:
os.environ["SERVER_HOST"] = args.server_host
if args.server_port is not None:
os.environ["SERVER_PORT"] = str(args.server_port)
effective_host = os.getenv("SERVER_HOST", "127.0.0.1").strip()
if effective_host == "0.0.0.0":
print(
"ERROR: SERVER_HOST=0.0.0.0 je bind adresa serveru, ne klientský TLS hostname.\n"
"Pouzij --server-host <hostname-nebo-ip-z-certifikatu> (napr. localhost nebo 192.168.1.112).",
file=sys.stderr,
)
return 2
member_password = _pick_password(args.member_password, f"Password for {args.member_email}: ")
peer_password = _pick_password(args.peer_password, f"Password for {args.peer_email}: ")
outsider_password = _pick_password(args.outsider_password, f"Password for {args.outsider_email}: ")
member: "ChatClient | None" = None
peer: "ChatClient | None" = None
outsider: "ChatClient | None" = None
results: list[TestResult] = []
try:
print("[setup] Logging in member account...")
member, _ = await _login_client(args.member_email, member_password)
print("[setup] Logging in peer account...")
peer, _ = await _login_client(args.peer_email, peer_password)
print("[setup] Logging in outsider account...")
outsider, _ = await _login_client(args.outsider_email, outsider_password)
if args.conversation_id:
conv_id = args.conversation_id
else:
print("[setup] Finding/creating member<->peer direct conversation...")
conv_id, err = await member.find_or_create_conversation(args.peer_email)
if not conv_id:
raise RuntimeError(f"Could not find/create conversation: {err}")
outsider_convs = {c["conversation_id"] for c in await outsider.list_conversations()}
if conv_id in outsider_convs:
results.append(
TestResult(
"Conversation Isolation (AuthZ)",
"SKIP",
"Outsider is already a member of target conversation; choose different outsider/account set.",
)
)
else:
results.append(await test_conversation_isolation(outsider, conv_id))
results.append(await test_malformed_header_rejected(member, conv_id))
results.append(await test_session_reset_no_shared(outsider, peer.session["user_id"]))
if not args.skip_login_rate_limit:
results.append(await test_login_rate_limits())
except ssl.SSLCertVerificationError as e:
results.append(
TestResult(
"Harness Setup",
"FAIL",
(
f"{e}. Zkus --server-host s hodnotou ze SAN/CN certifikatu "
"(napr. localhost nebo 192.168.1.112)."
),
)
)
except Exception as e:
emsg = str(e)
if "CERTIFICATE_VERIFY_FAILED" in emsg or "IP address mismatch" in emsg:
emsg += (
" | Hint: pouzij --server-host s hostname/IP, ktery je v certifikatu "
"(SERVER_HOST nesmi byt 0.0.0.0)."
)
results.append(TestResult("Harness Setup", "FAIL", emsg))
finally:
await _close_client(member)
await _close_client(peer)
await _close_client(outsider)
print("\n=== Pentest Results ===")
for r in results:
print(f"[{r.outcome}] {r.name}: {r.details}")
has_fail = any(r.outcome == "FAIL" for r in results)
return 1 if has_fail else 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run focused pentest/integration checks against encrypted_chat server."
)
parser.add_argument("--member-email", required=True, help="Email of regular account A (conversation member).")
parser.add_argument("--peer-email", required=True, help="Email of regular account B (other conversation member).")
parser.add_argument("--outsider-email", required=True, help="Email of regular account C (must not be in target conversation).")
parser.add_argument(
"--server-host",
default=None,
help="Override SERVER_HOST for this run (must match TLS cert SAN/CN).",
)
parser.add_argument(
"--server-port",
type=int,
default=None,
help="Override SERVER_PORT for this run.",
)
parser.add_argument("--member-password", default=None, help="Password for --member-email (optional; prompt if omitted).")
parser.add_argument("--peer-password", default=None, help="Password for --peer-email (optional; prompt if omitted).")
parser.add_argument("--outsider-password", default=None, help="Password for --outsider-email (optional; prompt if omitted).")
parser.add_argument(
"--conversation-id",
default=None,
help="Optional target conversation UUID. If omitted, member<->peer DM is found/created automatically.",
)
parser.add_argument(
"--skip-login-rate-limit",
action="store_true",
help="Skip anonymous login_start rate-limit regression check.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
return asyncio.run(run(args))
if __name__ == "__main__":
raise SystemExit(main())

539
theme.py Normal file
View File

@@ -0,0 +1,539 @@
"""Theme system for Encrypted Chat GUI — light + dark mode with live switching."""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Callable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ThemeColors:
"""All colour tokens for one theme."""
# Surface hierarchy
bg_primary: str # Main background (messages area, right panel)
bg_secondary: str # Cards, inputs, elevated surfaces
bg_tertiary: str # Sidebar, deeper surfaces
bg_hover: str # Hover state on list items
bg_selected: str # Selected list item
# Text
text_primary: str # Main text
text_secondary: str # Secondary / muted text
text_muted: str # Timestamps, counters, hints
# Accent (brand blue)
accent: str
accent_hover: str
accent_text: str # Text on accent background
# Message bubbles
bubble_sent_bg: str
bubble_sent_text: str
bubble_recv_bg: str
bubble_recv_text: str
bubble_sent_meta: str # Timestamp/read inside sent bubble
bubble_recv_meta: str # Timestamp inside received bubble
# Semantic colours
success: str
warning: str
error: str
info: str
# Chrome / borders
border: str
border_focus: str
scrollbar: str
separator: str
overlay: str # Privacy overlay background (rgba)
# Links
link_https: str
link_http: str # Insecure link (orange)
# Mentions & search
mention: str
search_highlight: str
search_current: str
# Reactions
reaction_bg: str
reaction_bg_own: str
reaction_border: str
reaction_border_own: str
# Misc
online_dot: str
online_dot_border: str
pin_color: str
sender_name_other: str # Non-self sender name colour in groups
receipt_read: str # Read receipt checkmarks (must contrast with sent bubble bg)
# ---------------------------------------------------------------------------
# Dark theme — Catppuccin Mocha palette
# ---------------------------------------------------------------------------
DARK_THEME = ThemeColors(
bg_primary="#1e1e2e",
bg_secondary="#313244",
bg_tertiary="#181825",
bg_hover="#252536",
bg_selected="#313244",
text_primary="#cdd6f4",
text_secondary="#bac2de",
text_muted="#6c7086",
accent="#89b4fa",
accent_hover="#74c7ec",
accent_text="#1e1e2e",
bubble_sent_bg="#2a4a7f",
bubble_sent_text="#cdd6f4",
bubble_recv_bg="#2c2c3e",
bubble_recv_text="#cdd6f4",
bubble_sent_meta="#8899bb",
bubble_recv_meta="#6c7086",
success="#a6e3a1",
warning="#f9e2af",
error="#f38ba8",
info="#74c7ec",
border="#45475a",
border_focus="#89b4fa",
scrollbar="#45475a",
separator="#45475a",
overlay="rgba(30, 30, 46, 245)",
link_https="#89b4fa",
link_http="#fab387",
mention="#89b4fa",
search_highlight="#f9e2af",
search_current="#fab387",
reaction_bg="#313244",
reaction_bg_own="#45475a",
reaction_border="#45475a",
reaction_border_own="#585b70",
online_dot="#a6e3a1",
online_dot_border="#181825",
pin_color="#f9e2af",
sender_name_other="#f9e2af",
receipt_read="#74c7ec",
)
# ---------------------------------------------------------------------------
# Light theme — Signal-inspired palette
# ---------------------------------------------------------------------------
LIGHT_THEME = ThemeColors(
bg_primary="#ffffff",
bg_secondary="#f2f2f7",
bg_tertiary="#e5e5ea",
bg_hover="#dcdce4",
bg_selected="#c7c7d1",
text_primary="#1c1c1e",
text_secondary="#3a3a3c",
text_muted="#8a8a8e",
accent="#3478f6",
accent_hover="#2563eb",
accent_text="#ffffff",
bubble_sent_bg="#3478f6",
bubble_sent_text="#ffffff",
bubble_recv_bg="#e5e5ea",
bubble_recv_text="#1c1c1e",
bubble_sent_meta="#a3c4ff",
bubble_recv_meta="#8a8a8e",
success="#34c759",
warning="#ff9500",
error="#ff3b30",
info="#5ac8fa",
border="#c6c6c8",
border_focus="#3478f6",
scrollbar="#aeaeb2",
separator="#c6c6c8",
overlay="rgba(0, 0, 0, 200)",
link_https="#2563eb",
link_http="#ea580c",
mention="#2563eb",
search_highlight="#fde68a",
search_current="#fb923c",
reaction_bg="#e5e5ea",
reaction_bg_own="#c7c7d1",
reaction_border="#c6c6c8",
reaction_border_own="#a0a0a8",
online_dot="#34c759",
online_dot_border="#e5e5ea",
pin_color="#ff9500",
sender_name_other="#7c3aed",
receipt_read="#d0e8ff",
)
# ---------------------------------------------------------------------------
# ThemeManager singleton
# ---------------------------------------------------------------------------
class ThemeManager:
"""Manages the active theme, persistence and change notification."""
_instance: ThemeManager | None = None
@classmethod
def instance(cls) -> ThemeManager:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self._is_dark: bool = True
self._listeners: list[Callable[[], None]] = []
self._email: str | None = None
self._load_global()
# -- Public API --
@property
def is_dark(self) -> bool:
return self._is_dark
@property
def colors(self) -> ThemeColors:
return DARK_THEME if self._is_dark else LIGHT_THEME
def toggle(self):
self._is_dark = not self._is_dark
self._save()
self._notify()
def set_dark(self, dark: bool):
if dark == self._is_dark:
return
self._is_dark = dark
self._save()
self._notify()
def set_email(self, email: str):
"""After login, bind to user-specific preference file."""
self._email = email
self._load_user()
def on_change(self, callback: Callable[[], None]):
self._listeners.append(callback)
def remove_listener(self, callback: Callable[[], None]):
try:
self._listeners.remove(callback)
except ValueError:
pass
def generate_qss(self) -> str:
return _build_qss(self.colors)
# -- Persistence --
def _global_path(self) -> Path:
p = Path.home() / ".encrypted_chat"
p.mkdir(parents=True, exist_ok=True)
return p / "global_settings.json"
def _user_path(self) -> Path | None:
if not self._email:
return None
p = Path.home() / ".encrypted_chat" / self._email
if not p.exists():
return None
return p / "theme.json"
def _load_global(self):
try:
p = self._global_path()
if p.exists():
data = json.loads(p.read_text())
self._is_dark = data.get("dark", True)
except Exception:
pass
def _load_user(self):
try:
p = self._user_path()
if p and p.exists():
data = json.loads(p.read_text())
self._is_dark = data.get("dark", self._is_dark)
except Exception:
pass
def _save(self):
data = {"dark": self._is_dark}
try:
self._global_path().write_text(json.dumps(data))
except Exception:
pass
try:
p = self._user_path()
if p:
p.write_text(json.dumps(data))
except Exception:
pass
def _notify(self):
for cb in list(self._listeners):
try:
cb()
except Exception:
logger.debug("Theme listener error", exc_info=True)
# ---------------------------------------------------------------------------
# Convenience accessors
# ---------------------------------------------------------------------------
def c() -> ThemeColors:
"""Shorthand for ThemeManager.instance().colors."""
return ThemeManager.instance().colors
def qss() -> str:
"""Shorthand for ThemeManager.instance().generate_qss()."""
return ThemeManager.instance().generate_qss()
def tm() -> ThemeManager:
"""Shorthand for ThemeManager.instance()."""
return ThemeManager.instance()
# ---------------------------------------------------------------------------
# QSS generator
# ---------------------------------------------------------------------------
_FONT_STACK = (
'"Segoe UI Variable", "Segoe UI", "Helvetica Neue", '
'"SF Pro Text", "Calibri", sans-serif'
)
def _build_qss(t: ThemeColors) -> str:
return f"""
/* ── Global ──────────────────────────────────────────────── */
QWidget {{
background-color: {t.bg_primary};
color: {t.text_primary};
font-family: {_FONT_STACK};
font-size: 11pt;
}}
/* ── Input fields ────────────────────────────────────────── */
QLineEdit {{
background-color: {t.bg_secondary};
border: 1px solid {t.border};
border-radius: 6px;
padding: 8px;
color: {t.text_primary};
}}
QLineEdit:focus {{
border: 1px solid {t.border_focus};
}}
/* ── Buttons ─────────────────────────────────────────────── */
QPushButton {{
background-color: {t.accent};
color: {t.accent_text};
border: none;
border-radius: 6px;
padding: 8px 16px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: {t.accent_hover};
}}
QPushButton:pressed {{
background-color: {t.accent_hover};
}}
QPushButton#secondaryBtn {{
background-color: {t.bg_secondary};
color: {t.text_primary};
font-weight: normal;
}}
QPushButton#secondaryBtn:hover {{
background-color: {t.bg_hover};
}}
QPushButton#toolBtn {{
background-color: transparent;
border: none;
border-radius: 4px;
padding: 4px;
}}
QPushButton#toolBtn:hover {{
background-color: {t.bg_hover};
}}
/* ── Lists ───────────────────────────────────────────────── */
QListWidget {{
background-color: {t.bg_tertiary};
border: none;
border-radius: 6px;
padding: 4px;
}}
QListWidget::item {{
padding: 10px;
border-radius: 4px;
}}
QListWidget::item:selected {{
background-color: {t.bg_selected};
border-left: 3px solid {t.accent};
}}
QListWidget::item:hover {{
background-color: {t.bg_hover};
color: {t.text_primary};
}}
/* ── Text areas ──────────────────────────────────────────── */
QTextEdit, QTextBrowser {{
background-color: {t.bg_primary};
border: none;
border-radius: 6px;
padding: 8px;
color: {t.text_primary};
}}
/* ── Scrollbar ───────────────────────────────────────────── */
QScrollBar:vertical {{
background: transparent;
width: 8px;
margin: 0;
}}
QScrollBar::handle:vertical {{
background: {t.scrollbar};
border-radius: 4px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background: {t.text_muted};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0;
}}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
background: transparent;
}}
QScrollBar:horizontal {{
background: transparent;
height: 8px;
margin: 0;
}}
QScrollBar::handle:horizontal {{
background: {t.scrollbar};
border-radius: 4px;
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background: {t.text_muted};
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0;
}}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{
background: transparent;
}}
/* ── Title label ─────────────────────────────────────────── */
QLabel#title {{
font-size: 15pt;
font-weight: bold;
color: {t.accent};
}}
/* ── Sidebar panel ───────────────────────────────────────── */
#sidebarPanel {{
background-color: {t.bg_tertiary};
}}
/* ── Splitter ────────────────────────────────────────────── */
QSplitter::handle {{
background-color: {t.separator};
width: 1px;
}}
/* ── Checkbox ────────────────────────────────────────────── */
QCheckBox {{
color: {t.text_primary};
}}
/* ── Menus ───────────────────────────────────────────────── */
QMenu {{
background-color: {t.bg_secondary};
border: 1px solid {t.border};
border-radius: 6px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 20px;
color: {t.text_primary};
border-radius: 4px;
}}
QMenu::item:selected {{
background-color: {t.bg_hover};
}}
QMenu::separator {{
height: 1px;
background: {t.separator};
margin: 4px 8px;
}}
/* ── Dialogs ─────────────────────────────────────────────── */
QDialog {{
background-color: {t.bg_primary};
color: {t.text_primary};
}}
/* ── MessageBox ──────────────────────────────────────────── */
QMessageBox {{
background-color: {t.bg_primary};
color: {t.text_primary};
}}
QMessageBox QLabel {{
color: {t.text_primary};
}}
/* ── InputDialog ─────────────────────────────────────────── */
QInputDialog {{
background-color: {t.bg_primary};
color: {t.text_primary};
}}
/* ── ScrollArea ──────────────────────────────────────────── */
QScrollArea {{
background-color: {t.bg_primary};
border: none;
}}
/* ── ToolTip ─────────────────────────────────────────────── */
QToolTip {{
background-color: {t.bg_secondary};
color: {t.text_primary};
border: 1px solid {t.border};
padding: 4px 8px;
font-size: 9pt;
}}
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Some files were not shown because too many files have changed in this diff Show More