initial commit
This commit is contained in:
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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
19
.env
Normal 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
12
.gitignore
vendored
@@ -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)
|
||||
|
||||
341
README.md
341
README.md
@@ -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
363
SECURITY_AUDIT.md
Normal 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
22
TODO.md
Normal 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
101
certs/README.md
Normal 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í
|
||||
```
|
||||
11
certs/cloudflare.ini.example
Normal file
11
certs/cloudflare.ini.example
Normal 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
28
certs/reload-server.sh
Executable 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
108
certs/setup-tls.sh
Executable 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
3481
chat_core.py
Normal file
File diff suppressed because it is too large
Load Diff
899
client.py
Normal file
899
client.py
Normal 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
935
crypto_utils.py
Normal 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]
|
||||
152
gemini.md
Normal file
152
gemini.md
Normal 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
6338
gui_client.py
Normal file
File diff suppressed because it is too large
Load Diff
18
ios_client/EncryptedChat/App/AppState.swift
Normal file
18
ios_client/EncryptedChat/App/AppState.swift
Normal 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()
|
||||
}
|
||||
36
ios_client/EncryptedChat/App/EncryptedChatApp.swift
Normal file
36
ios_client/EncryptedChat/App/EncryptedChatApp.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1644
ios_client/EncryptedChat/Core/ChatClient.swift
Normal file
1644
ios_client/EncryptedChat/Core/ChatClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
397
ios_client/EncryptedChat/Core/KeyStorage.swift
Normal file
397
ios_client/EncryptedChat/Core/KeyStorage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
65
ios_client/EncryptedChat/Core/MessageCache.swift
Normal file
65
ios_client/EncryptedChat/Core/MessageCache.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
95
ios_client/EncryptedChat/Crypto/CryptoErrors.swift
Normal file
95
ios_client/EncryptedChat/Crypto/CryptoErrors.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
196
ios_client/EncryptedChat/Crypto/CryptoUtils.swift
Normal file
196
ios_client/EncryptedChat/Crypto/CryptoUtils.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
371
ios_client/EncryptedChat/Crypto/DoubleRatchet.swift
Normal file
371
ios_client/EncryptedChat/Crypto/DoubleRatchet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
73
ios_client/EncryptedChat/Crypto/Ed25519Crypto.swift
Normal file
73
ios_client/EncryptedChat/Crypto/Ed25519Crypto.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
231
ios_client/EncryptedChat/Crypto/FieldArithmetic.swift
Normal file
231
ios_client/EncryptedChat/Crypto/FieldArithmetic.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
106
ios_client/EncryptedChat/Crypto/KeyEncryption.swift
Normal file
106
ios_client/EncryptedChat/Crypto/KeyEncryption.swift
Normal 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
|
||||
}
|
||||
}
|
||||
309
ios_client/EncryptedChat/Crypto/RSACrypto.swift
Normal file
309
ios_client/EncryptedChat/Crypto/RSACrypto.swift
Normal 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
|
||||
}
|
||||
}
|
||||
175
ios_client/EncryptedChat/Crypto/SenderKeyState.swift
Normal file
175
ios_client/EncryptedChat/Crypto/SenderKeyState.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
77
ios_client/EncryptedChat/Crypto/X25519Crypto.swift
Normal file
77
ios_client/EncryptedChat/Crypto/X25519Crypto.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
118
ios_client/EncryptedChat/Crypto/X3DH.swift
Normal file
118
ios_client/EncryptedChat/Crypto/X3DH.swift
Normal 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
|
||||
}
|
||||
}
|
||||
46
ios_client/EncryptedChat/Models/Conversation.swift
Normal file
46
ios_client/EncryptedChat/Models/Conversation.swift
Normal 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 }
|
||||
}
|
||||
43
ios_client/EncryptedChat/Models/DeviceBundle.swift
Normal file
43
ios_client/EncryptedChat/Models/DeviceBundle.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
9
ios_client/EncryptedChat/Models/Invitation.swift
Normal file
9
ios_client/EncryptedChat/Models/Invitation.swift
Normal 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
|
||||
}
|
||||
33
ios_client/EncryptedChat/Models/Message.swift
Normal file
33
ios_client/EncryptedChat/Models/Message.swift
Normal 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
|
||||
}
|
||||
19
ios_client/EncryptedChat/Models/User.swift
Normal file
19
ios_client/EncryptedChat/Models/User.swift
Normal 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?
|
||||
}
|
||||
188
ios_client/EncryptedChat/Network/ConnectionManager.swift
Normal file
188
ios_client/EncryptedChat/Network/ConnectionManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
90
ios_client/EncryptedChat/Network/ProtocolHandler.swift
Normal file
90
ios_client/EncryptedChat/Network/ProtocolHandler.swift
Normal 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
|
||||
}
|
||||
}
|
||||
38
ios_client/EncryptedChat/Utilities/Constants.swift
Normal file
38
ios_client/EncryptedChat/Utilities/Constants.swift
Normal 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
|
||||
}
|
||||
132
ios_client/EncryptedChat/Utilities/Extensions.swift
Normal file
132
ios_client/EncryptedChat/Utilities/Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
114
ios_client/EncryptedChat/ViewModels/AuthViewModel.swift
Normal file
114
ios_client/EncryptedChat/ViewModels/AuthViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
131
ios_client/EncryptedChat/ViewModels/ChatViewModel.swift
Normal file
131
ios_client/EncryptedChat/ViewModels/ChatViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
127
ios_client/EncryptedChat/ViewModels/ConversationListVM.swift
Normal file
127
ios_client/EncryptedChat/ViewModels/ConversationListVM.swift
Normal 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
|
||||
}
|
||||
}
|
||||
66
ios_client/EncryptedChat/ViewModels/ProfileViewModel.swift
Normal file
66
ios_client/EncryptedChat/ViewModels/ProfileViewModel.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
134
ios_client/EncryptedChat/Views/Auth/LoginView.swift
Normal file
134
ios_client/EncryptedChat/Views/Auth/LoginView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
49
ios_client/EncryptedChat/Views/Auth/PairingView.swift
Normal file
49
ios_client/EncryptedChat/Views/Auth/PairingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
4
ios_client/EncryptedChat/Views/Auth/RegisterView.swift
Normal file
4
ios_client/EncryptedChat/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Registration is handled within LoginView via mode toggle.
|
||||
// This file exists for potential future separation.
|
||||
164
ios_client/EncryptedChat/Views/Chat/ChatView.swift
Normal file
164
ios_client/EncryptedChat/Views/Chat/ChatView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ios_client/EncryptedChat/Views/Chat/ImageViewerView.swift
Normal file
43
ios_client/EncryptedChat/Views/Chat/ImageViewerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ios_client/EncryptedChat/Views/Chat/MessageBubbleView.swift
Normal file
123
ios_client/EncryptedChat/Views/Chat/MessageBubbleView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
55
ios_client/EncryptedChat/Views/Chat/MessageInputView.swift
Normal file
55
ios_client/EncryptedChat/Views/Chat/MessageInputView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
46
ios_client/EncryptedChat/Views/Chat/SearchOverlayView.swift
Normal file
46
ios_client/EncryptedChat/Views/Chat/SearchOverlayView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Group creation is handled within NewConversationSheet via the isGroup toggle.
|
||||
// This file exists for potential future separation.
|
||||
123
ios_client/EncryptedChat/Views/Groups/GroupInfoView.swift
Normal file
123
ios_client/EncryptedChat/Views/Groups/GroupInfoView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
ios_client/EncryptedChat/Views/Groups/InvitationBanner.swift
Normal file
41
ios_client/EncryptedChat/Views/Groups/InvitationBanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Profile editing is handled within ProfileView when isOwnProfile = true.
|
||||
// This file exists for potential future separation.
|
||||
111
ios_client/EncryptedChat/Views/Profile/ProfileView.swift
Normal file
111
ios_client/EncryptedChat/Views/Profile/ProfileView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
239
ios_client/incremental_sync_changes.md
Normal file
239
ios_client/incremental_sync_changes.md
Normal 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
33
ios_client/project.yml
Normal 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
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
142
protocol.py
Normal 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
11
requirements.txt
Normal 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
252
scaling.md
Normal 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 000–20 000 uživatelů**, **2000–5000 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í ~2–5ms 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
189
schema.sql
Normal 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;
|
||||
79
tests/PENTEST_CLIENT.md
Normal file
79
tests/PENTEST_CLIENT.md
Normal 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
338
tests/pentest_client.py
Normal 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
539
theme.py
Normal 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;
|
||||
}}
|
||||
"""
|
||||
BIN
uploads/041d292d-c94a-4c46-b8f0-b4ac02536d50.enc
Normal file
BIN
uploads/041d292d-c94a-4c46-b8f0-b4ac02536d50.enc
Normal file
Binary file not shown.
BIN
uploads/055eca42-b4b5-4211-b819-030dc601e9b4.enc
Normal file
BIN
uploads/055eca42-b4b5-4211-b819-030dc601e9b4.enc
Normal file
Binary file not shown.
BIN
uploads/0cb3e08e-b294-437d-8228-abf97c996311.enc
Normal file
BIN
uploads/0cb3e08e-b294-437d-8228-abf97c996311.enc
Normal file
Binary file not shown.
BIN
uploads/189af45e-fa60-4fd6-8a3f-8313921d1f48.enc
Normal file
BIN
uploads/189af45e-fa60-4fd6-8a3f-8313921d1f48.enc
Normal file
Binary file not shown.
BIN
uploads/1ce346fd-54f6-4e7a-85c4-7c5098e01d2a.enc
Normal file
BIN
uploads/1ce346fd-54f6-4e7a-85c4-7c5098e01d2a.enc
Normal file
Binary file not shown.
BIN
uploads/1fb0cffc-1e95-4f21-aba9-90fc398d1bb2.enc
Normal file
BIN
uploads/1fb0cffc-1e95-4f21-aba9-90fc398d1bb2.enc
Normal file
Binary file not shown.
BIN
uploads/23737dc9-334b-4818-a258-758596e75aef.enc
Normal file
BIN
uploads/23737dc9-334b-4818-a258-758596e75aef.enc
Normal file
Binary file not shown.
BIN
uploads/37ef215b-b30e-4339-a7ab-43445df27526.enc
Normal file
BIN
uploads/37ef215b-b30e-4339-a7ab-43445df27526.enc
Normal file
Binary file not shown.
BIN
uploads/41384719-ab8c-4b65-9823-91217d3bf3d3.enc
Normal file
BIN
uploads/41384719-ab8c-4b65-9823-91217d3bf3d3.enc
Normal file
Binary file not shown.
BIN
uploads/4f661a78-fd57-469f-8af1-fd88bf8c167e.enc
Normal file
BIN
uploads/4f661a78-fd57-469f-8af1-fd88bf8c167e.enc
Normal file
Binary file not shown.
BIN
uploads/572a6f37-6a4f-4004-a95a-9e10a3080b7e.enc
Normal file
BIN
uploads/572a6f37-6a4f-4004-a95a-9e10a3080b7e.enc
Normal file
Binary file not shown.
BIN
uploads/6aabd2ba-c64b-40ae-960a-a7a161c337db.enc
Normal file
BIN
uploads/6aabd2ba-c64b-40ae-960a-a7a161c337db.enc
Normal file
Binary file not shown.
BIN
uploads/6deec74d-940d-498a-8f91-38424b935a13.enc
Normal file
BIN
uploads/6deec74d-940d-498a-8f91-38424b935a13.enc
Normal file
Binary file not shown.
BIN
uploads/73680cd7-4a47-4944-980f-4225e019527c.enc
Normal file
BIN
uploads/73680cd7-4a47-4944-980f-4225e019527c.enc
Normal file
Binary file not shown.
BIN
uploads/7e32ef79-2c29-4466-8c1a-cd5cee3e430c.enc
Normal file
BIN
uploads/7e32ef79-2c29-4466-8c1a-cd5cee3e430c.enc
Normal file
Binary file not shown.
BIN
uploads/8cc77d3d-f28a-4b9c-80b6-adc6ec672214.enc
Normal file
BIN
uploads/8cc77d3d-f28a-4b9c-80b6-adc6ec672214.enc
Normal file
Binary file not shown.
BIN
uploads/8e81df99-8ae0-4348-842b-a3d0de0510f5.enc
Normal file
BIN
uploads/8e81df99-8ae0-4348-842b-a3d0de0510f5.enc
Normal file
Binary file not shown.
BIN
uploads/9321986f-0918-4e59-b30b-bc8086a20508.enc
Normal file
BIN
uploads/9321986f-0918-4e59-b30b-bc8086a20508.enc
Normal file
Binary file not shown.
BIN
uploads/9a17095f-58bc-461c-a8cc-43a20ec78392.enc
Normal file
BIN
uploads/9a17095f-58bc-461c-a8cc-43a20ec78392.enc
Normal file
Binary file not shown.
BIN
uploads/a5eb6b09-47ab-43c0-9d44-bef051c56a17.enc
Normal file
BIN
uploads/a5eb6b09-47ab-43c0-9d44-bef051c56a17.enc
Normal file
Binary file not shown.
BIN
uploads/avatars/0b282232-9214-4fbe-a72d-2f07a74760e3.png
Normal file
BIN
uploads/avatars/0b282232-9214-4fbe-a72d-2f07a74760e3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
uploads/avatars/14420bdd-f4e3-4e57-87e9-4c553361cb1c.png
Normal file
BIN
uploads/avatars/14420bdd-f4e3-4e57-87e9-4c553361cb1c.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
uploads/avatars/1c4b7c64-fb08-4cc6-948f-5c9e46aa5b35.jpg
Normal file
BIN
uploads/avatars/1c4b7c64-fb08-4cc6-948f-5c9e46aa5b35.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
uploads/avatars/59abf7ba-576c-4052-9042-6ccd18e68ded.png
Normal file
BIN
uploads/avatars/59abf7ba-576c-4052-9042-6ccd18e68ded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
uploads/avatars/74e3f5a5-df49-4f76-ba39-c5b5aab2e77b.jpg
Normal file
BIN
uploads/avatars/74e3f5a5-df49-4f76-ba39-c5b5aab2e77b.jpg
Normal file
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
Reference in New Issue
Block a user