diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fbcf12d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +The main Python modules live in the repository root. `server.py` contains the asyncio TCP server, request handlers, rate limiting, and upload flows. `chat_core.py` holds shared client logic, crypto workflows, and local key handling. `client.py` is the CLI, `gui_client.py` is the PyQt6 GUI, `db.py` is the MySQL layer, `protocol.py` defines the newline-delimited JSON protocol, and `crypto_utils.py` contains X3DH, Double Ratchet, Sender Keys, and local encryption helpers. Use `schema.sql` for a clean database bootstrap. Security and architecture notes are tracked in `SECURITY_AUDIT.md`, `README.md`, `scaling.md`, and `CLAUDE.md`. Put new test tooling under `tests/`. Treat `zaloha/` as archive code, not an active source directory. + +## Build, Test, and Development Commands + +Use the project virtualenv and MySQL schema: + +```bash +.venv/bin/pip install -r requirements.txt +mysql -u -p < schema.sql +.venv/bin/python server.py +.venv/bin/python client.py +.venv/bin/python gui_client.py +``` + +For quick validation, run: + +```bash +.venv/bin/python -m py_compile server.py chat_core.py client.py gui_client.py db.py +.venv/bin/python tests/pentest_client.py --server-host --member-email ... --peer-email ... --outsider-email ... +``` + +There is no full `pytest` suite yet; current regression coverage is mainly protocol-level through `tests/pentest_client.py`. + +## Coding Style & Naming Conventions + +Follow existing Python conventions: 4-space indentation, `snake_case` for functions and variables, `PascalCase` for classes, and type hints on new or changed code. Keep handlers non-blocking: DB, file, or SMTP work that can block should be moved behind async helpers or `asyncio.to_thread()`. Reuse central validation helpers instead of duplicating checks, and keep logs free of secrets, emails, or raw user-controlled text where possible. + +## Testing Guidelines + +Add tests in `tests/` with descriptive names. Prefer `test_.py` for focused checks and `_client.py` for protocol or penetration probes. Every security fix should include a regression path that covers malformed input, authorization, replay, rate limiting, or multi-device behavior. + +## Commit & Pull Request Guidelines + +Git history is not available in this workspace snapshot, so use short imperative commit messages. Conventional Commit style is preferred, for example `fix: reject invalid ratchet headers`. PRs should summarize behavior changes, mention schema or `.env` updates, link related issues, and include CLI or GUI evidence for user-visible changes. + +## Security & Configuration Tips + +Do not commit `.env`, TLS private keys, uploaded files, or local key material from `~/.encrypted_chat/`. When testing TLS, remember that `0.0.0.0` is a server bind address, not a valid client hostname. Use a host or IP that matches the certificate SAN or CN. diff --git a/README.md b/README.md index c0221cf..d015ff1 100644 --- a/README.md +++ b/README.md @@ -1,333 +1,246 @@ -<<<<<<< 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. +## Architektura + +``` +┌─────────────┐ TLS/TCP ┌─────────────┐ MySQL ┌─────────┐ +│ GUI/CLI │◄───────────────►│ Server │◄──────────────►│ DB │ +│ klient │ JSON + base64 │ (asyncio) │ │ │ +└─────────────┘ └─────────────┘ └─────────┘ + │ │ + │ X3DH + Double Ratchet │ Opaque blobs + │ Sender Keys (skupiny) │ (server nevidí plaintext) + ▼ ▼ + Lokální klíče Šifrované zprávy + (~/.encrypted_chat/) + metadata +``` + ## 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, ...) | +| Soubor | Řádky | Účel | +|--------|-------|------| +| `server.py` | ~2 900 | Asyncio TCP server, 45 handlerů, rate limiting, 5 asyncio.Lock guardů, real-time notifikace | +| `db.py` | ~1 700 | MySQL CRUD, connection pooling (pool_size=10), phantom users, reactions/pins CRUD | +| `schema.sql` | ~190 | MySQL schéma (14 tabulek) | ### 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 | +| Soubor | Řádky | Účel | +|--------|-------|------| +| `gui_client.py` | ~6 300 | PyQt6 GUI — dark/light téma, widget-based message bubbles, verifikace kontaktů, privacy overlay | +| `client.py` | ~900 | CLI klient — 23 menu opcí | +| `chat_core.py` | ~3 500 | Sdílená logika — session management, X3DH/ratchet šifrování, lokální klíče, multi-device | +| `theme.py` | ~540 | Catppuccin dark + Signal-inspired light téma, live switching | ### 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 | +| `crypto_utils.py` (~935 ř.) | Ed25519, X25519, AES-256-GCM, HKDF, PBKDF2, X3DH, Double Ratchet (state rollback), Sender Keys (state rollback), ECP1 key encryption, contact verification (fingerprints, safety numbers, QR), message padding | +| `protocol.py` (~140 ř.) | Newline-delimited JSON protokol, base64 encoding, verze (0.8.4) | + +### iOS klient +| Složka | Účel | +|--------|------| +| `ios_client/` (47 Swift souborů, ~5 000 ř.) | Nativní iOS port — CryptoKit + pure Swift GF(2^255-19) + Security.framework RSA, SwiftUI views, wire-kompatibilní s Python serverem | + +### Testy +| Soubor | Účel | +|--------|------| +| `tests/pentest_client.py` (~340 ř.) | Automatizované security regresní testy (AuthZ, malformed headers, session reset, rate limits) | ## 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`. +2. Spustit `schema.sql` v MySQL 3. `python server.py` -4. Klient: `python client.py` (CLI) nebo `python gui_client.py` (GUI, PyQt6) +4. Klient: `python gui_client.py` (GUI) nebo `python client.py` (CLI) ## 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) | +| RSA-4096 | Asymetrický | Pouze login challenge-response. Šifrovaný ECP1 (PBKDF2 600k + AES-256-GCM). | +| Identity Key (IK) | Ed25519 | Podpisy, konverze na X25519 pro X3DH. Šifrovaný ECP1. | +| Signed Pre-Key (SPK) | X25519 | DH v X3DH, podepsaný IK. **Rotuje se každých 7 dní** s grace periodem. | +| 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). +1. Alice stáhne Bobovy per-device key bundles (IK, SPK, OPK) → X3DH per device → shared secret per device. +2. Double Ratchet inicializován ze shared secret — jedna session per (user, device). +3. Každá zpráva: symmetric ratchet (HMAC chain) → message key → AES-256-GCM. +4. Každá odpověď: DH ratchet (nový X25519 keypair) → nový root key + chain key. +5. Per-device ciphertext — každé zařízení příjemce dostane individuálně šifrovaný blob. +6. Self-encrypted kopie s SELF_DEVICE_ID sentinel, čitelná všemi vlastními zařízeními. ### 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). +1. Každý odesílatel má vlastní SenderKeyState per group. +2. Sender key distribuován členům přes pairwise Double Ratchet (jako control DM). 3. Skupinové zprávy: symmetric ratchet na sender key → AES-256-GCM. -4. Jeden ciphertext pro celou skupinu (efektivní). +4. Stejný ciphertext pro všechny příjemce (efektivní). + +### Kontaktní verifikace (Signal-style) +- **Safety numbers** — 60-digit číslo (12 skupin × 5 číslic), deterministické pro každý pár. +- **QR kódy** — binární payload zakódovaný jako base64. +- **Fingerprints** — 30-digit per-user číslo. +- **TOFU** — Trust On First Use + explicit verification + key change warning. ### 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 + private.pem / public.pem — RSA (login, ECP1 formát) + identity_private.bin / _public.bin — Ed25519 (ECP1 formát) + device_id.txt — UUID tohoto zařízení + spk_private.bin / spk_id.txt — Aktuální SPK (AES-256-GCM) + prev_spk_private.bin / prev_spk_id.txt — Předchozí SPK, grace period + opk_private/{opk_id}.bin — One-time prekeys (AES-256-GCM) + sessions/{uid}_{did}.bin — Double Ratchet stavy (AES-256-GCM) + sender_keys/{conv_id}.bin — Vlastní sender keys + sender_keys_recv/{conv}_{uid}_{did}.bin — Přijaté sender keys + known_identity_keys.bin — TOFU registr (AES-256-GCM) + verified_contacts.bin — Explicitní verifikace (AES-256-GCM) + message_cache/{conv_id}.bin — Šifrovaný message cache + login_lockout.json — Brute-force lockout stav ``` ## 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 +### Šifrování privátních klíčů (ECP1 formát) +- **PBKDF2-HMAC-SHA256** s 600 000 iteracemi (OWASP 2023) +- **AES-256-GCM**, 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. +- Zpětná kompatibilita: staré PEM se migrují automaticky -### Š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` +### Lokální šifrování dat +- Session/sender key soubory, OPK, SPK, message cache, verifikační soubory — AES-256-GCM klíčem z HKDF(identity_key) +- `chmod 0o700` na adresáře, `0o600` na soubory -### 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) +### Brute-force ochrana +- Exponenciální backoff: `min(2^N, 300)` sekund po N chybných pokusech +- Aplikováno na login + privacy overlay unlock ### 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í +- Automatická rotace s grace periodem pro in-flight X3DH +- Omezuje dopad kompromitace SPK -### 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 +### Ratchet state rollback +- Snapshot/restore při selhání dešifrování (DoubleRatchet + SenderKeyState) -## Registrace +### Secure deletion +- Overwrite `os.urandom()` + `fsync` + `unlink` na smazané citlivé soubory -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. +### Message padding +- Bucketed padding (64B–64KB) maskuje délku zpráv + +### Metadata privacy +- Log sanitizace (žádná PII), metadata retention (90 dní), sender chain minimalizace + +### Anti-enumeration +- Phantom users pro neregistrované emaily +- Generické odpovědi na register/login/get_user_info ## 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 +- **Devices tabulka** — každé přihlášení registruje device (UUID) +- **Per-device prekeys** — každé zařízení má vlastní SPK + OPKs +- **Per-device sessions** — klíčované `"user_id:device_id"` +- **Self-encryption** — statický klíč z identity key (čitelné všemi vlastními zařízeními) +- **Pairing** — přenos RSA + Ed25519, nové zařízení generuje vlastní SPK + OPKs -### Device Pairing (zjednodušený) +## Features -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. +### Protokol & šifrování +- X3DH + Double Ratchet (DM) s forward secrecy +- Sender Keys (skupiny) s distribucí přes pairwise ratchet +- Per-device šifrování (multi-device) +- SPK rotace (7 dní) + grace period +- Ratchet state rollback při selhání +- ECP1 šifrování klíčů (PBKDF2 600k) +- Message padding (bucketed 64B–64KB) +- Kontaktní verifikace (safety numbers, fingerprints, QR kódy) -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. +### Komunikace +- DM + skupinové konverzace +- Reakce na zprávy (thumbsup, heart, laugh, surprised, sad, thumbsdown) +- Přeposílání zpráv (text, obrázky, soubory) +- Připnuté zprávy (pin/unpin + dialog) +- @Mentions s autocomplete +- Odpovědi na zprávy (reply_to) +- Hledání zpráv (client-side, Ctrl+F) +- Šifrované obrázky (AES-256-GCM, chunked upload, thumbnail) +- Šifrované soubory (až 50 MB, chunked upload) +- Read receipts (real-time) -### 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 +### Skupiny +- Skupinové pozvánky (accept/decline) +- Leave group + přenos creatora +- Rename group (creator only) +- Delete conversation (DMs per-user, groups creator-only) +- Group avatar -## Device Revocation (Key Rotation) +### Správa +- Multi-device support (per-device sessions, pairing) +- User profily (telefon, lokace, avatar, viditelnost) +- Online/offline status +- Session reset (při poškození ratchetu) +- Key rotation (revokace zařízení) +- Brute-force lockout -Rotuje RSA login klíč. Odpojí ostatní sessions. Forward secrecy zajišťuje, že kompromitace -jednoho session klíče neodhalí historii — není potřeba re-encryption. +### GUI (PyQt6) +- Dark (Catppuccin Mocha) + Light (Signal) téma s live switching +- Widget-based message bubbles s ConversationDelegate +- Cirkulární avatary + online zelená tečka +- Unread count badges +- Privacy overlay / lock screen (30s timeout + heslo) +- Drag & drop souborů +- Frameless dialogy +- Connection indicator (green/red/orange) + auto-reconnect +- VerificationDialog (safety numbers, QR, fingerprints) +- Key change warning dialog + +### CLI +- 23 menu opcí (DM, skupiny, soubory, reakce, piny, forwarding, verifikace, zařízení, search) + +### iOS (SwiftUI) +- Wire-kompatibilní s Python serverem +- Kompletní Signal Protocol (X3DH, Double Ratchet, Sender Keys) +- CryptoKit + pure Swift field arithmetic + Security.framework RSA +- SwiftUI views (login, chat, groups, profiles, search) ## Konfigurace ### Server + DB - `SERVER_HOST` (default `127.0.0.1`), `SERVER_PORT` (default `9999`) - `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` +- `DB_POOL_SIZE` (default `10`) ### 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_REQUIRED` — vyžaduje TLS_ENABLED +- `TLS_CERT_FILE`, `TLS_KEY_FILE` — cesty k certifikátu (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_CA_FILE` (klient) — vlastní CA certifikát - `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) +- Bez SMTP = dev mód (kód se vrací přímo klientovi) ### 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ů +- `MAX_MESSAGE_BYTES` (default `65536`), `MAX_IMAGE_BYTES` (5 MB), `MAX_FILE_BYTES` (50 MB) +- `MAX_INPUT_CHARS` (GUI, default `2000`) +- `METADATA_RETENTION_DAYS` (default `90`) +- Rate limity: register 3/min, login 10/min, send_message 20/min +- Connection: 20 req/s, max 10/IP, 200 global ### 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ů. @@ -336,8 +249,22 @@ Dva bezpečnostní audity provedeny (kód review). Nalezeno 6 CRITICAL, 12 HIGH, |-----------|--------|----------|-------| | CRITICAL | 6 | **6** | 0 | | HIGH | 12 | **11** | 1 (H9 — by-design) | -| MEDIUM | 12 | **10** | 2 (M1 částečně, M6, M7) | -| LOW | 8 | 0 | 8 | +| MEDIUM | 12 | **11** | 1 (M7) | +| LOW | 8 | **1** | 7 | -Detaily viz `CLAUDE.md`. ->>>>>>> d506e65 (initial commit) +Detaily viz `SECURITY_AUDIT.md` a `CLAUDE.md`. + +## Known Issues + +- **Sender Key Redistribution:** Nový člen skupiny nedešifruje staré skupinové zprávy (sender keys se nedistribuují znovu při přidání). +- **iOS: Contact Key Verification** — safety numbers, QR kódy, TOFU zatím neimplementovány v iOS klientu. + +## Závislosti + +- `cryptography` — Ed25519, X25519, AES-GCM, RSA, HKDF, PBKDF2 +- `mysql-connector-python` — MySQL s connection pooling +- `python-dotenv` — env vars +- `PyQt6` — GUI +- `Pillow` — resize/thumbnail obrázků +- `qrcode` — generování QR kódů +- `pyzbar` (volitelné) — skenování QR kódů diff --git a/TODO.md b/TODO.md index 302a5f5..5f35b01 100644 --- a/TODO.md +++ b/TODO.md @@ -1,22 +1,131 @@ # TODO -## Distributed global cap for phantom users (multi-process safe) +## Zbývající bezpečnostní nálezy -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. +### HIGH +- [ ] **H9: Self-encryption key** — statický/deterministický klíč z identity key (by-design pro cross-device čtení, architektonické omezení — žádná forward secrecy pro self-copies) + +### MEDIUM +- [ ] **M7: MySQL TLS** — `db.get_connection()` nepředává SSL parametry. Na vzdáleném serveru jdou DB credentials v plaintextu. Přidat `ssl_ca`, `ssl_cert`, `ssl_key`. + +### LOW (nízké riziko) +- [ ] L1: Hex string keys v skipped messages dict — timing side-channel (post-auth) +- [ ] L2: RatchetHeader redundantní type konverze +- [ ] L3: `notif_label.setText()` vs `setHtml()` křehkost +- [ ] L4: SQL column interpolation v `update_user_profile` (whitelist chrání) +- [ ] L5: TLS cipher suite hardening (Python defaults rozumné, ne explicitní) +- [ ] L6: Temporary pairing key cleanup z paměti +- [ ] L7: `_user_cache` indefinite growth + +## Funkční TODO + +### High Priority +- [ ] **Sender Key Redistribution** — při `add_member` redistribuovat sender keys všem členům včetně nového. Nový člen skupiny momentálně nedešifruje staré zprávy. + +### Medium Priority +- [ ] **iOS: Contact Key Verification** — safety numbers, fingerprints, QR kódy, TOFU registr. Spec viz CLAUDE.md (iOS implementation spec). +- [ ] Typing indicators (`typing_start`/`typing_stop` + 3s timeout, debounce) +- [ ] Delivery receipts (`message_delivered` notifikace — 1 fajfka odesláno, 2 fajfky doručeno, modré přečteno) +- [ ] Group admin roles (více adminů) +- [ ] Edit sent messages + +### Low Priority +- [ ] Desktop notifications (system tray) +- [ ] Image gallery view +- [ ] Systemd + Docker deployment + +## Před nasazením do produkce + +- [ ] **TLS certifikáty** — Let's Encrypt nebo vlastní CA. `TLS_ENABLED=true`, `TLS_CERT_FILE`, `TLS_KEY_FILE`. +- [ ] **SMTP** — reálný SMTP server pro registrační kódy. +- [ ] **MySQL TLS** — SSL parametry v `db.get_connection()` pokud DB na jiném stroji. +- [ ] **UPLOAD_DIR** — persistentní disk, dostatečná kapacita, správná práva (0o700). +- [ ] **Backup** — pravidelný backup MySQL + UPLOAD_DIR. +- [ ] **Packaging** — pyinstaller / cx_Freeze pro distribuci klientů. +- [ ] **Penetrační testy** — manuální + automatizované (path traversal, DoS, race conditions, enumeration, TLS downgrade, pairing hijacking). + +## Budoucí plány + +- [ ] WebSocket upgrade (nahradit raw TCP pro lepší kompatibilitu) +- [ ] Mobilní push notifikace (APNs + FCM) +- [ ] Auto-update klientů (po packagingu) +- [ ] Monetizace — oddělený platební server (Stripe), premium kódy, free/premium tier. Detaily viz CLAUDE.md. + +## Phantom Users — Distributed Cap + +Pro multi-process deployment: +1. DB-backed quota (`system_quotas` tabulka, `SELECT ... FOR UPDATE`) +2. Same-email races přes `UNIQUE(email)` +3. Periodic reconciliation job +4. Shared rate-limits (Redis nebo DB atomic counters) +5. Concurrency testy + +## Hotovo + +### Security (všechny CRITICAL + většina HIGH/MEDIUM opraveny) +- [x] C1: readuntil DoS → LimitOverrunError handling +- [x] C2: SenderKeyState fast-forward DoS → MAX_SENDER_KEY_SKIP=256 +- [x] C3: Plaintext message cache → AES-256-GCM šifrování +- [x] C4: OPK file permissions → chmod 0o600 +- [x] C5: Upload size validation → received_bytes == file_size check +- [x] C6: Path traversal → UUID validace + is_relative_to +- [x] H1: Session/sender key šifrování → AES-256-GCM via _local_key +- [x] H2+H14: TLS hardening → ENVIRONMENT=dev guard +- [x] H3+H13: Anti-enumeration → generické odpovědi, auth pro get_user_info +- [x] H4: Race conditions → 5 asyncio.Lock guardů +- [x] H5+H6: Protocol error handling → base64/JSON exception handling +- [x] H7: Avatar path traversal → _safe_avatar_path +- [x] H8: Password memory → bytearray + zero-out +- [x] H10: Image validation → size + dimensions check +- [x] H11: Filename sanitization → os.path.basename +- [x] H12: OPK race condition → SELECT FOR UPDATE +- [x] M2: HKDF salt → b"\x00"*32 +- [x] M3: PBKDF2 600k iterations (ECP1 formát) +- [x] M4: SPK rotace 7 dní + grace period +- [x] M5: Rate limit cleanup +- [x] M6: TOCTOU → remove_conversation_member_atomic +- [x] M8: UUID validace všech handlerů +- [x] M9: Ratchet state rollback (snapshot/restore) +- [x] M10: message_ids cap (500) +- [x] M11: Pairing poll token (secrets.token_hex) +- [x] M12: Upload end size validation +- [x] M13: chmod 0o700/0o600 na klíčové adresáře/soubory +- [x] L8: Phantom user cleanup (30 dní + email validace) +- [x] SPK/OPK šifrování na disku +- [x] Brute-force lockout (exponenciální backoff) + +### Features +- [x] X3DH + Double Ratchet (Signal Protocol) +- [x] Sender Keys pro skupiny +- [x] Multi-device support (per-device sessions, pairing) +- [x] Kontaktní verifikace (safety numbers, fingerprints, QR kódy) — Python klienti +- [x] Message padding (bucketed 64B–64KB) +- [x] Metadata privacy (log sanitizace, retention, sender chain minimalizace) +- [x] Secure deletion (overwrite + fsync + unlink) +- [x] Reakce na zprávy (6 emoji typů) +- [x] Přeposílání zpráv (text, obrázky, soubory) +- [x] Připnuté zprávy (pin/unpin + dialog) +- [x] @Mentions s autocomplete +- [x] Hledání zpráv (client-side, Ctrl+F) +- [x] Šifrované obrázky + soubory (chunked upload, až 50 MB) +- [x] Skupinové pozvánky (accept/decline) +- [x] Leave group + přenos creatora +- [x] Rename group (creator only) +- [x] Delete conversation +- [x] Group avatar +- [x] User profily (telefon, lokace, avatar, viditelnost) +- [x] Online/offline status +- [x] Unread count badges (server-side pro offline uživatele) +- [x] Privacy overlay / lock screen +- [x] Dark/light téma (Catppuccin + Signal) s live switching +- [x] Session recovery (reset + auto X3DH) +- [x] Connection indicator + auto-reconnect +- [x] Drag & drop souborů +- [x] Favorites (GUI) +- [x] Phantom users (anti-enumeration) +- [x] DB connection pooling (pool_size=10) +- [x] Version negotiation (0.8.4, MIN_CLIENT_VERSION=0.8.3) +- [x] Graceful server shutdown +- [x] iOS klient (47 Swift souborů, ~5 000 řádků) +- [x] CLI klient (23 menu opcí) +- [x] Pentest harness (4 test kategorií) diff --git a/chat_core.py b/chat_core.py index 7f19d07..d5dd0cc 100644 --- a/chat_core.py +++ b/chat_core.py @@ -1044,30 +1044,34 @@ class ChatClient: # ------------------------------------------------------------------ async def register(self, username: str, password: str, email: str) -> tuple[bool, str]: - """Register user. Generates RSA + Ed25519 + prekeys.""" + """Register user. Generates RSA + Ed25519 in memory (saved to disk + only after server confirms registration via confirm_registration).""" self.username = username self.email = email pwd_bytes = bytearray(password.encode("utf-8")) if password else None try: - # RSA keys for login - priv, pub, err = load_keys(email, password=bytes(pwd_bytes) if pwd_bytes else None) + pwd = bytes(pwd_bytes) if pwd_bytes else None + # Try loading existing keys (previous successful registration) + priv, pub, err = load_keys(email, password=pwd) if priv is None: priv, pub = generate_rsa_keypair() - save_keys(email, priv, pub, password=bytes(pwd_bytes) if pwd_bytes else None) self.private_key = priv self.public_key = pub - # Ed25519 identity keys - ed_priv, ed_pub = _load_identity_keys(email, password=bytes(pwd_bytes) if pwd_bytes else None) + try: + ed_priv, ed_pub = _load_identity_keys(email, password=pwd) + except Exception: + ed_priv, ed_pub = None, None if ed_priv is None: ed_priv, ed_pub = generate_identity_keypair() - _save_identity_keys(email, ed_priv, ed_pub, password=bytes(pwd_bytes) if pwd_bytes else None) self.identity_private = ed_priv self.identity_public = ed_pub self._cache_key = derive_self_encryption_key(ed_priv) self._local_key = derive_local_storage_key(ed_priv) - self._load_verification_stores() + + # Store password for saving keys after confirm + self._reg_password = pwd finally: if pwd_bytes: pwd_bytes[:] = b'\x00' * len(pwd_bytes) @@ -1100,6 +1104,7 @@ class ChatClient: **extra_fields, ) if start["status"] != "ok": + self._reg_password = None return False, start["data"]["message"] code = start["data"].get("code") if code: @@ -1109,6 +1114,12 @@ class ChatClient: async def confirm_registration(self, email: str, username: str, code: str) -> tuple[bool, str]: confirm = await self.send_and_recv("register_confirm", email=email, code=code) if confirm["status"] == "ok": + # Registration confirmed — NOW save keys to disk + pwd = getattr(self, "_reg_password", None) + save_keys(email, self.private_key, self.public_key, password=pwd) + _save_identity_keys(email, self.identity_private, self.identity_public, password=pwd) + self._reg_password = None + self._load_verification_stores() # Upload prekeys immediately after registration await self._generate_and_upload_prekeys() return True, f"Registered as '{username}' (ID: {confirm['data']['user_id']})" diff --git a/ios_client 0.8.5/ARCHITECTURE.md b/ios_client 0.8.5/ARCHITECTURE.md new file mode 100644 index 0000000..0715af1 --- /dev/null +++ b/ios_client 0.8.5/ARCHITECTURE.md @@ -0,0 +1,346 @@ +# Kecalek iOS — Architecture & Features + +**Version:** 0.8.5 +**Platform:** iOS 26+ / Swift 6 +**Files:** 57 Swift source files + +--- + +## Project Structure + +``` +Kecalek/ +├── KecalekApp.swift # App entry point, tab navigation +├── AppState.swift # Login state, connection monitoring, reconnection +├── Core/ +│ ├── ChatClient.swift # Main actor — all server communication & crypto (3400+ lines) +│ ├── KeyStorage.swift # Persistent key storage (RSA, Ed25519, sessions, TOFU) +│ ├── KeychainService.swift # Secure credential storage (biometric auth) +│ └── MessageCache.swift # Encrypted message cache (per-conversation) +├── Crypto/ +│ ├── CryptoUtils.swift # AES-256-GCM, HKDF, chain KDF, local encryption +│ ├── DoubleRatchet.swift # Signal Double Ratchet (DM encryption) +│ ├── X3DH.swift # Extended Triple Diffie-Hellman (session init) +│ ├── SenderKeyState.swift # Sender Key chains (group encryption) +│ ├── Ed25519Crypto.swift # Identity key generation & signing +│ ├── X25519Crypto.swift # DH key agreement & Ed25519↔X25519 conversion +│ ├── RSACrypto.swift # RSA-2048 key generation, PKCS#1/PKCS#8 +│ ├── KeyEncryption.swift # ECP1 format: PBKDF2 600K + AES-GCM key encryption +│ ├── FieldArithmetic.swift # GF(2^255-19) for Ed25519→X25519 conversion +│ ├── MessagePadding.swift # Bucket-based padding (64B–64KB) for metadata privacy +│ ├── ContactVerification.swift # Fingerprints, safety numbers, QR codes +│ └── CryptoErrors.swift # Error types +├── Network/ +│ ├── ConnectionManager.swift # TCP/TLS via Network.framework (actor) +│ └── ProtocolHandler.swift # Newline-delimited JSON encoding/decoding +├── Models/ +│ ├── Message.swift # Message, reactions, replies, pins, files, images +│ ├── Conversation.swift # Conversation, members, group detection +│ ├── User.swift # User, UserProfile +│ ├── DeviceBundle.swift # X3DH key bundle per device +│ └── Invitation.swift # Group invitation +├── ViewModels/ +│ ├── AuthViewModel.swift # Login, register, pairing, biometrics +│ ├── ChatViewModel.swift # Messages, sending, search, reactions, pins +│ ├── ConversationListVM.swift # Conversations, online users, favorites, avatars +│ ├── ProfileViewModel.swift # Profile editing, avatar upload +│ └── VerificationVM.swift # Safety numbers, QR verification +├── Views/ +│ ├── Auth/ # LoginView, RegisterView, PairingView, AuthorizeDeviceView +│ ├── Chat/ # ChatView, MessageBubbleView, MessageInputView, etc. +│ ├── Components/ # CircularAvatarView, ConnectionIndicator, OnlineDotOverlay +│ ├── Conversations/ # ConversationListView, ConversationRowView, NewConversationSheet +│ ├── Groups/ # GroupInfoView, InvitationBanner, CreateGroupSheet +│ ├── Profile/ # ProfileView, EditProfileView +│ └── Verification/ # SafetyNumberView, QRCodeScannerView, VerificationStatusView +└── Utilities/ + ├── Constants.swift # Version, limits, timeouts, server defaults, crypto params + └── Extensions.swift # Data hex/base64, DateParsing, Dictionary helpers +``` + +--- + +## Architecture + +### Pattern: MVVM + Actor Isolation + +- **Views** — SwiftUI, declarative UI, bind to `@Observable` ViewModels +- **ViewModels** — `@Observable final class`, business logic, async operations +- **ChatClient** — `actor`, single source of truth for all crypto & network ops +- **Models** — plain `struct`s with `Identifiable`, `Codable` + +### Concurrency Model + +- `ChatClient` is an **actor** — all crypto state (keys, sessions, ratchets) is thread-safe +- All network calls use `async/await` +- Real-time notifications via `AsyncStream` (multiple subscribers) +- Background tasks: avatar loading, reconnection, notification listening + +### Connection Lifecycle + +``` +App Launch → Login (RSA challenge-response) → TCP/TLS connected + → Background listener loop reads messages continuously + → Notifications broadcast via AsyncStream to all subscribers + → On disconnect: exponential backoff reconnect (1s → 30s, 5 attempts) + → On auth failure: immediate logout (keys rotated) + → On foreground: check connection health, reconnect if stale (>30s) +``` + +--- + +## Encryption (Signal Protocol) + +### Key Types + +| Key | Algorithm | Size | Purpose | +|-----|-----------|------|---------| +| RSA | RSA-2048 | 256B | Login authentication (challenge-response) | +| Identity Key (IK) | Ed25519 | 32B | Long-term identity, signs SPK | +| Signed Pre-Key (SPK) | X25519 | 32B | Medium-term, rotated every 7 days | +| One-Time Pre-Keys (OPKs) | X25519 | 32B each | Single-use, batch of 50, replenish at 20 | +| Ratchet Keys | X25519 | 32B | Ephemeral per DH ratchet step | +| Sender Keys | Random | 32B | Per-group, per-sender chain key | + +### DM Encryption (X3DH + Double Ratchet) + +1. **Session Init (X3DH):** + - Alice computes: DH(IK_A, SPK_B) || DH(EK_A, IK_B) || DH(EK_A, SPK_B) || DH(EK_A, OPK_B) + - HKDF-SHA256 derives 32-byte shared secret + - Double Ratchet initialized + +2. **Message Encryption (Double Ratchet):** + - Root key → chain key → message key (HKDF chain) + - DH ratchet step on each direction change + - AES-256-GCM with derived message key + - AAD: ratchet header (dh_pub, n, pn) + - Max skip: 256 messages + +3. **Message Format:** + ``` + plaintext → MessagePadding.pad() → AES-256-GCM encrypt → base64 → JSON + ``` + +### Group Encryption (Sender Keys) + +1. Each member maintains own sender key chain +2. Sender key distributed to all members via pairwise Double Ratchet DMs +3. Messages encrypted with AES-256-GCM using derived chain key +4. Chain ID = SHA-256(sender_key) for verification +5. Max skip: 256 messages per chain + +### Message Padding + +Bucket sizes: `64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536` bytes + +``` +Format: 0x01 | plaintext | random_padding | pad_length (4B big-endian) +``` + +All messages padded to nearest bucket size — prevents metadata analysis of message lengths. + +### Self-Encryption + +- Derived from identity private key via HKDF +- Encrypts own message copies for multi-device access +- Static key — same across all user's devices + +### Contact Verification (TOFU) + +- **Fingerprint:** Iterated SHA-512 (5200 rounds) over identity key +- **Safety Number:** 60 digits (12 groups of 5), deterministic ordering by userId +- **QR Code:** Binary `version(1B) + uid_len(1B) + uid + identity_key(32B)` +- **TOFU Registry:** Track first-seen identity keys, alert on change +- **Verification Status:** unverified → trusted (TOFU) → verified (manual/QR) + +--- + +## Features + +### Authentication & Accounts + +- [x] **Registration** — email + password + verification code +- [x] **Login** — RSA challenge-response authentication +- [x] **Biometric Login** — Face ID / Touch ID via Keychain +- [x] **PoW Challenge** — SHA-256 proof-of-work during registration surge +- [x] **Brute-Force Lockout** — exponential backoff (2^n seconds, max 300s) +- [x] **Change Username** — update display name +- [x] **Change Password** — re-encrypt RSA + Ed25519 keys with new PBKDF2 password +- [x] **Key Rotation** — regenerate all keys with grace period for in-flight sessions +- [x] **Logout** — clean disconnect, clear session + +### Multi-Device + +- [x] **Device Pairing** — authorize new device via pairing flow +- [x] **Device List** — view all authorized devices +- [x] **Device Removal** — revoke device authorization +- [x] **Self-Encryption** — own messages readable on all devices + +### Messaging + +- [x] **Text Messages** — encrypted DM and group messages +- [x] **Message Replies** — reply-to with visual indicator +- [x] **Reactions** — 6 emoji reactions (👍❤️😂😮😢👎) with toggle +- [x] **Message Pinning** — pin/unpin with pinned messages sheet +- [x] **Message Deletion** — soft delete with "Message deleted" indicator +- [x] **Message Forwarding** — forward to any conversation with source attribution +- [x] **Message Search** — full-text search with result navigation (prev/next) +- [x] **Read Receipts** — track who read each message +- [x] **Delivery Receipts** — sent → delivered → read indicators (checkmarks) +- [x] **Incremental Sync** — fetch only new messages via `after_ts` +- [x] **Deleted Sync** — `get_deleted_since` for incremental deletion sync +- [x] **Message Padding** — metadata privacy via bucket-based padding + +### Media & Files + +- [x] **Image Upload** — encrypt + chunked upload (24KB chunks) +- [x] **Image Thumbnails** — base64 JPEG preview inline +- [x] **Image Viewer** — full-screen with pinch zoom +- [x] **File Upload** — any file type with mime detection +- [x] **File Download** — decrypt + share via system share sheet +- [x] **File Icons** — type-based system icons (PDF, DOC, ZIP, etc.) + +### Conversations + +- [x] **Direct Messages** — 1-on-1 encrypted chat +- [x] **Group Conversations** — multi-member with Sender Keys +- [x] **Create Conversation** — new DM or group +- [x] **Rename Conversation** — group rename (creator only) +- [x] **Delete Conversation** — remove DM or delete group +- [x] **Favorites** — pin conversations to top with star icon +- [x] **Unread Counts** — per-conversation badge +- [x] **Online Status** — real-time presence (green dot) + +### Group Management + +- [x] **Add Member** — by email +- [x] **Remove Member** — creator only +- [x] **Leave Group** — with confirmation +- [x] **Group Avatar** — upload/change group photo +- [x] **Group Rename** — change group name +- [x] **Group Invitations** — accept/decline with banner UI + +### Profile + +- [x] **User Profile** — username, email, phone, location +- [x] **Avatar** — upload/change profile photo +- [x] **Field Visibility** — toggle phone/location visibility +- [x] **View Other Profiles** — see other users' info (respects visibility) + +### Contact Verification + +- [x] **Safety Numbers** — 60-digit verification code per contact pair +- [x] **Fingerprints** — identity key fingerprints +- [x] **QR Code Generation** — generate scannable verification QR +- [x] **QR Code Scanning** — camera-based QR scan for verification +- [x] **Verification Status** — verified (green) / trusted (blue) / unverified (gray) +- [x] **TOFU Registry** — track identity key first-seen, detect changes +- [x] **Shield Icon** — verification badge in chat toolbar + +### Connection & Reliability + +- [x] **TCP/TLS** — Network.framework with optional TLS +- [x] **Configurable Server** — host, port, TLS toggle in login screen +- [x] **Connection Indicator** — visual status (disconnected/connecting/connected) +- [x] **Auto-Reconnect** — exponential backoff (1s → 30s, 5 attempts) +- [x] **Background/Foreground Handling** — reconnect when returning from background +- [x] **Auth Failure Detection** — immediate logout on key rotation + +### Caching & Storage + +- [x] **Message Cache** — encrypted per-conversation cache on disk +- [x] **Avatar Cache** — disk + in-memory cache +- [x] **Conversation Cache** — cached list for instant UI +- [x] **Session Persistence** — Double Ratchet states saved encrypted +- [x] **Sender Key Persistence** — group key chains saved encrypted +- [x] **Device Bundle Cache** — 5-minute TTL in-memory +- [x] **Keychain Storage** — biometric-protected credentials + +--- + +## Network Protocol + +### Transport + +``` +TCP → optional TLS → Newline-delimited JSON (\n terminated) +``` + +### Message Format + +```json +{"type": "send_message", "request_id": "uuid", "conversation_id": "...", "ciphertext": "base64..."} +``` + +### API Methods (36 endpoints) + +**Auth:** `register`, `register_confirm`, `login_start`, `login_finish`, `change_username`, `change_password` + +**Keys:** `get_key_bundle`, `ensure_prekeys`, `get_prekey_count`, `rotate_keys`, `reset_session` + +**Messaging:** `send_message`, `get_messages`, `delete_message`, `mark_read`, `mark_conversation_read`, `react_message`, `pin_message`, `get_pinned_messages`, `get_deleted_since`, `forward_message`, `confirm_delivery`, `search_messages` + +**Conversations:** `list_conversations`, `create_conversation`, `find_conversation`, `delete_conversation`, `rename_conversation`, `add_member`, `remove_member`, `leave_group`, `accept_invitation`, `decline_invitation`, `list_invitations` + +**Profiles:** `get_profile`, `update_profile`, `update_avatar`, `get_avatar`, `update_group_avatar`, `get_group_avatar` + +**Files:** `upload_file`, `download_file` + +**Devices:** `list_devices`, `remove_device`, `pairing_start`, `pairing_wait`, `authorize_device` + +### Notification Types (17 real-time events) + +``` +new_message, messages_read, message_deleted, message_reacted, +message_pinned, message_unpinned, message_delivered, +conversation_created, conversation_renamed, conversation_deleted, +member_added, member_removed, group_invitation, +user_online, user_offline, online_users, +session_reset, keys_updated +``` + +--- + +## Storage Layout + +``` +~/Library/Application Support/EncryptedChat/{email}/ +├── private.pem # RSA private key (password-protected) +├── public.pem # RSA public key +├── identity_private.bin # Ed25519 private (ECP1: PBKDF2 + AES-GCM) +├── identity_public.bin # Ed25519 public +├── spk_private.bin # Current signed pre-key (X25519) +├── spk_id.txt # SPK ID +├── prevspk_private.bin # Previous SPK (grace period) +├── prevspk_id.txt +├── opk_{id}.bin # One-time pre-keys +├── sessions/ +│ └── {userId}_{deviceId}.bin # Double Ratchet state (encrypted) +├── sender_keys/ +│ └── {convId}_{senderId}_{deviceId}.bin # Sender Key chain (encrypted) +├── message_cache/ +│ └── {convId}.json # Message cache (encrypted) +├── conversations_cache.json # Conversation list cache (encrypted) +├── avatars/ +│ └── {convId}.bin # Avatar image data (encrypted) +├── known_identity_keys.bin # TOFU registry (encrypted) +├── verified_contacts.bin # Verified contacts (encrypted) +└── favorites.bin # Favorite conversation IDs (encrypted) +``` + +--- + +## Build + +```bash +# Xcode build +open /Users/filip/Desktop/kecalek_ios/Kecalek/Kecalek.xcodeproj + +# Command-line build +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ +xcodebuild -scheme Kecalek \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + build +``` + +**Result:** 57 files, 0 errors, 0 warnings. diff --git a/ios_client 0.8.5/Kecalek.xcodeproj/project.pbxproj b/ios_client 0.8.5/Kecalek.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1943678 --- /dev/null +++ b/ios_client 0.8.5/Kecalek.xcodeproj/project.pbxproj @@ -0,0 +1,351 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + DC2D11D32F3CE6FD009F93FA /* Kecalek.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kecalek.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + DC2D11D52F3CE6FD009F93FA /* Kecalek */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Kecalek; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + DC2D11D02F3CE6FD009F93FA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + DC2D11CA2F3CE6FD009F93FA = { + isa = PBXGroup; + children = ( + DC2D11D52F3CE6FD009F93FA /* Kecalek */, + DC2D11D42F3CE6FD009F93FA /* Products */, + ); + sourceTree = ""; + }; + DC2D11D42F3CE6FD009F93FA /* Products */ = { + isa = PBXGroup; + children = ( + DC2D11D32F3CE6FD009F93FA /* Kecalek.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + DC2D11D22F3CE6FD009F93FA /* Kecalek */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC2D11DE2F3CE6FF009F93FA /* Build configuration list for PBXNativeTarget "Kecalek" */; + buildPhases = ( + DC2D11CF2F3CE6FD009F93FA /* Sources */, + DC2D11D02F3CE6FD009F93FA /* Frameworks */, + DC2D11D12F3CE6FD009F93FA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DC2D11D52F3CE6FD009F93FA /* Kecalek */, + ); + name = Kecalek; + packageProductDependencies = ( + ); + productName = Kecalek; + productReference = DC2D11D32F3CE6FD009F93FA /* Kecalek.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DC2D11CB2F3CE6FD009F93FA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2620; + TargetAttributes = { + DC2D11D22F3CE6FD009F93FA = { + CreatedOnToolsVersion = 26.0.1; + }; + }; + }; + buildConfigurationList = DC2D11CE2F3CE6FD009F93FA /* Build configuration list for PBXProject "Kecalek" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DC2D11CA2F3CE6FD009F93FA; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = DC2D11D42F3CE6FD009F93FA /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DC2D11D22F3CE6FD009F93FA /* Kecalek */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + DC2D11D12F3CE6FD009F93FA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + DC2D11CF2F3CE6FD009F93FA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + DC2D11DC2F3CE6FF009F93FA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = J26GZ5AW57; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DC2D11DD2F3CE6FF009F93FA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = J26GZ5AW57; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DC2D11DF2F3CE6FF009F93FA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = J26GZ5AW57; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Kecalek; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR codes for contact verification"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Sign in with Face ID"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos in chat"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Kecalek.lockmseg.com2; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC2D11E02F3CE6FF009F93FA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = J26GZ5AW57; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Kecalek; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR codes for contact verification"; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Sign in with Face ID"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos in chat"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Kecalek.lockmseg.com2; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + DC2D11CE2F3CE6FD009F93FA /* Build configuration list for PBXProject "Kecalek" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC2D11DC2F3CE6FF009F93FA /* Debug */, + DC2D11DD2F3CE6FF009F93FA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC2D11DE2F3CE6FF009F93FA /* Build configuration list for PBXNativeTarget "Kecalek" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC2D11DF2F3CE6FF009F93FA /* Debug */, + DC2D11E02F3CE6FF009F93FA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = DC2D11CB2F3CE6FD009F93FA /* Project object */; +} diff --git a/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcuserdata/filip.xcuserdatad/UserInterfaceState.xcuserstate b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcuserdata/filip.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..ab8d679 Binary files /dev/null and b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcuserdata/filip.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcuserdata/filip.xcuserdatad/WorkspaceSettings.xcsettings b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcuserdata/filip.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..723a561 --- /dev/null +++ b/ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/xcuserdata/filip.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/ios_client 0.8.5/Kecalek.xcodeproj/xcuserdata/filip.xcuserdatad/xcschemes/xcschememanagement.plist b/ios_client 0.8.5/Kecalek.xcodeproj/xcuserdata/filip.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..8619ae5 --- /dev/null +++ b/ios_client 0.8.5/Kecalek.xcodeproj/xcuserdata/filip.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Kecalek.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/ios_client 0.8.5/Kecalek/.DS_Store b/ios_client 0.8.5/Kecalek/.DS_Store new file mode 100644 index 0000000..142e921 Binary files /dev/null and b/ios_client 0.8.5/Kecalek/.DS_Store differ diff --git a/ios_client 0.8.5/Kecalek/AppState.swift b/ios_client 0.8.5/Kecalek/AppState.swift new file mode 100644 index 0000000..374a838 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/AppState.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftUI + +enum ConnectionStatus: Equatable { + case disconnected + case connecting + case connected + case reconnecting +} + +@Observable +final class AppState { + var isLoggedIn = false + var currentUser: User? + var connectionStatus: ConnectionStatus = .disconnected + var email: String = "" + + let chatClient = ChatClient() + + private var reconnectTask: Task? + private var notificationTask: Task? + private var isReconnecting = false + private var backgroundedAt: Date? + + /// Start listening for connection state changes (call after login) + func startConnectionMonitor() { + notificationTask?.cancel() + notificationTask = Task { [weak self] in + guard let self else { return } + let stream = await chatClient.makeNotificationStream() + for await notification in stream { + guard !Task.isCancelled else { break } + if case .connectionStateChanged(let connected) = notification { + await MainActor.run { + if connected { + self.connectionStatus = .connected + self.isReconnecting = false + self.reconnectTask?.cancel() + self.reconnectTask = nil + } else if self.isLoggedIn, !self.isReconnecting { + // Only start reconnect if not already reconnecting + // (reconnect() internally calls disconnect() which fires this) + self.connectionStatus = .disconnected + self.attemptReconnect() + } + } + } + } + } + } + + /// Attempt reconnect with exponential backoff; immediate logout on auth failure + @MainActor + private func attemptReconnect() { + reconnectTask?.cancel() + isReconnecting = true + reconnectTask = Task { [weak self] in + guard let self else { return } + let maxAttempts = 5 + var delay: TimeInterval = Constants.reconnectBaseDelay + + for attempt in 1...maxAttempts { + guard !Task.isCancelled, self.isLoggedIn else { return } + + self.connectionStatus = .reconnecting + #if DEBUG + print("DEBUG AppState: reconnect attempt \(attempt)/\(maxAttempts), delay=\(delay)s") + #endif + + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled, self.isLoggedIn else { return } + + let result = await self.chatClient.reconnect() + switch result { + case .success: + self.connectionStatus = .connected + self.isReconnecting = false + #if DEBUG + print("DEBUG AppState: reconnected on attempt \(attempt)") + #endif + return + case .authFailed: + // Keys rotated or invalid — logout immediately, don't retry + self.isReconnecting = false + #if DEBUG + print("DEBUG AppState: auth failed (keys likely rotated), logging out immediately") + #endif + await self.logout() + return + case .networkError: + // Network issue — retry with backoff + delay = min(delay * 2, Constants.reconnectMaxDelay) + } + } + + // All network retries exhausted → force logout + self.isReconnecting = false + guard !Task.isCancelled, self.isLoggedIn else { return } + #if DEBUG + print("DEBUG AppState: reconnect failed after \(maxAttempts) attempts, logging out") + #endif + await self.logout() + } + } + + // MARK: - App Lifecycle + + func handleEnteredBackground() { + backgroundedAt = Date() + } + + @MainActor + func handleBecameActive() { + guard isLoggedIn, !isReconnecting else { return } + let wasInBackground = backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0 + backgroundedAt = nil + + Task { + let alive = await chatClient.isConnectionAlive() + if !alive { + #if DEBUG + print("DEBUG AppState: foreground — connection dead, reconnecting") + #endif + await MainActor.run { + guard !self.isReconnecting else { return } + self.connectionStatus = .reconnecting + self.attemptReconnect() + } + } else if wasInBackground > 30 { + // Connection appears alive but was backgrounded a long time — + // force reconnect to ensure fresh state + #if DEBUG + print("DEBUG AppState: foreground — stale connection (\(Int(wasInBackground))s), reconnecting") + #endif + await MainActor.run { + guard !self.isReconnecting else { return } + self.connectionStatus = .reconnecting + self.attemptReconnect() + } + } else { + #if DEBUG + print("DEBUG AppState: foreground — connection alive (\(Int(wasInBackground))s in bg)") + #endif + } + } + } + + func logout() async { + isReconnecting = false + reconnectTask?.cancel() + reconnectTask = nil + notificationTask?.cancel() + notificationTask = nil + await chatClient.disconnect() + KeychainService.deleteCredentials() + isLoggedIn = false + currentUser = nil + connectionStatus = .disconnected + email = "" + } +} diff --git a/ios_client 0.8.5/Kecalek/Assets.xcassets/AccentColor.colorset/Contents.json b/ios_client 0.8.5/Kecalek/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_client 0.8.5/Kecalek/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios_client 0.8.5/Kecalek/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..bcf9dd0 Binary files /dev/null and b/ios_client 0.8.5/Kecalek/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/ios_client 0.8.5/Kecalek/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios_client 0.8.5/Kecalek/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..87d4015 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json b/ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_client 0.8.5/Kecalek/Core/ChatClient.swift b/ios_client 0.8.5/Kecalek/Core/ChatClient.swift new file mode 100644 index 0000000..8a0c4c6 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Core/ChatClient.swift @@ -0,0 +1,3666 @@ +import Foundation +import CryptoKit +import UIKit + +/// Notification types from the server +enum ChatNotification { + case newMessage(data: [String: Any]) + case messagesRead(data: [String: Any]) + case messageDeleted(data: [String: Any]) + case conversationCreated(data: [String: Any]) + case memberAdded(data: [String: Any]) + case memberRemoved(data: [String: Any]) + case userOnline(userId: String) + case userOffline(userId: String) + case onlineUsers(userIds: [String]) + case groupInvitation(data: [String: Any]) + case conversationRenamed(data: [String: Any]) + case sessionReset(data: [String: Any]) + case messageReacted(data: [String: Any]) + case messagePinned(data: [String: Any]) + case messageUnpinned(data: [String: Any]) + case messageDelivered(data: [String: Any]) + case conversationDeleted(data: [String: Any]) + case connectionStateChanged(connected: Bool) + case reconnected +} + +/// Result of a reconnect attempt +enum ReconnectResult { + case success + case authFailed // Keys invalid (e.g. rotated) — should logout immediately + case networkError // TCP/connection issue — can retry +} + +/// Main chat client — handles all server communication and crypto operations. +/// Thread-safe via Swift actor isolation. +/// Port of Python ChatClient class from chat_core.py +actor ChatClient { + + // MARK: - Connection + + let connectionManager = ConnectionManager() + private(set) var isConnected = false + private var lastHost = Constants.defaultHost + private var lastPort = Constants.defaultPort + private(set) var sessionToken: String? + private(set) var userId: String? + private(set) var username: String = "" + private(set) var email: String = "" + private(set) var loginRejected = false + private(set) var onlineUserIds: Set = [] + + // MARK: - Keys + + private var rsaPrivate: SecKey? + private var rsaPublic: SecKey? + private(set) var identityPrivate: Curve25519.Signing.PrivateKey? + private(set) var identityPublic: Curve25519.Signing.PublicKey? + private var spkPrivate: Curve25519.KeyAgreement.PrivateKey? + private var spkId: String = "" + private var prevSpkPrivate: Curve25519.KeyAgreement.PrivateKey? + private var prevSpkId: String = "" + private var opkPrivates: [String: Curve25519.KeyAgreement.PrivateKey] = [:] + + // MARK: - Sessions & Sender Keys + + private var sessions: [String: DoubleRatchet] = [:] // "userId:deviceId" -> ratchet + private var senderKeyStates: [String: SenderKeyState] = [:] // convId -> own sender key + private var recvSenderKeys: [String: SenderKeyState] = [:] // "convId:senderId:deviceId" -> their key + + // MARK: - Derived Keys + + private(set) var cacheKey: Data? // for encrypting message cache + private(set) var localKey: Data? // for encrypting session/sender key files + + // MARK: - Multi-Device + + private(set) var deviceId: String? + + // MARK: - Caches + + private var userCache: [String: User] = [:] + private var deviceBundleCache: [String: (timestamp: Date, bundles: [DeviceBundle])] = [:] + + // MARK: - Self-Encrypt Queue + + private var pendingSelfEncrypt: [(messageId: String, plaintext: Data)] = [] + + // MARK: - TOFU / Contact Verification + + private var knownIdentityKeys: [String: [String: String]] = [:] // userId -> {identity_key, first_seen, last_seen} + private var verifiedContacts: [String: [String: String]] = [:] // userId -> {identity_key, verified_at, method} + + // MARK: - Brute-Force Lockout + + private static let lockoutBaseSeconds: TimeInterval = 2 + private static let lockoutMaxSeconds: TimeInterval = 300 + + // MARK: - Request/Response Tracking + + private var pendingRequests: [String: CheckedContinuation<[String: Any], Error>] = [:] + private var listenerTask: Task? + + // MARK: - Notification Broadcast (supports multiple consumers) + + private var notificationSubscribers: [UUID: AsyncStream.Continuation] = [:] + + /// Create a new notification stream. Each subscriber gets ALL notifications. + func makeNotificationStream() -> AsyncStream { + let id = UUID() + var captured: AsyncStream.Continuation! + let stream = AsyncStream { cont in + captured = cont + } + captured.onTermination = { [weak self] _ in + Task { await self?.removeSubscriber(id: id) } + } + notificationSubscribers[id] = captured + return stream + } + + private func removeSubscriber(id: UUID) { + notificationSubscribers.removeValue(forKey: id) + } + + private func broadcastNotification(_ notification: ChatNotification) { + for (_, continuation) in notificationSubscribers { + continuation.yield(notification) + } + } + + // MARK: - Init + + init() {} + + // MARK: - Connection + + func connect(host: String = Constants.defaultHost, port: UInt16 = Constants.defaultPort) async throws { + try await connectionManager.connect(host: host, port: port) + lastHost = host + lastPort = port + isConnected = true + startBackgroundListener() + broadcastNotification(.connectionStateChanged(connected: true)) + } + + /// Check if the underlying TCP connection is actually alive + func isConnectionAlive() async -> Bool { + guard isConnected else { return false } + return await connectionManager.isConnected + } + + func disconnect() async { + listenerTask?.cancel() + listenerTask = nil + await connectionManager.disconnect() + isConnected = false + // Fail all pending requests + let pending = pendingRequests + pendingRequests.removeAll() + for (_, cont) in pending { + cont.resume(throwing: NetworkError.notConnected) + } + broadcastNotification(.connectionStateChanged(connected: false)) + } + + // MARK: - Send and Receive + + /// Send a request and wait for the matching response. + func sendAndReceive(type: String, timeout: TimeInterval = 30, params: [String: Any] = [:]) async -> [String: Any] { + let requestId = ProtocolHandler.newRequestId() + #if DEBUG + print("DEBUG sendAndReceive: type=\(type), requestId=\(requestId)") + #endif + + // Timeout task — resumes continuation with error if server doesn't respond + let timeoutTask = Task { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if let cont = pendingRequests.removeValue(forKey: requestId) { + cont.resume(throwing: NetworkError.timeout) + } + } + + do { + let response: [String: Any] = try await withCheckedThrowingContinuation { continuation in + pendingRequests[requestId] = continuation + + Task { + do { + try await connectionManager.sendMessage(type: type, requestId: requestId, params: params) + } catch { + if let cont = pendingRequests.removeValue(forKey: requestId) { + cont.resume(throwing: error) + } + } + } + } + timeoutTask.cancel() + return response + } catch { + timeoutTask.cancel() + pendingRequests.removeValue(forKey: requestId) + return [ + "type": type, + "status": "error", + "data": ["message": error.localizedDescription] + ] + } + } + + // MARK: - Background Listener + + func startBackgroundListener() { + listenerTask?.cancel() + listenerTask = Task { [weak self] in + guard let self = self else { return } + await self.backgroundListenerLoop() + } + } + + private func backgroundListenerLoop() async { + while !Task.isCancelled { + do { + guard let msg = try await connectionManager.readMessage() else { + // EOF — connection closed + handleDisconnect() + break + } + routeMessage(msg) + } catch { + handleDisconnect() + break + } + } + } + + private func handleDisconnect() { + isConnected = false + // Fail all pending futures + let pending = pendingRequests + pendingRequests.removeAll() + for (_, cont) in pending { + cont.resume(throwing: NetworkError.notConnected) + } + broadcastNotification(.connectionStateChanged(connected: false)) + } + + private func routeMessage(_ msg: [String: Any]) { + #if DEBUG + print("DEBUG routeMessage received: \(msg)") + #endif + let msgType = msg["type"] as? String ?? "" + + // Notification types (no request_id expected from client) + let notificationTypes = Set([ + "new_message", "messages_read", "message_deleted", + "conversation_created", "member_added", "member_removed", + "user_online", "user_offline", "online_users", + "group_invitation", "conversation_renamed", "session_reset", + "keys_updated", "message_reacted", "message_pinned", "message_unpinned", + "message_delivered", "conversation_deleted" + ]) + + if notificationTypes.contains(msgType) { + let data = msg["data"] as? [String: Any] ?? msg + switch msgType { + case "new_message": + broadcastNotification(.newMessage(data: data)) + case "messages_read": + broadcastNotification(.messagesRead(data: data)) + case "message_deleted": + broadcastNotification(.messageDeleted(data: data)) + case "conversation_created": + broadcastNotification(.conversationCreated(data: data)) + case "member_added": + broadcastNotification(.memberAdded(data: data)) + case "member_removed": + broadcastNotification(.memberRemoved(data: data)) + case "user_online": + if let uid = data["user_id"] as? String { + onlineUserIds.insert(uid) + broadcastNotification(.userOnline(userId: uid)) + } + case "user_offline": + if let uid = data["user_id"] as? String { + onlineUserIds.remove(uid) + broadcastNotification(.userOffline(userId: uid)) + } + case "online_users": + if let uids = data["user_ids"] as? [String] { + onlineUserIds = Set(uids) + broadcastNotification(.onlineUsers(userIds: uids)) + } + case "group_invitation": + broadcastNotification(.groupInvitation(data: data)) + case "conversation_renamed": + broadcastNotification(.conversationRenamed(data: data)) + case "session_reset": + broadcastNotification(.sessionReset(data: data)) + case "message_reacted": + broadcastNotification(.messageReacted(data: data)) + case "message_pinned": + broadcastNotification(.messagePinned(data: data)) + case "message_unpinned": + broadcastNotification(.messageUnpinned(data: data)) + case "message_delivered": + broadcastNotification(.messageDelivered(data: data)) + case "conversation_deleted": + broadcastNotification(.conversationDeleted(data: data)) + case "keys_updated": + // Peer uploaded new prekeys (new device or rotation) — invalidate bundle cache + if let uid = data["user_id"] as? String { + deviceBundleCache.removeValue(forKey: uid) + #if DEBUG + print("DEBUG keys_updated: invalidated bundle cache for \(uid)") + #endif + } + default: + break + } + } else { + // Response to a pending request + if let requestId = msg["request_id"] as? String, + let cont = pendingRequests.removeValue(forKey: requestId) { + cont.resume(returning: msg) + } + } + } + + // MARK: - User Info Cache + + func getUserInfo(userId: String = "", userEmail: String = "") async -> User? { + if !userId.isEmpty, let cached = userCache[userId] { + return cached + } + var params: [String: Any] = [:] + if !userId.isEmpty { params["user_id"] = userId } + else if !userEmail.isEmpty { params["email"] = userEmail } + else { return nil } + + let resp = await sendAndReceive(type: "get_user_info", params: params) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { return nil } + + var ikData: Data? + if let ikB64 = data["identity_key"] as? String { + ikData = try? ProtocolHandler.decodeBinary(ikB64) + } + + let user = User( + id: data.string(for: "user_id") ?? "", + username: data.string(for: "username") ?? "", + email: data.string(for: "email") ?? "", + identityKey: ikData + ) + userCache[user.id] = user + return user + } + + // MARK: - Registration + + func register(username: String, password: String, email: String) async -> (success: Bool, message: String) { + self.username = username + self.email = email + var pwdBytes = Array(password.utf8) + defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } + + let pwdData = Data(pwdBytes) + + do { + // RSA keys + let (rsaPriv, rsaPub, _) = KeyStorage.loadRSAKeys(email: email, password: pwdData) + if let rsaPriv = rsaPriv, let rsaPub = rsaPub { + self.rsaPrivate = rsaPriv + self.rsaPublic = rsaPub + } else { + let (newPriv, newPub) = try RSACrypto.generateKeypair() + try KeyStorage.saveRSAKeys(email: email, privateKey: newPriv, publicKey: newPub, password: pwdData) + self.rsaPrivate = newPriv + self.rsaPublic = newPub + } + + // Ed25519 identity keys + let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: pwdData) + if let edPriv = edPriv, let edPub = edPub { + self.identityPrivate = edPriv + self.identityPublic = edPub + } else { + let (newPriv, newPub) = Ed25519Crypto.generateKeypair() + try KeyStorage.saveIdentityKeys(email: email, privateKey: newPriv, publicKey: newPub, password: pwdData) + self.identityPrivate = newPriv + self.identityPublic = newPub + } + + self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate!.rawData) + self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: identityPrivate!.rawData) + } catch { + return (false, "Key generation failed: \(error.localizedDescription)") + } + + // Send registration request + let pubPem = String(data: try! RSACrypto.serializePublicKey(rsaPublic!), encoding: .utf8)! + let ikB64 = ProtocolHandler.encodeBinary(Ed25519Crypto.serializePublic(identityPublic!)) + + let resp = await sendAndReceive(type: "register", params: [ + "username": username, + "public_key": pubPem, + "email": email, + "identity_key": ikB64, + ]) + + #if DEBUG + print("DEBUG register response: \(resp)") + #endif + + // Handle PoW challenge if required by server + if resp.string(for: "status") == "pow_required", + let powData = resp.dict(for: "data"), + let powChallenge = powData.string(for: "challenge"), + let powMac = powData.string(for: "mac"), + let powDifficulty = powData.int(for: "difficulty") { + #if DEBUG + print("DEBUG register: PoW required, difficulty=\(powDifficulty)") + #endif + let powNonce = ChatClient.solvePow(challenge: powChallenge, difficulty: powDifficulty) + #if DEBUG + print("DEBUG register: PoW solved, nonce=\(powNonce)") + #endif + + // Retry with PoW solution + let retryResp = await sendAndReceive(type: "register", params: [ + "username": username, + "public_key": pubPem, + "email": email, + "identity_key": ikB64, + "pow_challenge": powChallenge, + "pow_mac": powMac, + "pow_nonce": powNonce, + ]) + + guard retryResp.string(for: "status") == "ok" else { + let msg = retryResp.dict(for: "data")?.string(for: "message") ?? "Registration failed after PoW" + return (false, msg) + } + let retryData = retryResp.dict(for: "data") ?? [:] + if let code = retryData.string(for: "code") { + return (true, code) + } + return (true, retryData.string(for: "message") ?? "Check your email for the code.") + } + + guard resp.string(for: "status") == "ok" else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Registration failed" + return (false, msg) + } + + let data = resp.dict(for: "data") ?? [:] + if let code = data.string(for: "code") { + return (true, code) + } + return (true, data.string(for: "message") ?? "Check your email for the code.") + } + + func confirmRegistration(email: String, username: String, code: String) async -> (success: Bool, message: String) { + #if DEBUG + print("DEBUG confirmRegistration: email=\(email), code=\(code)") + #endif + let resp = await sendAndReceive(type: "register_confirm", params: [ + "email": email, + "code": code, + ]) + + guard resp.string(for: "status") == "ok" else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Confirmation failed" + return (false, msg) + } + + // NOTE: Don't upload prekeys here - user isn't logged in yet. + // ensurePrekeys() will be called after login. + + let uid = resp.dict(for: "data")?.string(for: "user_id") ?? "" + return (true, "Registered as '\(username)' (ID: \(uid))") + } + + // MARK: - Prekeys + + private func generateAndUploadPrekeys(keepSPK: Bool = false) async { + guard let identityPrivate = identityPrivate else { return } + + do { + let spkData: [String: Any] + + if keepSPK, let spkPriv = spkPrivate, !spkId.isEmpty { + let spkPubBytes = X25519Crypto.serializePublic(spkPriv.publicKey) + let sig = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes) + spkData = [ + "id": spkId, + "public_key": ProtocolHandler.encodeBinary(spkPubBytes), + "signature": ProtocolHandler.encodeBinary(sig), + ] + } else { + // Save current as previous (grace period) + if let spkPriv = spkPrivate, !spkId.isEmpty { + prevSpkPrivate = spkPriv + prevSpkId = spkId + try? KeyStorage.savePrevSPK(email: email, privateKey: spkPriv, spkId: spkId) + } + + let spk = try X3DH.generateSignedPrekey(identityPrivate: identityPrivate) + self.spkPrivate = spk.privateKey + self.spkId = spk.id + try? KeyStorage.saveSPK(email: email, privateKey: spk.privateKey, spkId: spk.id) + + spkData = [ + "id": spk.id, + "public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(spk.publicKey)), + "signature": ProtocolHandler.encodeBinary(spk.signature), + ] + } + + // Generate OPKs + let opks = X3DH.generateOneTimePrekeys(count: Constants.opkBatchSize) + #if DEBUG + print("DEBUG generatePrekeys: generating \(opks.count) OPKs") + #endif + for opk in opks { + opkPrivates[opk.id] = opk.privateKey + do { + try KeyStorage.saveOPKPrivate(email: email, opkId: opk.id, privateKey: opk.privateKey) + } catch { + #if DEBUG + print("DEBUG generatePrekeys: FAILED to save OPK \(opk.id): \(error)") + #endif + } + } + #if DEBUG + print("DEBUG generatePrekeys: OPK IDs = \(opks.map { $0.id })") + #endif + + let otpData = opks.map { opk -> [String: Any] in + [ + "id": opk.id, + "public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(opk.publicKey)), + ] + } + + let uploadResp = await sendAndReceive(type: "ensure_prekeys", params: [ + "signed_prekey": spkData, + "one_time_prekeys": otpData, + ]) + let uploadStatus = uploadResp.string(for: "status") ?? "nil" + #if DEBUG + print("DEBUG generatePrekeys: ensure_prekeys response status=\(uploadStatus) deviceId=\(deviceId ?? "nil")") + #endif + if uploadStatus != "ok" { + let errMsg = uploadResp.dict(for: "data")?.string(for: "message") ?? "unknown" + #if DEBUG + print("DEBUG generatePrekeys: upload FAILED: \(errMsg)") + #endif + } + } catch { + // Log error but don't fail + #if DEBUG + print("Prekey generation error: \(error)") + #endif + } + } + + private func ensurePrekeys() async { + let resp = await sendAndReceive(type: "get_prekey_count") + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { return } + + let count = data.int(for: "count") ?? 0 + let spkCreatedAt = data.string(for: "spk_created_at") ?? "" + + var needNewSPK = false + if !spkCreatedAt.isEmpty { + if let created = DateParsing.parse(spkCreatedAt) { + let ageDays = Calendar.current.dateComponents([.day], from: created, to: Date()).day ?? 0 + if ageDays >= Constants.spkRotationDays { + needNewSPK = true + } + } + } + + if count < Constants.opkReplenishThreshold || needNewSPK { + await generateAndUploadPrekeys() + } + } + + // MARK: - Login + + func login(email: String, password: String) async -> (success: Bool, message: String) { + self.email = email + + // Check brute-force lockout + let lockoutRemaining = ChatClient.checkLockout(email: email) + if lockoutRemaining > 0 { + return (false, "Too many failed attempts. Wait \(Int(lockoutRemaining))s.") + } + + var pwdBytes = Array(password.utf8) + defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } + let pwdData = Data(pwdBytes) + + // Load RSA keys + let (rsaPriv, rsaPub, err) = KeyStorage.loadRSAKeys(email: email, password: pwdData) + guard let rsaPriv = rsaPriv, let rsaPub = rsaPub else { + return (false, err ?? "No local keys found. Register first.") + } + self.rsaPrivate = rsaPriv + self.rsaPublic = rsaPub + + // Load identity keys + let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: pwdData) + if let edPriv = edPriv, let edPub = edPub { + self.identityPrivate = edPriv + self.identityPublic = edPub + self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) + self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: edPriv.rawData) + } + + // Load SPK + let (spkP, spkI) = KeyStorage.loadSPK(email: email) + if let spkP = spkP { + self.spkPrivate = spkP + self.spkId = spkI ?? "" + } + + // Load previous SPK (grace period) + let (prevP, prevI) = KeyStorage.loadPrevSPK(email: email) + if let prevP = prevP { + self.prevSpkPrivate = prevP + self.prevSpkId = prevI ?? "" + } + + // Load device ID + self.deviceId = KeyStorage.loadDeviceId(email: email) + + // RSA challenge-response login + let startResp = await sendAndReceive(type: "login_start", params: ["email": email]) + guard startResp.string(for: "status") == "ok", + let startData = startResp.dict(for: "data"), + let challengeB64 = startData.string(for: "challenge") else { + let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Login failed" + return (false, msg) + } + + let challengeData: Data + do { + challengeData = try ProtocolHandler.decodeBinary(challengeB64) + } catch { + return (false, "Invalid challenge data") + } + + let signature: Data + do { + signature = try RSACrypto.sign(rsaPriv, data: challengeData) + } catch { + return (false, "RSA signing failed: \(error.localizedDescription)") + } + + var finishParams: [String: Any] = [ + "email": email, + "signature": ProtocolHandler.encodeBinary(signature), + "client_version": Constants.version, + ] + if let deviceId = deviceId { + finishParams["device_id"] = deviceId + } + + let finishResp = await sendAndReceive(type: "login_finish", params: finishParams) + guard finishResp.string(for: "status") == "ok", + let finishData = finishResp.dict(for: "data") else { + let msg = finishResp.dict(for: "data")?.string(for: "message") ?? "Login failed" + loginRejected = true + ChatClient.recordFailedAttempt(email: email) + return (false, msg) + } + + self.userId = finishData.string(for: "user_id") + self.username = finishData.string(for: "username") ?? "" + self.sessionToken = finishData.string(for: "session_token") + + // Save device ID from server + if let newDeviceId = finishData.string(for: "device_id") { + self.deviceId = newDeviceId + try? KeyStorage.saveDeviceId(email: email, deviceId: newDeviceId) + } + + // Handle online_users if included + if let onlineUserIds = finishData["online_user_ids"] as? [String] { + self.onlineUserIds = Set(onlineUserIds) + broadcastNotification(.onlineUsers(userIds: onlineUserIds)) + } + + // Check if we have local OPK private keys. + // After device pairing, the new device has no local OPKs — must generate fresh ones. + let hasLocalOPKs: Bool + if let dir = try? KeyStorage.getKeyDir(email: email) { + let opkDir = dir.appendingPathComponent("opk_private") + let files = (try? FileManager.default.contentsOfDirectory(atPath: opkDir.path)) ?? [] + hasLocalOPKs = !files.isEmpty + } else { + hasLocalOPKs = false + } + + if hasLocalOPKs { + // Existing device — check/replenish prekeys in background + Task { await ensurePrekeys() } + } else { + // New device — MUST upload prekeys before returning so other clients + // can encrypt for this device immediately. Fire-and-forget would create + // a race where senders fetch bundles before prekeys exist on server. + #if DEBUG + print("DEBUG login: no local OPKs (likely new device). Generating fresh prekeys (synchronous).") + #endif + await generateAndUploadPrekeys(keepSPK: true) + } + + // Load previous SPK for grace period (M4) + let (prevSPK, prevSPKId) = KeyStorage.loadPrevSPK(email: email) + if let prevSPK = prevSPK { + prevSpkPrivate = prevSPK + prevSpkId = prevSPKId ?? "" + } + + // Load TOFU and verification stores + loadVerificationStores() + + // Clear lockout on successful login + ChatClient.clearLockout(email: email) + + return (true, "Logged in as \(username)") + } + + // MARK: - Reconnect + + func reconnect() async -> ReconnectResult { + guard rsaPrivate != nil else { return .authFailed } + + let host = lastHost + let port = lastPort + + await disconnect() + + do { + try await connect(host: host, port: port) + } catch { + return .networkError + } + + // TCP connected — try RSA challenge-response with in-memory keys + let startResp = await sendAndReceive(type: "login_start", params: ["email": email]) + guard startResp.string(for: "status") == "ok", + let startData = startResp.dict(for: "data"), + let challengeB64 = startData.string(for: "challenge"), + let challengeData = try? ProtocolHandler.decodeBinary(challengeB64), + let signature = try? RSACrypto.sign(rsaPrivate!, data: challengeData) else { + // TCP was fine but login_start failed — auth issue + return .authFailed + } + + var finishParams: [String: Any] = [ + "email": email, + "signature": ProtocolHandler.encodeBinary(signature), + "client_version": Constants.version, + ] + if let deviceId = deviceId { + finishParams["device_id"] = deviceId + } + + let finishResp = await sendAndReceive(type: "login_finish", params: finishParams) + guard finishResp.string(for: "status") == "ok", + let finishData = finishResp.dict(for: "data") else { + // TCP connected, got challenge, but signature rejected — keys rotated + return .authFailed + } + + // Update session token if server returned a new one + if let token = finishData.string(for: "session_token") { + self.sessionToken = token + } + + // Refresh online users (matches Python: self.session = finish["data"]) + if let onlineUserIds = finishData["online_user_ids"] as? [String] { + self.onlineUserIds = Set(onlineUserIds) + broadcastNotification(.onlineUsers(userIds: onlineUserIds)) + } + + // Replenish prekeys if needed (matches Python: asyncio.create_task(self._ensure_prekeys())) + Task { await ensurePrekeys() } + + broadcastNotification(.reconnected) + return .success + } + + // MARK: - Device Bundles + + private func getDeviceBundles(userId: String) async throws -> [DeviceBundle] { + // Check cache (5-min TTL) + if let cached = deviceBundleCache[userId], + Date().timeIntervalSince(cached.timestamp) < Constants.deviceBundleCacheTTL { + return cached.bundles + } + + let resp = await sendAndReceive(type: "get_key_bundle", params: ["user_id": userId]) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { + throw ChatError.operationFailed("Failed to get key bundle") + } + + var bundles: [DeviceBundle] = [] + + // Get identity key from top level (shared across all device bundles) + var identityKey: Data? + if let ikB64 = data["identity_key"] as? String { + identityKey = Data(base64Encoded: ikB64) + } + + // Per-device bundles (new format) + if let deviceBundlesRaw = data["device_bundles"] as? [[String: Any]] { + for bundleDict in deviceBundlesRaw { + if let bundle = try? DeviceBundle.fromDict(bundleDict, identityKey: identityKey) { + bundles.append(bundle) + } + } + } + // Legacy single bundle + else if identityKey != nil { + let bundle = try DeviceBundle.fromDict(data, identityKey: identityKey) + bundles.append(bundle) + } + + deviceBundleCache[userId] = (Date(), bundles) + return bundles + } + + // MARK: - Session Management + + private func getOrCreateSession( + peerUserId: String, + peerDeviceId: String, + bundle: DeviceBundle + ) async throws -> DoubleRatchet { + let sessionKey = "\(peerUserId):\(peerDeviceId)" + #if DEBUG + print("DEBUG getOrCreateSession: looking for session \(sessionKey)") + #endif + + // Check memory + if let session = sessions[sessionKey] { + #if DEBUG + print("DEBUG getOrCreateSession: found in memory") + #endif + return session + } + + // Check disk + if let session = KeyStorage.loadSession( + email: email, + peerUserId: peerUserId, + localKey: localKey, + peerDeviceId: peerDeviceId + ) { + #if DEBUG + print("DEBUG getOrCreateSession: found on disk") + #endif + sessions[sessionKey] = session + return session + } + + #if DEBUG + print("DEBUG getOrCreateSession: creating new session via X3DH") + #endif + + // Create new via X3DH + let remoteIkEd = try Ed25519Crypto.loadPublic(bundle.identityKey) + let spkRemote = try X25519Crypto.loadPublic(bundle.spk) + var opkRemote: Curve25519.KeyAgreement.PublicKey? + if let opkData = bundle.opk { + opkRemote = try X25519Crypto.loadPublic(opkData) + } + + let (sharedSecret, _, ekPub) = try X3DH.initiate( + ikPrivateEd: identityPrivate!, + ikPublicRemoteEd: remoteIkEd, + spkRemote: spkRemote, + spkSignature: bundle.spkSignature, + opkRemote: opkRemote + ) + + let ratchet = try DoubleRatchet.initAlice(sharedSecret: sharedSecret, bobSpkPub: spkRemote) + + // Build X3DH header for first message + // Keys must be base64 encoded (not hex) to match Python server + let myIkBytes = Ed25519Crypto.serializePublic(identityPublic!) + let ekPubBytes = X25519Crypto.serializePublic(ekPub) + #if DEBUG + print("DEBUG getOrCreateSession: sending ik len=\(myIkBytes.count)") + print("DEBUG getOrCreateSession: sending ek len=\(ekPubBytes.count)") + #endif + var x3dhHeader: [String: Any] = [ + "ik": ProtocolHandler.encodeBinary(myIkBytes), + "ek": ProtocolHandler.encodeBinary(ekPubBytes), + "spk_id": bundle.spkId, + ] + if let opkId = bundle.opkId { + x3dhHeader["opk_id"] = opkId + #if DEBUG + print("DEBUG getOrCreateSession: using opk_id = \(opkId)") + #endif + } + ratchet.x3dhHeader = x3dhHeader + + sessions[sessionKey] = ratchet + try? KeyStorage.saveSession(email: email, peerUserId: peerUserId, ratchet: ratchet, localKey: localKey, peerDeviceId: peerDeviceId) + + return ratchet + } + + // MARK: - X3DH Response (Bob Side) + + private func processX3DHHeader( + senderId: String, + x3dhHeader: [String: Any], + senderDeviceId: String, + spkOverride: Curve25519.KeyAgreement.PrivateKey? = nil + ) throws -> DoubleRatchet { + guard let ikB64 = x3dhHeader["ik"] as? String, + let ikData = try? ProtocolHandler.decodeBinary(ikB64), + let ekB64 = x3dhHeader["ek"] as? String, + let ekData = try? ProtocolHandler.decodeBinary(ekB64) else { + throw CryptoError.x3dhFailed("Invalid X3DH header - missing ik or ek") + } + + // spk_id is optional - if missing, use current SPK + let spkIdStr = x3dhHeader["spk_id"] as? String + #if DEBUG + print("DEBUG processX3DHHeader: ik=\(ikData.count)B, ek=\(ekData.count)B, spk_id=\(spkIdStr ?? "nil")") + #endif + + let remoteIkEd = try Ed25519Crypto.loadPublic(ikData) + let ekRemote = try X25519Crypto.loadPublic(ekData) + + // Determine which SPK to use + let spkToUse: Curve25519.KeyAgreement.PrivateKey + if let override = spkOverride { + #if DEBUG + print("DEBUG processX3DHHeader: using spkOverride") + #endif + spkToUse = override + } else if let spkIdStr = spkIdStr { + // spk_id provided - match against known SPKs + #if DEBUG + print("DEBUG processX3DHHeader: spk_id provided, mySpkId=\(spkId), prevSpkId=\(prevSpkId)") + #endif + if spkIdStr == spkId, let spk = spkPrivate { + spkToUse = spk + } else if spkIdStr == prevSpkId, let prevSpk = prevSpkPrivate { + spkToUse = prevSpk + } else { + throw CryptoError.x3dhFailed("SPK \(spkIdStr) not found") + } + } else { + // No spk_id provided - use current SPK (for backward compatibility) + #if DEBUG + print("DEBUG processX3DHHeader: no spk_id, using current SPK") + #endif + guard let spk = spkPrivate else { + throw CryptoError.x3dhFailed("No SPK available") + } + spkToUse = spk + } + + // OPK + var opkPriv: Curve25519.KeyAgreement.PrivateKey? + if let opkIdStr = x3dhHeader["opk_id"] as? String { + #if DEBUG + print("DEBUG processX3DHHeader: looking for OPK \(opkIdStr)") + print("DEBUG processX3DHHeader: opkPrivates has \(opkPrivates.count) keys: \(Array(opkPrivates.keys).prefix(5))...") + #endif + + // Check file system + if let dir = try? KeyStorage.getKeyDir(email: email) { + let opkDir = dir.appendingPathComponent("opk_private") + let opkFile = opkDir.appendingPathComponent("\(opkIdStr).bin") + #if DEBUG + print("DEBUG processX3DHHeader: checking file \(opkFile.path)") + print("DEBUG processX3DHHeader: file exists = \(FileManager.default.fileExists(atPath: opkFile.path))") + #endif + if let files = try? FileManager.default.contentsOfDirectory(atPath: opkDir.path) { + #if DEBUG + print("DEBUG processX3DHHeader: opk_private dir has \(files.count) files: \(files.prefix(5))...") + #endif + } + } + + opkPriv = opkPrivates[opkIdStr] ?? KeyStorage.loadOPKPrivate(email: email, opkId: opkIdStr) + if opkPriv != nil { + #if DEBUG + print("DEBUG processX3DHHeader: OPK found") + #endif + opkPrivates.removeValue(forKey: opkIdStr) + KeyStorage.deleteOPKPrivate(email: email, opkId: opkIdStr) + } else { + #if DEBUG + print("DEBUG processX3DHHeader: OPK NOT found - continuing without OPK") + #endif + } + } + + #if DEBUG + print("DEBUG processX3DHHeader: performing X3DH respond") + #endif + let sharedSecret = try X3DH.respond( + ikPrivateEd: identityPrivate!, + spkPrivate: spkToUse, + ikRemoteEd: remoteIkEd, + ekRemote: ekRemote, + opkPrivate: opkPriv + ) + #if DEBUG + print("DEBUG processX3DHHeader: X3DH respond success, sharedSecret=\(sharedSecret.count) bytes") + #endif + + let ratchet = DoubleRatchet.initBob( + sharedSecret: sharedSecret, + spkPair: (spkToUse, spkToUse.publicKey) + ) + + let sessionKey = "\(senderId):\(senderDeviceId)" + sessions[sessionKey] = ratchet + try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId) + #if DEBUG + print("DEBUG processX3DHHeader: session created for \(sessionKey)") + #endif + + return ratchet + } + + // MARK: - Send Message + + func sendMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil, + extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { + let isGroup = members.count > 2 + + if isGroup { + return await sendGroupMessage(convId: convId, text: text, members: members, replyTo: replyTo, extraPayload: extraPayload, imageFileId: imageFileId) + } else { + return await sendDM(convId: convId, text: text, members: members, replyTo: replyTo, extraPayload: extraPayload, imageFileId: imageFileId) + } + } + + // MARK: - Send DM + + private func sendDM(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil, + extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { + #if DEBUG + print("DEBUG sendDM: convId=\(convId), members=\(members.map { $0.userId })") + #endif + guard let identityPrivate = identityPrivate else { + #if DEBUG + print("DEBUG sendDM: Identity key not loaded") + #endif + return (false, "Identity key not loaded", nil) + } + + // Build JSON payload matching Python format for cross-client compatibility + var payload: [String: Any] = [ + "sender": username, + "text": text, + "reply_to": (replyTo as Any?) ?? NSNull(), + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + // Merge extra fields (image, file) at top level + if let extra = extraPayload { + for (key, value) in extra { payload[key] = value } + } + let rawPlaintext: Data + if let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) { + rawPlaintext = jsonData + } else { + rawPlaintext = Data(text.utf8) + } + let plaintext = MessagePadding.pad(rawPlaintext) + + var recipients: [[String: Any]] = [] + + // Ensure we have the other member(s) — refetch if members only contains self + var actualMembers = members + let otherMembers = members.filter { $0.userId != userId } + if otherMembers.isEmpty { + #if DEBUG + print("DEBUG sendDM: WARNING — no other members in conversation, refetching from server") + #endif + let allConvs = await listConversations() + if let conv = allConvs.first(where: { $0.id == convId }) { + actualMembers = conv.members + #if DEBUG + print("DEBUG sendDM: refetched \(actualMembers.count) members for conv \(convId)") + #endif + } + } + + // Encrypt for each member's devices + #if DEBUG + print("DEBUG sendDM: encrypting for members, my userId=\(userId ?? "nil")") + #endif + for member in actualMembers where member.userId != userId { + #if DEBUG + print("DEBUG sendDM: processing member \(member.userId)") + #endif + do { + let bundles = try await getDeviceBundles(userId: member.userId) + #if DEBUG + print("DEBUG sendDM: got \(bundles.count) device bundles for \(member.userId)") + #endif + for (index, bundle) in bundles.enumerated() { + #if DEBUG + print("DEBUG sendDM: encrypting for bundle \(index + 1)/\(bundles.count), deviceId=\(bundle.deviceId)") + #endif + let ratchet = try await getOrCreateSession( + peerUserId: member.userId, + peerDeviceId: bundle.deviceId, + bundle: bundle + ) + + // Consume X3DH header if present (first message only) + let x3dhHeader = ratchet.x3dhHeader + ratchet.x3dhHeader = nil + + let encrypted = try ratchet.encrypt(plaintext) + try? KeyStorage.saveSession(email: email, peerUserId: member.userId, ratchet: ratchet, localKey: localKey, peerDeviceId: bundle.deviceId) + + var recipientEntry: [String: Any] = [ + "user_id": member.userId, + "device_id": bundle.deviceId, + "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext), + "nonce": ProtocolHandler.encodeBinary(encrypted.nonce), + "ratchet_header": encrypted.header, + ] + if let x3dh = x3dhHeader { + recipientEntry["x3dh_header"] = x3dh + #if DEBUG + print("DEBUG sendDM: x3dh_header for \(bundle.deviceId) = \(x3dh)") + #endif + } + #if DEBUG + print("DEBUG sendDM: ratchet_header for \(bundle.deviceId) = \(encrypted.header)") + #endif + recipients.append(recipientEntry) + } + } catch { + #if DEBUG + print("DEBUG sendDM: ERROR encrypting for member \(member.userId): \(error)") + #endif + return (false, "Encryption failed for \(member.username): \(error.localizedDescription)", nil) + } + } + + // Self-encrypted copy (readable by all own devices via shared identity key) + let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData) + if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) { + let selfCiphertext = ct + tag + recipients.append([ + "user_id": userId!, + "device_id": Constants.selfDeviceId, + "encrypted_content": ProtocolHandler.encodeBinary(selfCiphertext), + "nonce": ProtocolHandler.encodeBinary(nonce), + "ratchet_header": ["self": true], + ]) + } + + // Build ratchet header for message table (use first recipient's or dummy) + let ratchetHeader: [String: Any] + if let first = recipients.first { + ratchetHeader = first["ratchet_header"] as? [String: Any] ?? [:] + } else { + ratchetHeader = ["dh_pub": String(repeating: "00", count: 32), "n": 0, "pn": 0] + } + + var params: [String: Any] = [ + "conversation_id": convId, + "ratchet_header": ratchetHeader, + "recipients": recipients, + ] + if let replyTo = replyTo { + params["reply_to"] = replyTo + } + if let imageFileId = imageFileId { + params["image_file_id"] = imageFileId + } + + #if DEBUG + print("DEBUG sendDM: sending with \(recipients.count) recipients") + #endif + let resp = await sendAndReceive(type: "send_message", params: params) + #if DEBUG + print("DEBUG sendDM: response status=\(resp.string(for: "status") ?? "nil")") + #endif + guard resp.string(for: "status") == "ok" else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed" + return (false, msg, nil) + } + + // Build sent Message and cache the plaintext + var sentMessage: Message? + if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") { + // Cache plaintext for future getMessages calls + MessageCache.cacheDecryptedMessage( + email: email, convId: convId, messageId: messageId, + plaintext: plaintext, cacheKey: cacheKey + ) + + var forwardedFrom: ForwardedFrom? + if let fwd = extraPayload?["forwarded_from"] as? [String: Any], + let fwdSender = fwd["sender"] as? String { + forwardedFrom = ForwardedFrom(sender: fwdSender, + conversationId: fwd["conversation_id"] as? String ?? "", + messageId: fwd["message_id"] as? String ?? "") + } + + // Parse image/file info from extraPayload so the sent message displays correctly + var sentImage: ImageInfo? + if let imgDict = extraPayload?["image"] as? [String: Any], + let imgFileId = imgDict["file_id"] as? String, !imgFileId.isEmpty { + sentImage = ImageInfo( + fileId: imgFileId, + aesKey: imgDict["aes_key"] as? String ?? "", + iv: imgDict["iv"] as? String ?? "", + thumbnail: imgDict["thumbnail"] as? String, + filename: imgDict["filename"] as? String ?? "image.jpg", + size: imgDict["size"] as? Int ?? 0 + ) + } + var sentFile: FileInfo? + if let fileDict = extraPayload?["file"] as? [String: Any], + let fFileId = fileDict["file_id"] as? String, !fFileId.isEmpty { + sentFile = FileInfo( + fileId: fFileId, + aesKey: fileDict["aes_key"] as? String ?? "", + iv: fileDict["iv"] as? String ?? "", + filename: fileDict["filename"] as? String ?? "", + size: fileDict["size"] as? Int ?? 0, + mimeType: fileDict["mime_type"] as? String ?? "" + ) + } + + let createdAt = msgData.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() + sentMessage = Message( + id: messageId, conversationId: convId, senderId: userId!, + senderUsername: username, createdAt: createdAt, + text: text.isEmpty ? nil : text, replyTo: replyTo, + imageFileId: imageFileId, file: sentFile, image: sentImage, + isDeleted: false, readBy: [], + reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil + ) + } + + return (true, "Message sent", sentMessage) + } + + // MARK: - Send Group Message + + private func sendGroupMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil, + extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { + guard let identityPrivate = identityPrivate, let userId = userId, deviceId != nil else { + return (false, "Not properly logged in", nil) + } + + // Ensure we have all members — refetch if only self is present + var actualMembers = members + let otherMembers = members.filter { $0.userId != userId } + if otherMembers.isEmpty { + #if DEBUG + print("DEBUG sendGroupMessage: WARNING — no other members, refetching from server") + #endif + let allConvs = await listConversations() + if let conv = allConvs.first(where: { $0.id == convId }) { + actualMembers = conv.members + } + } + + // Get or create sender key for this group + var senderKeyState = senderKeyStates[convId] + if senderKeyState == nil { + senderKeyState = KeyStorage.loadSenderKeyState(email: email, convId: convId, localKey: localKey) + } + + var needDistribute = false + if senderKeyState == nil { + senderKeyState = SenderKeyState() + needDistribute = true + } + + senderKeyStates[convId] = senderKeyState + + // Distribute sender key if new + if needDistribute { + await distributeSenderKey(convId: convId, members: actualMembers) + } + + // Encrypt with sender key — JSON payload for cross-client compatibility + var groupPayload: [String: Any] = [ + "sender": username, + "text": text, + "reply_to": (replyTo as Any?) ?? NSNull(), + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + if let extra = extraPayload { + for (key, value) in extra { groupPayload[key] = value } + } + let rawPlaintext: Data + if let jsonData = try? JSONSerialization.data(withJSONObject: groupPayload, options: [.sortedKeys]) { + rawPlaintext = jsonData + } else { + rawPlaintext = Data(text.utf8) + } + let plaintext = MessagePadding.pad(rawPlaintext) + do { + let encrypted = try senderKeyState!.encrypt(plaintext) + try? KeyStorage.saveSenderKeyState(email: email, convId: convId, state: senderKeyState!, localKey: localKey) + + // Build recipients (same ciphertext for all) + var recipients: [[String: Any]] = [] + for member in actualMembers where member.userId != userId { + recipients.append([ + "user_id": member.userId, + "device_id": Constants.selfDeviceId, // group messages use sentinel + "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext), + "nonce": ProtocolHandler.encodeBinary(encrypted.nonce), + ]) + } + + // Self copy (readable by all own devices via shared identity key) + let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData) + if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) { + recipients.append([ + "user_id": userId, + "device_id": Constants.selfDeviceId, + "encrypted_content": ProtocolHandler.encodeBinary(ct + tag), + "nonce": ProtocolHandler.encodeBinary(nonce), + "ratchet_header": ["self": true], + ]) + } + + let dummyHeader: [String: Any] = [ + "dh_pub": String(repeating: "00", count: 32), + "n": 0, + "pn": 0, + ] + + var params: [String: Any] = [ + "conversation_id": convId, + "ratchet_header": dummyHeader, + "recipients": recipients, + ] + + // Include sender key metadata for group routing (base64-encoded chain ID bytes) + if let chainIdBytes = Data(hexString: encrypted.chainIdHex) { + params["sender_chain_id"] = ProtocolHandler.encodeBinary(chainIdBytes) + } + params["sender_chain_n"] = encrypted.n + + if let replyTo = replyTo { + params["reply_to"] = replyTo + } + if let imageFileId = imageFileId { + params["image_file_id"] = imageFileId + } + + let resp = await sendAndReceive(type: "send_message", params: params) + guard resp.string(for: "status") == "ok" else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed" + return (false, msg, nil) + } + + // Build sent Message and cache the plaintext + var sentMessage: Message? + if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") { + MessageCache.cacheDecryptedMessage( + email: email, convId: convId, messageId: messageId, + plaintext: plaintext, cacheKey: cacheKey + ) + + var forwardedFrom: ForwardedFrom? + if let fwd = extraPayload?["forwarded_from"] as? [String: Any], + let fwdSender = fwd["sender"] as? String { + forwardedFrom = ForwardedFrom(sender: fwdSender, + conversationId: fwd["conversation_id"] as? String ?? "", + messageId: fwd["message_id"] as? String ?? "") + } + + // Parse image/file info from extraPayload so the sent message displays correctly + var sentImage: ImageInfo? + if let imgDict = extraPayload?["image"] as? [String: Any], + let imgFileId = imgDict["file_id"] as? String, !imgFileId.isEmpty { + sentImage = ImageInfo( + fileId: imgFileId, + aesKey: imgDict["aes_key"] as? String ?? "", + iv: imgDict["iv"] as? String ?? "", + thumbnail: imgDict["thumbnail"] as? String, + filename: imgDict["filename"] as? String ?? "image.jpg", + size: imgDict["size"] as? Int ?? 0 + ) + } + var sentFile: FileInfo? + if let fileDict = extraPayload?["file"] as? [String: Any], + let fFileId = fileDict["file_id"] as? String, !fFileId.isEmpty { + sentFile = FileInfo( + fileId: fFileId, + aesKey: fileDict["aes_key"] as? String ?? "", + iv: fileDict["iv"] as? String ?? "", + filename: fileDict["filename"] as? String ?? "", + size: fileDict["size"] as? Int ?? 0, + mimeType: fileDict["mime_type"] as? String ?? "" + ) + } + + let createdAt = msgData.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() + sentMessage = Message( + id: messageId, conversationId: convId, senderId: userId, + senderUsername: username, createdAt: createdAt, + text: text.isEmpty ? nil : text, replyTo: replyTo, + imageFileId: imageFileId, file: sentFile, image: sentImage, + isDeleted: false, readBy: [], + reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil + ) + } + + return (true, "Message sent", sentMessage) + } catch { + return (false, "Encryption failed: \(error.localizedDescription)", nil) + } + } + + // MARK: - Distribute Sender Key + + private func distributeSenderKey(convId: String, members: [ConversationMember]) async { + guard let senderKeyState = senderKeyStates[convId], + let userId = userId, + let deviceId = deviceId else { return } + + let exportedKey = senderKeyState.exportKey() + + for member in members where member.userId != userId { + do { + let bundles = try await getDeviceBundles(userId: member.userId) + for bundle in bundles { + let ratchet = try await getOrCreateSession( + peerUserId: member.userId, + peerDeviceId: bundle.deviceId, + bundle: bundle + ) + + let x3dhHeader = ratchet.x3dhHeader + ratchet.x3dhHeader = nil + + // Payload includes sender key + metadata + let controlPayload: [String: Any] = [ + "_sender_key": [ + "conv_id": convId, + "key": ProtocolHandler.encodeBinary(exportedKey), + "sender_device_id": deviceId, + ] + ] + let controlData = try JSONSerialization.data(withJSONObject: controlPayload) + let encrypted = try ratchet.encrypt(controlData) + try? KeyStorage.saveSession(email: email, peerUserId: member.userId, ratchet: ratchet, localKey: localKey, peerDeviceId: bundle.deviceId) + + var recipientEntry: [String: Any] = [ + "user_id": member.userId, + "device_id": bundle.deviceId, + "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext), + "nonce": ProtocolHandler.encodeBinary(encrypted.nonce), + "ratchet_header": encrypted.header, + ] + if let x3dh = x3dhHeader { + recipientEntry["x3dh_header"] = x3dh + } + + let dummyHeader: [String: Any] = [ + "dh_pub": String(repeating: "00", count: 32), + "n": 0, + "pn": 0, + ] + + _ = await sendAndReceive(type: "send_message", params: [ + "conversation_id": convId, + "ratchet_header": dummyHeader, + "recipients": [recipientEntry], + ]) + } + } catch { + #if DEBUG + print("Failed to distribute sender key to \(member.userId): \(error)") + #endif + } + } + } + + // MARK: - Decrypt + + /// Decrypt DM using Double Ratchet, or static key for self-copies. + /// Matches Python: _decrypt_dm() lines 1547-1652 + func decryptDMRecipientData( + senderData: [String: Any], + senderId: String, + senderDeviceId: String + ) -> Data? { + // Server uses "encrypted_content", fallback to "ciphertext" for compatibility + let ctB64 = senderData["encrypted_content"] as? String ?? senderData["ciphertext"] as? String + guard let ctB64 = ctB64, + let nonceB64 = senderData["nonce"] as? String, + let ciphertext = try? ProtocolHandler.decodeBinary(ctB64), + let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else { + #if DEBUG + print("DEBUG decryptDM: missing encrypted_content or nonce") + #endif + return nil + } + #if DEBUG + print("DEBUG decryptDM: ct=\(ciphertext.count)B nonce=\(nonce.count)B sender=\(senderId) senderDev=\(senderDeviceId)") + #endif + + let ratchetHeader = senderData["ratchet_header"] as? [String: Any] ?? [:] + + // Self-encrypted message (ratchet_header.self == true) + // Matches Python lines 1562-1566 + let isSelf = (ratchetHeader["self"] as? Bool) == true || (ratchetHeader["self"] as? Int) == 1 + if isSelf { + #if DEBUG + print("DEBUG decryptDM: self-encrypted copy") + #endif + guard let cacheKey = cacheKey, ciphertext.count >= 16 else { + #if DEBUG + print("DEBUG decryptDM: no cacheKey or ct too short") + #endif + return nil + } + let ct = ciphertext.prefix(ciphertext.count - 16) + let tag = ciphertext.suffix(16) + let result = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ct), tag: Data(tag)) + #if DEBUG + print("DEBUG decryptDM: self-decrypt \(result != nil ? "OK" : "FAILED")") + #endif + return result + } + + // Regular DM — Double Ratchet decryption + // Matches Python lines 1568-1631 + let x3dhHeader = senderData["x3dh_header"] as? [String: Any] + let sessionKey = "\(senderId):\(senderDeviceId)" + + // Try to load existing session (memory → disk) + let ratchet = sessions[sessionKey] + ?? KeyStorage.loadSession(email: email, peerUserId: senderId, localKey: localKey, peerDeviceId: senderDeviceId) + if ratchet != nil { + sessions[sessionKey] = ratchet + } + #if DEBUG + print("DEBUG decryptDM: sessionKey=\(sessionKey) existingSession=\(ratchet != nil) hasX3DH=\(x3dhHeader != nil)") + #endif + + var plaintext: Data? + + if ratchet != nil && x3dhHeader == nil { + // Normal case: existing session, no X3DH header (Python line 1581-1585) + do { + plaintext = try ratchet!.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) + try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet!, localKey: localKey, peerDeviceId: senderDeviceId) + #if DEBUG + print("DEBUG decryptDM: normal decrypt OK") + #endif + } catch { + #if DEBUG + print("DEBUG decryptDM: normal decrypt FAILED: \(error)") + #endif + return nil + } + } else if let x3dh = x3dhHeader, let existingRatchet = ratchet { + // Existing session + X3DH: sender may have reset. Try existing first, fallback to new X3DH + // Matches Python lines 1587-1613 + #if DEBUG + print("DEBUG decryptDM: existing session + X3DH header, trying existing first") + #endif + let backup = try? existingRatchet.exportState() + do { + plaintext = try existingRatchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) + try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: existingRatchet, localKey: localKey, peerDeviceId: senderDeviceId) + #if DEBUG + print("DEBUG decryptDM: existing session decrypt OK (X3DH ignored)") + #endif + } catch { + // Restore from backup and try X3DH + #if DEBUG + print("DEBUG decryptDM: existing session failed, trying X3DH") + #endif + if let backup = backup, let restored = try? DoubleRatchet.importState(backup) { + sessions[sessionKey] = restored + try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: restored, localKey: localKey, peerDeviceId: senderDeviceId) + } + plaintext = tryX3DHDecrypt(x3dh: x3dh, ratchetHeader: ratchetHeader, ciphertext: ciphertext, nonce: nonce, senderId: senderId, senderDeviceId: senderDeviceId) + } + } else if let x3dh = x3dhHeader { + // No existing session, process X3DH + // Matches Python lines 1614-1629 + #if DEBUG + print("DEBUG decryptDM: no existing session, processing X3DH") + #endif + plaintext = tryX3DHDecrypt(x3dh: x3dh, ratchetHeader: ratchetHeader, ciphertext: ciphertext, nonce: nonce, senderId: senderId, senderDeviceId: senderDeviceId) + } else { + // No session and no X3DH + #if DEBUG + print("DEBUG decryptDM: no session and no X3DH for \(senderId)") + #endif + return nil + } + + guard let rawDecrypted = plaintext else { return nil } + let unpadded = MessagePadding.unpad(rawDecrypted) + + sessions[sessionKey] = ratchet ?? sessions[sessionKey] + #if DEBUG + print("DEBUG decryptDM: decrypt OK, \(unpadded.count) bytes") + #endif + + // Check for sender key distribution (control message) + if let jsonObj = try? JSONSerialization.jsonObject(with: unpadded) as? [String: Any], + let senderKeyInfo = jsonObj["_sender_key"] as? [String: Any] { + handleSenderKeyDistribution(senderKeyInfo, senderId: senderId) + return nil // Control message + } + + return unpadded + } + + /// Helper: try X3DH header processing + decrypt, with prev SPK fallback. + /// Matches Python's try/except pattern in _decrypt_dm lines 1615-1629. + private func tryX3DHDecrypt( + x3dh: [String: Any], ratchetHeader: [String: Any], + ciphertext: Data, nonce: Data, + senderId: String, senderDeviceId: String + ) -> Data? { + let sessionKey = "\(senderId):\(senderDeviceId)" + do { + let ratchet = try processX3DHHeader( + senderId: senderId, x3dhHeader: x3dh, senderDeviceId: senderDeviceId) + let plaintext = try ratchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) + sessions[sessionKey] = ratchet + try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId) + #if DEBUG + print("DEBUG tryX3DHDecrypt: OK with current SPK") + #endif + return plaintext + } catch { + #if DEBUG + print("DEBUG tryX3DHDecrypt: failed with current SPK: \(error)") + #endif + // Try with previous SPK (grace period, M4) + if let prevSpk = prevSpkPrivate { + do { + let ratchet = try processX3DHHeader( + senderId: senderId, x3dhHeader: x3dh, senderDeviceId: senderDeviceId, + spkOverride: prevSpk) + let plaintext = try ratchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) + sessions[sessionKey] = ratchet + try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId) + #if DEBUG + print("DEBUG tryX3DHDecrypt: OK with prev SPK") + #endif + return plaintext + } catch { + #if DEBUG + print("DEBUG tryX3DHDecrypt: failed with prev SPK too: \(error)") + #endif + } + } + return nil + } + } + + private func handleSenderKeyDistribution(_ info: [String: Any], senderId: String) { + guard let convId = info["conv_id"] as? String, + let keyB64 = info["key"] as? String, + let keyData = try? ProtocolHandler.decodeBinary(keyB64) else { return } + + let senderDeviceId = info["sender_device_id"] as? String ?? Constants.selfDeviceId + + do { + let senderKey = try SenderKeyState.fromKey(keyData) + let stateKey = "\(convId):\(senderId):\(senderDeviceId)" + recvSenderKeys[stateKey] = senderKey + try? KeyStorage.saveRecvSenderKey( + email: email, + convId: convId, + senderId: senderId, + senderDeviceId: senderDeviceId, + state: senderKey, + localKey: localKey + ) + } catch { + #if DEBUG + print("Failed to import sender key: \(error)") + #endif + } + } + + // MARK: - Group Decrypt + + /// Decrypt a group message using the sender's distributed Sender Key. + /// Matches Python: _decrypt_group() in chat_core.py + func decryptGroupMessage( + msgDict: [String: Any], + senderId: String, + senderDeviceId: String, + convId: String + ) -> Data? { + guard let chainIdB64 = msgDict["sender_chain_id"] as? String, + let chainN = msgDict["sender_chain_n"] as? Int, + let chainIdData = try? ProtocolHandler.decodeBinary(chainIdB64) else { + #if DEBUG + print("DEBUG decryptGroupMessage: missing sender_chain_id or sender_chain_n") + #endif + return nil + } + let chainIdHex = chainIdData.hexString + + let ctB64 = msgDict["encrypted_content"] as? String ?? msgDict["ciphertext"] as? String + guard let ctB64 = ctB64, + let nonceB64 = msgDict["nonce"] as? String, + let ciphertext = try? ProtocolHandler.decodeBinary(ctB64), + let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else { + #if DEBUG + print("DEBUG decryptGroupMessage: missing encrypted_content or nonce") + #endif + return nil + } + + // Look up received sender key — try with sender_device_id first + var sk: SenderKeyState? + let keyWithDevice = "\(convId):\(senderId):\(senderDeviceId)" + sk = recvSenderKeys[keyWithDevice] + if sk == nil { + sk = KeyStorage.loadRecvSenderKey( + email: email, convId: convId, senderId: senderId, + senderDeviceId: senderDeviceId, localKey: localKey + ) + if let loaded = sk { + recvSenderKeys[keyWithDevice] = loaded + } + } + + // Fallback: try with self device ID (legacy/default) + if sk == nil && senderDeviceId != Constants.selfDeviceId { + let keyLegacy = "\(convId):\(senderId):\(Constants.selfDeviceId)" + sk = recvSenderKeys[keyLegacy] + if sk == nil { + sk = KeyStorage.loadRecvSenderKey( + email: email, convId: convId, senderId: senderId, + senderDeviceId: Constants.selfDeviceId, localKey: localKey + ) + if let loaded = sk { + recvSenderKeys[keyLegacy] = loaded + } + } + } + + guard let senderKey = sk else { + #if DEBUG + print("DEBUG decryptGroupMessage: no sender key for \(senderId) in conv \(convId)") + #endif + return nil + } + + do { + let rawPlaintext = try senderKey.decrypt(chainIdHex: chainIdHex, n: chainN, ciphertext: ciphertext, nonce: nonce) + let plaintext = MessagePadding.unpad(rawPlaintext) + // Save updated state (chain has advanced) + try? KeyStorage.saveRecvSenderKey( + email: email, convId: convId, senderId: senderId, + senderDeviceId: senderDeviceId, state: senderKey, localKey: localKey + ) + #if DEBUG + print("DEBUG decryptGroupMessage: success, \(plaintext.count) bytes") + #endif + return plaintext + } catch { + #if DEBUG + print("DEBUG decryptGroupMessage: decrypt failed: \(error)") + #endif + return nil + } + } + + /// Decrypt a new_message notification. Matches Python: decrypt_notification(). + /// Supports multi-device format (device_entries array) and legacy flat format. + func decryptNotification(_ data: [String: Any]) -> Message? { + guard let senderId = data.string(for: "sender_id"), + let conversationId = data.string(for: "conversation_id"), + let messageId = data.string(for: "message_id") else { + #if DEBUG + print("DEBUG decryptNotification: missing sender_id/conversation_id/message_id") + #endif + return nil + } + + let senderDeviceId = data.string(for: "sender_device_id") ?? Constants.selfDeviceId + let myUserId = userId ?? "" + #if DEBUG + print("DEBUG decryptNotification: msgId=\(messageId) senderId=\(senderId) senderDeviceId=\(senderDeviceId) convId=\(conversationId)") + #endif + + // --- Step 1: Extract per-device encrypted content --- + // Matches Python decrypt_notification lines 1862-1897 + var encryptedContent: String = "" + var nonce: String = "" + var ratchetHeader: [String: Any] = [:] + var x3dhHeader: [String: Any]? + + if let deviceEntries = data["device_entries"] as? [[String: Any]] { + // Multi-device format: pick entry matching our device_id or SELF_DEVICE_ID + let entryDeviceIds = deviceEntries.compactMap { $0["device_id"] as? String } + #if DEBUG + print("DEBUG decryptNotification: myDeviceId=\(deviceId ?? "nil") device_entries=\(entryDeviceIds)") + #endif + + var chosen: [String: Any]? + var selfEntry: [String: Any]? + for entry in deviceEntries { + let eid = entry["device_id"] as? String ?? "" + if eid == deviceId { + chosen = entry + break + } + if eid == Constants.selfDeviceId { + selfEntry = entry + } + } + + // If sender is us, prefer self-encrypted entry (matches Python lines 1878-1882) + if senderId == myUserId { + chosen = selfEntry ?? chosen + } else if chosen == nil { + chosen = selfEntry + } + + guard let chosen = chosen else { + #if DEBUG + print("DEBUG decryptNotification: NO matching device_entry for device \(deviceId ?? "nil")") + #endif + return nil + } + + encryptedContent = chosen["encrypted_content"] as? String ?? "" + nonce = chosen["nonce"] as? String ?? "" + ratchetHeader = (chosen["ratchet_header"] as? [String: Any]) ?? (data["ratchet_header"] as? [String: Any]) ?? [:] + x3dhHeader = (chosen["x3dh_header"] as? [String: Any]) ?? (data["x3dh_header"] as? [String: Any]) + } else { + // Legacy flat format (no device_entries) + encryptedContent = data["encrypted_content"] as? String ?? data["ciphertext"] as? String ?? "" + nonce = data["nonce"] as? String ?? "" + ratchetHeader = data["ratchet_header"] as? [String: Any] ?? [:] + x3dhHeader = data["x3dh_header"] as? [String: Any] + } + + guard !encryptedContent.isEmpty, !nonce.isEmpty else { + #if DEBUG + print("DEBUG decryptNotification: empty encrypted_content or nonce") + #endif + return nil + } + + // --- Step 2: Build msg_data and decrypt --- + // Matches Python decrypt_notification lines 1899-1910 + let msgData: [String: Any] = [ + "sender_id": senderId, + "sender_device_id": senderDeviceId, + "conversation_id": conversationId, + "ratchet_header": ratchetHeader, + "encrypted_content": encryptedContent, + "nonce": nonce, + "x3dh_header": x3dhHeader as Any, + "sender_chain_id": data["sender_chain_id"] as Any, + "sender_chain_n": data["sender_chain_n"] as Any, + ] + + // Dispatch: matches Python _decrypt_message() lines 1533-1545 + // Check self-encrypted FIRST (before group check) — after re-encryption, + // group messages have {"self": true} ratchet_header but still sender_chain_id. + var plaintext: Data? + let isSelfEncrypted = (ratchetHeader["self"] as? Bool) == true + || (ratchetHeader["self"] as? Int) == 1 + + if isSelfEncrypted { + // Self-encrypted copy → static self-key (same path as _decrypt_dm self check) + #if DEBUG + print("DEBUG decryptNotification: self-encrypted message") + #endif + if let cacheKey = cacheKey { + if let ct = try? ProtocolHandler.decodeBinary(encryptedContent), + let n = try? ProtocolHandler.decodeBinary(nonce), + ct.count >= 16 { + let ciphertext = ct.prefix(ct.count - 16) + let tag = ct.suffix(16) + plaintext = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: n, ciphertext: Data(ciphertext), tag: Data(tag)) + #if DEBUG + print("DEBUG decryptNotification: self-decrypt result=\(plaintext != nil ? "OK" : "FAILED")") + #endif + } + } + } else if let chainId = msgData["sender_chain_id"] as? String, !chainId.isEmpty, senderId != myUserId { + // Group message from someone else → sender key decryption + #if DEBUG + print("DEBUG decryptNotification: group message") + #endif + plaintext = decryptGroupMessage( + msgDict: msgData, senderId: senderId, + senderDeviceId: senderDeviceId, convId: conversationId + ) + } else { + // DM → ratchet decryption + #if DEBUG + print("DEBUG decryptNotification: DM message, calling decryptDMRecipientData") + #endif + plaintext = decryptDMRecipientData( + senderData: msgData, + senderId: senderId, + senderDeviceId: senderDeviceId + ) + } + + guard let plaintext = plaintext else { + #if DEBUG + print("DEBUG decryptNotification: decrypt returned nil for msgId=\(messageId)") + #endif + return nil + } + + // --- Step 3: Cache and parse --- + MessageCache.cacheDecryptedMessage( + email: email, convId: conversationId, messageId: messageId, + plaintext: plaintext, cacheKey: cacheKey + ) + + // Queue for self-encryption if from another user (so other devices can read it) + if senderId != myUserId && !messageId.isEmpty { + pendingSelfEncrypt.append((messageId: messageId, plaintext: plaintext)) + } + + var messageText: String? = String(data: plaintext, encoding: .utf8) + var replyTo: String? + var file: FileInfo? + var image: ImageInfo? + var forwardedFrom: ForwardedFrom? + var senderName = data.string(for: "sender_username") ?? userCache[senderId]?.username ?? "Unknown" + + if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] { + messageText = jsonObj["text"] as? String + replyTo = jsonObj["reply_to"] as? String + if senderName == "Unknown", let s = jsonObj["sender"] as? String { + senderName = s + } + if let fileDict = jsonObj["file"] as? [String: Any] { + file = FileInfo( + fileId: fileDict["file_id"] as? String ?? "", + aesKey: fileDict["aes_key"] as? String ?? "", + iv: fileDict["iv"] as? String ?? "", + filename: fileDict["filename"] as? String ?? "", + size: fileDict["size"] as? Int ?? 0, + mimeType: fileDict["mime_type"] as? String ?? "" + ) + } + image = parseImageInfo(from: jsonObj) + if let fwd = jsonObj["forwarded_from"] as? [String: Any], + let fwdSender = fwd["sender"] as? String { + forwardedFrom = ForwardedFrom(sender: fwdSender, + conversationId: fwd["conversation_id"] as? String ?? "", + messageId: fwd["message_id"] as? String ?? "") + } + + // Check for sender key distribution (control message) + if jsonObj["_sender_key"] != nil { + #if DEBUG + print("DEBUG decryptNotification: control message (sender key distribution)") + #endif + return nil + } + } + + if messageText == nil && file == nil && image == nil { + #if DEBUG + print("DEBUG decryptNotification: control message (no text/file/image)") + #endif + return nil + } + + let createdAt = data.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() + + return Message( + id: messageId, + conversationId: conversationId, + senderId: senderId, + senderUsername: senderName, + createdAt: createdAt, + text: messageText, + replyTo: replyTo, + imageFileId: data.string(for: "image_file_id"), + file: file, + image: image, + isDeleted: false, + readBy: [], + reactions: [], + forwardedFrom: forwardedFrom, + pinnedAt: nil, + pinnedBy: nil + ) + } + + // MARK: - Self-Encrypt Flush + + /// Encrypt received messages with self-encryption key and upload to server. + /// Allows other devices of the same user to read received messages. + /// Matches Python: _flush_self_encrypt() + func flushSelfEncrypt() async { + guard !pendingSelfEncrypt.isEmpty, let edPriv = identityPrivate else { return } + + let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) + var updates: [[String: Any]] = [] + + for item in pendingSelfEncrypt { + guard let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(item.plaintext, key: selfKey) else { + continue + } + let selfCiphertext = ct + tag + updates.append([ + "message_id": item.messageId, + "encrypted_content": ProtocolHandler.encodeBinary(selfCiphertext), + "nonce": ProtocolHandler.encodeBinary(nonce), + ]) + } + pendingSelfEncrypt.removeAll() + + // Send in batches of 500 (matches Python batch size) + let batchSize = 500 + for batchStart in stride(from: 0, to: updates.count, by: batchSize) { + let batchEnd = min(batchStart + batchSize, updates.count) + let batch = Array(updates[batchStart.. [Conversation] { + let resp = await sendAndReceive(type: "list_conversations") + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let convList = data["conversations"] as? [[String: Any]] else { + return [] + } + + return convList.compactMap { dict -> Conversation? in + guard let id = dict.string(for: "conversation_id") else { return nil } + + let membersRaw = dict["members"] as? [[String: Any]] ?? [] + #if DEBUG + print("DEBUG listConversations: conv \(id) raw members count=\(membersRaw.count), raw=\(membersRaw)") + #endif + let members = membersRaw.compactMap { m -> ConversationMember? in + guard let uid = m.string(for: "user_id"), + let uname = m.string(for: "username"), + let uemail = m.string(for: "email") else { + #if DEBUG + print("DEBUG listConversations: FAILED to parse member: \(m)") + #endif + return nil + } + return ConversationMember(userId: uid, username: uname, email: uemail) + } + #if DEBUG + print("DEBUG listConversations: conv \(id) parsed members count=\(members.count)") + #endif + + let unreadCount = dict.int(for: "unread_count") ?? 0 + + return Conversation( + id: id, + name: dict.string(for: "name"), + members: members, + createdBy: dict.string(for: "created_by"), + avatarFile: dict.string(for: "avatar_file"), + unreadCount: unreadCount, + isFavorite: false, + lastMessageTime: nil + ) + } + } + + func createConversation(emails: [String], name: String? = nil) async -> (convId: String?, message: String) { + var params: [String: Any] = ["members": emails] + if let name = name { + params["name"] = name + } + + let resp = await sendAndReceive(type: "create_conversation", params: params) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let convId = data.string(for: "conversation_id") else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed to create conversation" + return (nil, msg) + } + + return (convId, "Conversation created") + } + + func findConversation(email: String) async -> String? { + let resp = await sendAndReceive(type: "find_conversation", params: ["email": email]) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { return nil } + return data.string(for: "conversation_id") + } + + // MARK: - Payload Parsing Helpers + + private func parseImageInfo(from jsonObj: [String: Any]) -> ImageInfo? { + guard let imgDict = jsonObj["image"] as? [String: Any], + let fileId = imgDict["file_id"] as? String, !fileId.isEmpty else { return nil } + return ImageInfo( + fileId: fileId, + aesKey: imgDict["aes_key"] as? String ?? "", + iv: imgDict["iv"] as? String ?? "", + thumbnail: imgDict["thumbnail"] as? String, + filename: imgDict["filename"] as? String ?? "image.jpg", + size: imgDict["size"] as? Int ?? 0 + ) + } + + // MARK: - Messages + + func getMessages(convId: String, limit: Int = 50, offset: Int = 0, afterTs: String? = nil) async -> [Message] { + var params: [String: Any] = [ + "conversation_id": convId, + "limit": limit, + "offset": offset, + ] + if let afterTs = afterTs { + params["after_ts"] = afterTs + } + let resp = await sendAndReceive(type: "get_messages", params: params) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let messagesRaw = data["messages"] as? [[String: Any]] else { + return [] + } + + // Server returns DESC (newest first), reverse to ASC (oldest first) — matches Python client + let sortedMessages = Array(messagesRaw.reversed()) + + // Deduplicate: server can return both device-specific and SELF_DEVICE_ID rows + // for the same message. Prefer self-encrypted rows (new devices after pairing + // can't decrypt old device-specific ratchet data, but CAN decrypt self-encrypted). + var dedupedMessages: [[String: Any]] = [] + var msgIndex: [String: Int] = [:] + for msgDict in sortedMessages { + guard let msgId = msgDict.string(for: "message_id") else { continue } + let rh = msgDict["ratchet_header"] as? [String: Any] + let isSelf = (rh?["self"] as? Bool) == true || (rh?["self"] as? Int) == 1 + if let idx = msgIndex[msgId] { + if isSelf { dedupedMessages[idx] = msgDict } + } else { + msgIndex[msgId] = dedupedMessages.count + dedupedMessages.append(msgDict) + } + } + + var messages: [Message] = [] + #if DEBUG + print("DEBUG getMessages: processing \(dedupedMessages.count) messages (from \(sortedMessages.count) rows)") + #endif + for msgDict in dedupedMessages { + guard let msgId = msgDict.string(for: "message_id"), + let senderId = msgDict.string(for: "sender_id") else { + #if DEBUG + print("DEBUG getMessages: skipping message without message_id or sender_id") + #endif + continue + } + + let senderDeviceId = msgDict.string(for: "sender_device_id") ?? Constants.selfDeviceId + let isDeleted = msgDict["deleted_at"] != nil && !(msgDict["deleted_at"] is NSNull) + #if DEBUG + print("DEBUG getMessages: msgId=\(msgId), senderId=\(senderId), senderDeviceId=\(senderDeviceId), myUserId=\(userId ?? "nil")") + print("DEBUG getMessages: msgDict keys=\(Array(msgDict.keys))") + #endif + + // Parse read_by from server response (matches Python: m.get("read_by", [])) + let readBy = Set(msgDict["read_by"] as? [String] ?? []) + + // Parse reactions from server response + var reactions: [MessageReaction] = [] + if let reactionsArr = msgDict["reactions"] as? [[String: Any]] { + reactions = reactionsArr.compactMap { r in + guard let rUserId = r["user_id"] as? String, + let reaction = r["reaction"] as? String else { return nil } + let rCreatedAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date() + return MessageReaction(userId: rUserId, reaction: reaction, createdAt: rCreatedAt) + } + } + + // Parse pin info from server response + let pinnedAt = (msgDict["pinned_at"] as? String).flatMap { DateParsing.parse($0) } + let pinnedBy = msgDict["pinned_by"] as? String + + if isDeleted { + let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() + messages.append(Message( + id: msgId, conversationId: convId, senderId: senderId, + senderUsername: "", + createdAt: createdAt, text: nil, isDeleted: true, readBy: readBy, + reactions: [], forwardedFrom: nil, pinnedAt: pinnedAt, pinnedBy: pinnedBy + )) + continue + } + + // Check per-message cache first (ratchet keys are one-time — cannot re-decrypt) + if let cachedPlaintext = MessageCache.getCachedMessage( + email: email, convId: convId, messageId: msgId, cacheKey: cacheKey + ) { + var messageText: String? = String(data: cachedPlaintext, encoding: .utf8) + var replyTo: String? + var file: FileInfo? + var image: ImageInfo? + var forwardedFrom: ForwardedFrom? + var senderName = userCache[senderId]?.username ?? "" + if let jsonObj = try? JSONSerialization.jsonObject(with: cachedPlaintext) as? [String: Any] { + messageText = jsonObj["text"] as? String + replyTo = jsonObj["reply_to"] as? String + if senderName.isEmpty, let s = jsonObj["sender"] as? String { + senderName = s + } + if let fileDict = jsonObj["file"] as? [String: Any] { + file = FileInfo( + fileId: fileDict["file_id"] as? String ?? "", + aesKey: fileDict["aes_key"] as? String ?? "", + iv: fileDict["iv"] as? String ?? "", + filename: fileDict["filename"] as? String ?? "", + size: fileDict["size"] as? Int ?? 0, + mimeType: fileDict["mime_type"] as? String ?? "" + ) + } + image = parseImageInfo(from: jsonObj) + if let fwd = jsonObj["forwarded_from"] as? [String: Any], + let fwdSender = fwd["sender"] as? String { + forwardedFrom = ForwardedFrom(sender: fwdSender, + conversationId: fwd["conversation_id"] as? String ?? "", + messageId: fwd["message_id"] as? String ?? "") + } + } + if messageText == nil && file == nil && image == nil { continue } // Control message + let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() + messages.append(Message( + id: msgId, conversationId: convId, senderId: senderId, + senderUsername: senderName, + createdAt: createdAt, text: messageText, replyTo: replyTo, + imageFileId: msgDict.string(for: "image_file_id"), file: file, + image: image, + isDeleted: false, readBy: readBy, + reactions: reactions, forwardedFrom: forwardedFrom, + pinnedAt: pinnedAt, pinnedBy: pinnedBy + )) + #if DEBUG + print("DEBUG getMessages: loaded from cache msgId=\(msgId)") + #endif + continue + } + + // Try to decrypt (only for messages not in cache) + // Dispatch: self-encrypted (ratchet_header.self) → static key + // group messages (sender_chain_id present, from others) → sender key decrypt + // DM or self-copy → ratchet / self-key decrypt + var plaintext: Data? + let ratchetHeader = msgDict["ratchet_header"] as? [String: Any] + let isSelfEncrypted = (ratchetHeader?["self"] as? Bool) == true + || (ratchetHeader?["self"] as? Int) == 1 + + #if DEBUG + print("DEBUG getMessages: decrypt msgId=\(msgId) senderId=\(senderId) isSelfEncrypted=\(isSelfEncrypted) ratchetHeader=\(ratchetHeader as Any) hasCacheKey=\(cacheKey != nil)") + #endif + + if isSelfEncrypted, let cacheKey = cacheKey { + // Re-encrypted message (from reencryptHistory) — decrypt with self-key + if let ctB64 = (msgDict["encrypted_content"] as? String ?? msgDict["ciphertext"] as? String), + let nonceB64 = msgDict["nonce"] as? String, + let ct = try? ProtocolHandler.decodeBinary(ctB64), + let nonce = try? ProtocolHandler.decodeBinary(nonceB64), + ct.count >= 16 { + let ciphertext = ct.prefix(ct.count - 16) + let tag = ct.suffix(16) + plaintext = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ciphertext), tag: Data(tag)) + #if DEBUG + print("DEBUG getMessages: self-decrypt msgId=\(msgId) ctLen=\(ct.count) nonceLen=\(nonce.count) result=\(plaintext != nil ? "OK(\(plaintext!.count)B)" : "FAILED")") + #endif + } else { + #if DEBUG + print("DEBUG getMessages: self-decrypt msgId=\(msgId) SKIPPED (missing ct/nonce)") + #endif + } + } else if let chainId = msgDict["sender_chain_id"] as? String, !chainId.isEmpty, senderId != userId { + plaintext = decryptGroupMessage( + msgDict: msgDict, senderId: senderId, + senderDeviceId: senderDeviceId, convId: convId + ) + } else { + plaintext = decryptDMRecipientData( + senderData: msgDict, senderId: senderId, + senderDeviceId: senderDeviceId + ) + } + + if let plaintext = plaintext { + // Cache the decrypted plaintext for future use + MessageCache.cacheDecryptedMessage( + email: email, convId: convId, messageId: msgId, + plaintext: plaintext, cacheKey: cacheKey + ) + + // Queue for self-encryption if from another user + if senderId != userId && !msgId.isEmpty { + pendingSelfEncrypt.append((messageId: msgId, plaintext: plaintext)) + } + + let text = String(data: plaintext, encoding: .utf8) + var messageText = text + var replyTo: String? + var file: FileInfo? + var image: ImageInfo? + var forwardedFrom: ForwardedFrom? + + var senderName = userCache[senderId]?.username ?? "" + if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] { + messageText = jsonObj["text"] as? String + replyTo = jsonObj["reply_to"] as? String + if senderName.isEmpty, let s = jsonObj["sender"] as? String { + senderName = s + } + if let fileDict = jsonObj["file"] as? [String: Any] { + file = FileInfo( + fileId: fileDict["file_id"] as? String ?? "", + aesKey: fileDict["aes_key"] as? String ?? "", + iv: fileDict["iv"] as? String ?? "", + filename: fileDict["filename"] as? String ?? "", + size: fileDict["size"] as? Int ?? 0, + mimeType: fileDict["mime_type"] as? String ?? "" + ) + } + image = parseImageInfo(from: jsonObj) + if let fwd = jsonObj["forwarded_from"] as? [String: Any], + let fwdSender = fwd["sender"] as? String { + forwardedFrom = ForwardedFrom(sender: fwdSender, + conversationId: fwd["conversation_id"] as? String ?? "", + messageId: fwd["message_id"] as? String ?? "") + } + } + + if messageText == nil && file == nil && image == nil { continue } // Control message + + let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() + messages.append(Message( + id: msgId, conversationId: convId, senderId: senderId, + senderUsername: senderName, + createdAt: createdAt, text: messageText, replyTo: replyTo, + imageFileId: msgDict.string(for: "image_file_id"), file: file, + image: image, + isDeleted: false, readBy: readBy, + reactions: reactions, forwardedFrom: forwardedFrom, + pinnedAt: pinnedAt, pinnedBy: pinnedBy + )) + #if DEBUG + print("DEBUG getMessages: decrypted and cached msgId=\(msgId)") + #endif + } else { + #if DEBUG + print("DEBUG getMessages: decryption failed for msgId=\(msgId)") + #endif + } + } + + // Confirm delivery for messages from others (fire-and-forget) + let deliverIds = messages.filter { $0.senderId != userId && !$0.isDeleted }.map(\.id) + if !deliverIds.isEmpty { + Task { await confirmDelivery(convId: convId, messageIds: deliverIds) } + } + + // Flush any queued self-encryptions in background + if !pendingSelfEncrypt.isEmpty { + Task { await flushSelfEncrypt() } + } + + #if DEBUG + print("DEBUG getMessages: returning \(messages.count) messages") + #endif + return messages + } + + func markRead(convId: String, messageIds: [String]) async { + _ = await sendAndReceive(type: "mark_read", params: [ + "conversation_id": convId, + "message_ids": messageIds, + ]) + } + + func markConversationRead(convId: String) async { + _ = await sendAndReceive(type: "mark_conversation_read", params: [ + "conversation_id": convId, + ]) + } + + func deleteMessage(messageId: String, convId: String) async -> Bool { + let resp = await sendAndReceive(type: "delete_message", params: [ + "message_id": messageId, + "conversation_id": convId, + ]) + return resp.string(for: "status") == "ok" + } + + func getDeletedSince(convId: String, sinceTs: String) async -> [String] { + let resp = await sendAndReceive(type: "get_deleted_since", params: [ + "conversation_id": convId, + "since_ts": sinceTs, + ]) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let ids = data["deleted_ids"] as? [String] else { return [] } + return ids + } + + func reactMessage(messageId: String, conversationId: String, + reaction: String, action: String) async -> Bool { + let resp = await sendAndReceive(type: "react_message", params: [ + "message_id": messageId, + "conversation_id": conversationId, + "reaction": reaction, + "action": action, + ]) + return resp.string(for: "status") == "ok" + } + + func pinMessage(messageId: String, conversationId: String, + action: String) async -> Bool { + let resp = await sendAndReceive(type: "pin_message", params: [ + "message_id": messageId, + "conversation_id": conversationId, + "action": action, + ]) + return resp.string(for: "status") == "ok" + } + + func getPinnedMessages(conversationId: String) async -> [[String: Any]] { + let resp = await sendAndReceive(type: "get_pinned_messages", params: [ + "conversation_id": conversationId, + ]) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let messages = data["messages"] as? [[String: Any]] else { return [] } + return messages + } + + // MARK: - Group Operations + + func addMember(convId: String, email: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "add_member", params: [ + "conversation_id": convId, + "email": email, + ]) + let msg = resp.dict(for: "data")?.string(for: "message") ?? "" + return (resp.string(for: "status") == "ok", msg) + } + + func removeMember(convId: String, userId: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "remove_member", params: [ + "conversation_id": convId, + "user_id": userId, + ]) + let msg = resp.dict(for: "data")?.string(for: "message") ?? "" + return (resp.string(for: "status") == "ok", msg) + } + + func leaveGroup(convId: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "leave_group", params: [ + "conversation_id": convId, + ]) + if resp.string(for: "status") == "ok" { + // Clean up local sender keys + senderKeyStates.removeValue(forKey: convId) + KeyStorage.deleteSenderKeyState(email: email, convId: convId) + KeyStorage.deleteRecvSenderKeys(email: email, convId: convId) + return (true, "Left group") + } + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed" + return (false, msg) + } + + func renameConversation(convId: String, name: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "rename_conversation", params: [ + "conversation_id": convId, + "name": name, + ]) + let msg = resp.dict(for: "data")?.string(for: "message") ?? "" + return (resp.string(for: "status") == "ok", msg) + } + + func deleteConversation(convId: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "delete_conversation", params: [ + "conversation_id": convId, + ]) + if resp.string(for: "status") == "ok" { + senderKeyStates.removeValue(forKey: convId) + KeyStorage.deleteSenderKeyState(email: email, convId: convId) + KeyStorage.deleteRecvSenderKeys(email: email, convId: convId) + return (true, "Deleted") + } + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed" + return (false, msg) + } + + // MARK: - Invitations + + func acceptInvitation(convId: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "accept_invitation", params: [ + "conversation_id": convId, + ]) + let msg = resp.dict(for: "data")?.string(for: "message") ?? "" + return (resp.string(for: "status") == "ok", msg) + } + + func declineInvitation(convId: String) async -> (success: Bool, message: String) { + let resp = await sendAndReceive(type: "decline_invitation", params: [ + "conversation_id": convId, + ]) + let msg = resp.dict(for: "data")?.string(for: "message") ?? "" + return (resp.string(for: "status") == "ok", msg) + } + + func listInvitations() async -> [Invitation] { + let resp = await sendAndReceive(type: "list_invitations") + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let invList = data["invitations"] as? [[String: Any]] else { + return [] + } + + return invList.compactMap { dict -> Invitation? in + guard let convId = dict.string(for: "conversation_id") else { return nil } + return Invitation( + id: dict.string(for: "id") ?? convId, + conversationId: convId, + conversationName: dict.string(for: "conversation_name") ?? "Group", + invitedBy: dict.string(for: "invited_by") ?? "", + invitedByUsername: dict.string(for: "invited_by_username") ?? "" + ) + } + } + + // MARK: - Profile + + func getProfile(userId: String? = nil) async -> UserProfile? { + var params: [String: Any] = [:] + if let userId = userId { + params["user_id"] = userId + } + let resp = await sendAndReceive(type: "get_profile", params: params) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { return nil } + + return UserProfile( + userId: data.string(for: "user_id") ?? userId ?? self.userId ?? "", + username: data.string(for: "username"), + email: data.string(for: "email"), + phone: data.string(for: "phone"), + phoneVisible: data.bool(for: "phone_visible") ?? false, + location: data.string(for: "location"), + locationVisible: data.bool(for: "location_visible") ?? false, + avatarFile: data.string(for: "avatar_file") + ) + } + + func updateProfile(phone: String? = nil, phoneVisible: Bool? = nil, + location: String? = nil, locationVisible: Bool? = nil) async -> Bool { + var params: [String: Any] = [:] + if let phone = phone { params["phone"] = phone } + if let phoneVisible = phoneVisible { params["phone_visible"] = phoneVisible } + if let location = location { params["location"] = location } + if let locationVisible = locationVisible { params["location_visible"] = locationVisible } + + let resp = await sendAndReceive(type: "update_profile", params: params) + return resp.string(for: "status") == "ok" + } + + func updateAvatar(imageData: Data) async -> (Bool, String) { + // Resize avatar to fit within protocol message limit (64KB total) + guard let uiImage = UIImage(data: imageData) else { return (false, "Invalid image") } + + let maxDim: CGFloat = 256 + let scale = min(1.0, maxDim / max(uiImage.size.width, uiImage.size.height)) + let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + let processed = renderer.image { _ in + uiImage.draw(in: CGRect(origin: .zero, size: newSize)) + } + + guard let jpegData = processed.jpegData(compressionQuality: 0.6) else { return (false, "JPEG encode failed") } + #if DEBUG + print("DEBUG updateAvatar: jpegData=\(jpegData.count) bytes, base64=\(jpegData.count * 4 / 3) bytes approx") + #endif + + let resp = await sendAndReceive(type: "update_avatar", params: [ + "data": ProtocolHandler.encodeBinary(jpegData), + ]) + #if DEBUG + print("DEBUG updateAvatar: resp=\(resp)") + #endif + if resp.string(for: "status") == "ok" { + return (true, "") + } + let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "Upload failed" + return (false, errMsg) + } + + func getAvatar(userId: String) async -> Data? { + let resp = await sendAndReceive(type: "get_avatar", params: ["user_id": userId]) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let avatarB64 = data.string(for: "data"), + let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else { + return nil + } + return avatarData + } + + // MARK: - Group Avatar + + func updateGroupAvatar(convId: String, imageData: Data) async -> Bool { + guard let uiImage = UIImage(data: imageData) else { return false } + + let maxDim: CGFloat = 256 + let scale = min(1.0, maxDim / max(uiImage.size.width, uiImage.size.height)) + let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + let processed = renderer.image { _ in + uiImage.draw(in: CGRect(origin: .zero, size: newSize)) + } + + guard let jpegData = processed.jpegData(compressionQuality: 0.6) else { return false } + + let resp = await sendAndReceive(type: "update_group_avatar", params: [ + "conversation_id": convId, + "data": ProtocolHandler.encodeBinary(jpegData), + ]) + return resp.string(for: "status") == "ok" + } + + func getGroupAvatar(convId: String) async -> Data? { + let resp = await sendAndReceive(type: "get_group_avatar", params: ["conversation_id": convId]) + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let avatarB64 = data.string(for: "data"), + let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else { + return nil + } + return avatarData + } + + // MARK: - Key Rotation + + func rotateKeys(password: String) async -> (success: Bool, message: String) { + guard rsaPrivate != nil, userId != nil else { + return (false, "Not logged in.") + } + + // Generate new RSA-4096 keypair + let newPriv: SecKey + let newPub: SecKey + do { + (newPriv, newPub) = try RSACrypto.generateKeypair() + } catch { + return (false, "Key generation failed: \(error.localizedDescription)") + } + + // Save locally (password-encrypted) + var pwdBytes = Array(password.utf8) + defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } + do { + try KeyStorage.saveRSAKeys(email: email, privateKey: newPriv, publicKey: newPub, password: Data(pwdBytes)) + } catch { + return (false, "Failed to save keys: \(error.localizedDescription)") + } + + // Update in-memory + self.rsaPrivate = newPriv + self.rsaPublic = newPub + + // Serialize public key to PEM and send to server + guard let pubPEM = try? RSACrypto.serializePublicKey(newPub), + let pubPEMStr = String(data: pubPEM, encoding: .utf8) else { + return (false, "Failed to serialize public key") + } + + let resp = await sendAndReceive(type: "rotate_keys", params: ["public_key": pubPEMStr]) + if resp.string(for: "status") == "ok" { + return (true, "Keys rotated. Other devices will be disconnected.") + } + let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "Rotation failed" + return (false, errMsg) + } + + // MARK: - Device Pairing + + private var pairingTempPrivateKey: SecKey? + private var pairingPollToken: String = "" + + /// Start pairing on NEW device: generates temp RSA-2048 key, sends to server, returns 8-digit code + func pairingStart(email: String) async -> (success: Bool, codeOrMessage: String) { + // Generate ephemeral RSA-2048 keypair + let tempPriv: SecKey + let tempPub: SecKey + do { + (tempPriv, tempPub) = try RSACrypto.generateKeypair2048() + } catch { + return (false, "Temp key generation failed") + } + self.pairingTempPrivateKey = tempPriv + + // Serialize temp public key to PEM + guard let tempPubPEM = try? RSACrypto.serializePublicKey(tempPub), + let tempPubStr = String(data: tempPubPEM, encoding: .utf8) else { + return (false, "Failed to serialize temp key") + } + + let resp = await sendAndReceive(type: "pairing_start", params: [ + "email": email, + "temp_public_key": tempPubStr, + ]) + + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data"), + let code = data.string(for: "code") else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Pairing start failed" + return (false, msg) + } + + self.pairingPollToken = data.string(for: "poll_token") ?? "" + return (true, code) + } + + /// Wait for pairing on NEW device: polls server every 2s, decrypts keys when ready + func pairingWait(code: String, email: String, password: String, timeout: TimeInterval = 300) async -> (success: Bool, message: String) { + guard pairingTempPrivateKey != nil else { + return (false, "Pairing not started.") + } + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let resp = await sendAndReceive(type: "pairing_poll", params: [ + "code": code, + "poll_token": pairingPollToken, + ]) + + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Poll failed" + return (false, msg) + } + + guard data.bool(for: "ready") == true else { + try? await Task.sleep(for: .seconds(2)) + continue + } + + // Payload received — decrypt keys + guard let payload = data["payload"] as? [String: Any], + let encKeyB64 = payload.string(for: "encrypted_key"), + let ivB64 = payload.string(for: "iv"), + let ctB64 = payload.string(for: "ciphertext"), + let tagB64 = payload.string(for: "tag") else { + return (false, "Invalid payload format") + } + + do { + let encAESKey = try ProtocolHandler.decodeBinary(encKeyB64) + let iv = try ProtocolHandler.decodeBinary(ivB64) + let ct = try ProtocolHandler.decodeBinary(ctB64) + let tag = try ProtocolHandler.decodeBinary(tagB64) + + // RSA-OAEP decrypt the AES key + let aesKey = try RSACrypto.decrypt(pairingTempPrivateKey!, ciphertext: encAESKey) + + // AES-GCM decrypt the keys JSON + let keysJSON = try CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: ct, tag: tag) + + guard let keysDict = try JSONSerialization.jsonObject(with: keysJSON) as? [String: String], + let rsaPrivPEM = keysDict["rsa_private"], + let identityHex = keysDict["identity_private"] else { + return (false, "Invalid keys JSON") + } + + // Import RSA key + var pwdBytes = Array(password.utf8) + defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } + let pwdData = Data(pwdBytes) + + let rsaPriv = try RSACrypto.loadPrivateKey(Data(rsaPrivPEM.utf8), password: nil) + let rsaPub = SecKeyCopyPublicKey(rsaPriv)! + + // Import Ed25519 identity key + guard let identityRaw = Data(hexString: identityHex), identityRaw.count == 32 else { + return (false, "Invalid identity key format") + } + let edPriv = try Curve25519.Signing.PrivateKey(rawRepresentation: identityRaw) + let edPub = edPriv.publicKey + + // Save to disk (encrypted with password) + try KeyStorage.saveRSAKeys(email: email, privateKey: rsaPriv, publicKey: rsaPub, password: pwdData) + try KeyStorage.saveIdentityKeys(email: email, privateKey: edPriv, publicKey: edPub, password: pwdData) + + // Update in-memory state + self.email = email + self.rsaPrivate = rsaPriv + self.rsaPublic = rsaPub + self.identityPrivate = edPriv + self.identityPublic = edPub + self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) + self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: edPriv.rawData) + + // Clear pairing state + self.pairingTempPrivateKey = nil + self.pairingPollToken = "" + + return (true, "Pairing complete.") + } catch { + return (false, "Failed to import keys: \(error.localizedDescription)") + } + } + + return (false, "Pairing timed out.") + } + + /// Re-encrypt all cached messages with self-encryption key so a paired device can read them. + /// Called by authorizeDevice() before sending keys. + func reencryptHistory() async { + guard let edPriv = identityPrivate else { return } + let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) + + // Phase 1: Fetch all messages to populate cache (decrypts and caches them) + let convs = await listConversations() + for conv in convs { + var offset = 0 + while true { + let msgs = await getMessages(convId: conv.id, limit: 200, offset: offset) + if msgs.count < 200 { break } + offset += msgs.count + } + } + + // Phase 2: Read per-message cache and re-encrypt with self-key + var allUpdates: [[String: String]] = [] + for conv in convs { + let cachedMessages = MessageCache.loadAllCachedMessages(email: email, convId: conv.id, cacheKey: cacheKey) + for (msgId, plaintext) in cachedMessages { + // Skip control messages + if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] { + if jsonObj["_control"] != nil { continue } + let text = jsonObj["text"] as? String + let image = jsonObj["image"] + let file = jsonObj["file"] + if text == nil && image == nil && file == nil { continue } + } + + // Re-encrypt with self-encryption key + guard let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) else { continue } + + allUpdates.append([ + "message_id": msgId, + "encrypted_content": ProtocolHandler.encodeBinary(ct + tag), + "nonce": ProtocolHandler.encodeBinary(nonce), + ]) + } + } + + if allUpdates.isEmpty { return } + + // Send in batches of 500 + let batchSize = 500 + for start in stride(from: 0, to: allUpdates.count, by: batchSize) { + let end = min(start + batchSize, allUpdates.count) + let batch = Array(allUpdates[start.. (success: Bool, message: String) { + guard let rsaPriv = rsaPrivate, let edPriv = identityPrivate else { + return (false, "Not logged in.") + } + + // Claim the pairing code — get temp public key + let claimResp = await sendAndReceive(type: "pairing_claim", params: ["code": code]) + guard claimResp.string(for: "status") == "ok", + let claimData = claimResp.dict(for: "data"), + let tempPubPEM = claimData.string(for: "temp_public_key") else { + let msg = claimResp.dict(for: "data")?.string(for: "message") ?? "Claim failed" + return (false, msg) + } + + // Load temp public key + let tempPub: SecKey + do { + tempPub = try RSACrypto.loadPublicKey(Data(tempPubPEM.utf8)) + } catch { + return (false, "Invalid temp public key: \(error.localizedDescription)") + } + + // Re-encrypt message history so new device can read old messages + await reencryptHistory() + + // Build keys payload (RSA private unencrypted PEM + Ed25519 raw hex) + let rsaPrivPEM: String + do { + let pemData = try RSACrypto.serializePrivateKey(rsaPriv, password: nil) + rsaPrivPEM = String(data: pemData, encoding: .utf8) ?? "" + } catch { + return (false, "Failed to serialize RSA key") + } + + let identityHex = edPriv.rawRepresentation.map { String(format: "%02x", $0) }.joined() + + let keysDict: [String: String] = [ + "rsa_private": rsaPrivPEM, + "identity_private": identityHex, + ] + + guard let keysJSON = try? JSONSerialization.data(withJSONObject: keysDict) else { + return (false, "JSON serialization failed") + } + + // Encrypt: AES-GCM for payload, RSA-OAEP for AES key + do { + let (aesKey, nonce, ct, tag) = try CryptoUtils.aesEncrypt(keysJSON) + let encAESKey = try RSACrypto.encrypt(tempPub, plaintext: aesKey) + + let payload: [String: String] = [ + "encrypted_key": ProtocolHandler.encodeBinary(encAESKey), + "iv": ProtocolHandler.encodeBinary(nonce), + "ciphertext": ProtocolHandler.encodeBinary(ct), + "tag": ProtocolHandler.encodeBinary(tag), + ] + + let resp = await sendAndReceive(type: "pairing_send", params: [ + "code": code, + "payload": payload, + ]) + + if resp.string(for: "status") == "ok" { + return (true, "Device authorized.") + } + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed" + return (false, msg) + } catch { + return (false, "Encryption failed: \(error.localizedDescription)") + } + } + + // MARK: - File Sharing + + func sendFile(convId: String, fileData: Data, filename: String, mimeType: String, + members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { + // Encrypt file with AES-GCM + guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(fileData) else { + return (false, "File encryption failed", nil) + } + + let encryptedData = ct + tag + let fileType = mimeType.hasPrefix("image/") ? "image" : "file" + + // Start upload + let fileId = UUID().uuidString.lowercased() + let startResp = await sendAndReceive(type: "upload_image_start", params: [ + "conversation_id": convId, + "file_id": fileId, + "file_size": encryptedData.count, + "file_type": fileType, + ]) + guard startResp.string(for: "status") == "ok" else { + let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed" + return (false, msg, nil) + } + + // Upload chunks + var offset = 0 + while offset < encryptedData.count { + let end = min(offset + Constants.imageChunkSize, encryptedData.count) + let chunk = encryptedData[offset.. (success: Bool, message: String, sentMessage: Message?) { + + // Step 1: Process image on MainActor (UIKit requires main thread) + let processed = await MainActor.run { () -> (imageBytes: Data, thumbnailB64: String, originalSize: Int)? in + guard let uiImage = UIImage(data: imageData) else { return nil } + + var finalBytes = imageData + var img = uiImage + // AES-GCM overhead: 12 nonce + 16 tag = 28 bytes + let maxPlaintextSize = Constants.maxImageBytes - 28 + + // If too large, progressively compress + if finalBytes.count > maxPlaintextSize { + let qualities: [CGFloat] = [0.92, 0.85, 0.75, 0.60] + var fits = false + for quality in qualities { + if let compressed = img.jpegData(compressionQuality: quality) { + finalBytes = compressed + if compressed.count <= maxPlaintextSize { fits = true; break } + } + } + + if !fits { + // Downscale dimensions + for maxDim: CGFloat in [3840, 2560, 1920, 1280] { + if max(img.size.width, img.size.height) > maxDim { + let scale = maxDim / max(img.size.width, img.size.height) + let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + img = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) } + } + if let compressed = img.jpegData(compressionQuality: 0.75) { + finalBytes = compressed + if compressed.count <= maxPlaintextSize { break } + } + } + } + } + + // Generate thumbnail (200x200, quality 60) + let thumbScale = min(200.0 / max(img.size.width, img.size.height), 1.0) + let thumbDim = CGSize(width: img.size.width * thumbScale, height: img.size.height * thumbScale) + let thumbRenderer = UIGraphicsImageRenderer(size: thumbDim) + let thumbImg = thumbRenderer.image { _ in img.draw(in: CGRect(origin: .zero, size: thumbDim)) } + let thumbB64 = thumbImg.jpegData(compressionQuality: 0.6)?.base64EncodedString() ?? "" + + return (finalBytes, thumbB64, imageData.count) + } + + guard let processed = processed else { + return (false, "Invalid image data", nil) + } + + // Step 2: Encrypt on background (actor isolates this automatically) + guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(processed.imageBytes) else { + return (false, "Image encryption failed", nil) + } + let encryptedData = ct + tag + + // Step 3: Chunked upload + let fileId = UUID().uuidString.lowercased() + let startResp = await sendAndReceive(type: "upload_image_start", params: [ + "conversation_id": convId, + "file_id": fileId, + "file_size": encryptedData.count, + "file_type": "image", + ]) + guard startResp.string(for: "status") == "ok" else { + let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed" + return (false, msg, nil) + } + + var offset = 0 + while offset < encryptedData.count { + let end = min(offset + Constants.imageChunkSize, encryptedData.count) + let chunk = encryptedData[offset.. Data? { + #if DEBUG + print("DEBUG downloadFile: starting download for fileId=\(fileId) convId=\(conversationId ?? "nil") aesKeyLen=\(aesKey.count) ivLen=\(iv.count)") + #endif + var allData = Data() + var offset = 0 + + while true { + var params: [String: Any] = [ + "file_id": fileId, + "offset": offset, + ] + if let conversationId = conversationId { + params["conversation_id"] = conversationId + } + let resp = await sendAndReceive(type: "download_image", params: params) + + let status = resp.string(for: "status") + guard status == "ok" else { + let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "unknown" + #if DEBUG + print("DEBUG downloadFile: server error status=\(status ?? "nil") msg=\(errMsg)") + #endif + break + } + guard let data = resp.dict(for: "data") else { + #if DEBUG + print("DEBUG downloadFile: missing 'data' dict in response, keys=\(Array(resp.keys))") + #endif + break + } + guard let chunkB64 = data.string(for: "data") else { + #if DEBUG + print("DEBUG downloadFile: missing 'data' string in data dict, keys=\(Array(data.keys))") + #endif + break + } + guard let chunk = try? ProtocolHandler.decodeBinary(chunkB64) else { + #if DEBUG + print("DEBUG downloadFile: base64 decode failed, chunkB64 length=\(chunkB64.count)") + #endif + break + } + + if chunk.isEmpty { + #if DEBUG + print("DEBUG downloadFile: empty chunk received, done") + #endif + break + } + allData.append(chunk) + offset += chunk.count + #if DEBUG + print("DEBUG downloadFile: received chunk \(chunk.count) bytes, total=\(allData.count)") + #endif + + if data.bool(for: "done") == true { + #if DEBUG + print("DEBUG downloadFile: server signaled done") + #endif + break + } + } + + guard !allData.isEmpty else { + #if DEBUG + print("DEBUG downloadFile: no data downloaded for fileId=\(fileId)") + #endif + return nil + } + + // Decrypt: allData = ciphertext + tag(16) + guard allData.count >= 16 else { + #if DEBUG + print("DEBUG downloadFile: data too short (\(allData.count) bytes) for fileId=\(fileId)") + #endif + return nil + } + let ct = allData.prefix(allData.count - 16) + let tag = allData.suffix(16) + #if DEBUG + print("DEBUG downloadFile: decrypting \(ct.count) bytes ciphertext + 16 bytes tag") + #endif + do { + let result = try CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: Data(ct), tag: Data(tag)) + #if DEBUG + print("DEBUG downloadFile: decryption success, \(result.count) bytes") + #endif + return result + } catch { + #if DEBUG + print("DEBUG downloadFile: decryption FAILED: \(error)") + #endif + return nil + } + } + + // MARK: - Devices + + func listDevices() async -> [[String: Any]] { + let resp = await sendAndReceive(type: "list_devices") + guard resp.string(for: "status") == "ok", + let data = resp.dict(for: "data") else { return [] } + return data["devices"] as? [[String: Any]] ?? [] + } + + func removeDevice(deviceIdToRemove: String) async -> Bool { + let resp = await sendAndReceive(type: "remove_device", params: [ + "device_id": deviceIdToRemove, + ]) + return resp.string(for: "status") == "ok" + } + + // MARK: - Session Reset + + func resetSession(peerUserId: String, peerDeviceId: String? = nil) async { + if let peerDeviceId = peerDeviceId { + let sessionKey = "\(peerUserId):\(peerDeviceId)" + sessions.removeValue(forKey: sessionKey) + KeyStorage.deleteSession(email: email, peerUserId: peerUserId, peerDeviceId: peerDeviceId) + } else { + // Delete all sessions for this user + for key in sessions.keys where key.hasPrefix(peerUserId) { + sessions.removeValue(forKey: key) + } + KeyStorage.deleteSession(email: email, peerUserId: peerUserId) + } + + _ = await sendAndReceive(type: "session_reset", params: [ + "peer_user_id": peerUserId, + "peer_device_id": peerDeviceId ?? "", + ]) + } + + func handleSessionResetNotification(fromUserId: String, fromDeviceId: String?) { + if let deviceId = fromDeviceId { + let sessionKey = "\(fromUserId):\(deviceId)" + sessions.removeValue(forKey: sessionKey) + KeyStorage.deleteSession(email: email, peerUserId: fromUserId, peerDeviceId: deviceId) + } else { + for key in sessions.keys where key.hasPrefix(fromUserId) { + sessions.removeValue(forKey: key) + } + KeyStorage.deleteSession(email: email, peerUserId: fromUserId) + } + } + + // MARK: - Search + + func searchMessages(convId: String, query: String) -> [[String: Any]] { + MessageCache.search(email: email, convId: convId, query: query, cacheKey: cacheKey) + } + + // MARK: - Change Username + + func changeUsername(newUsername: String) async -> (success: Bool, message: String) { + let trimmed = newUsername.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 100 else { + return (false, "Username must be 1-100 characters.") + } + let resp = await sendAndReceive(type: "change_username", params: ["username": trimmed]) + if resp.string(for: "status") == "ok" { + if let data = resp.dict(for: "data"), let newName = data.string(for: "username") { + username = newName + } else { + username = trimmed + } + return (true, "Username changed.") + } + let msg = resp.dict(for: "data")?.string(for: "message") ?? "Unknown error" + return (false, msg) + } + + // MARK: - Change Password + + func changePassword(oldPassword: String, newPassword: String) -> (success: Bool, message: String) { + guard !email.isEmpty else { + return (false, "Not logged in.") + } + + let oldPwd = Data(oldPassword.utf8) + let newPwd = Data(newPassword.utf8) + + // 1. Verify old password by loading keys + let (rsaPriv, rsaPub, err) = KeyStorage.loadRSAKeys(email: email, password: oldPwd) + guard let rsaPriv = rsaPriv, let rsaPub = rsaPub else { + return (false, err ?? "Wrong current password.") + } + + let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: oldPwd) + guard let edPriv = edPriv, let edPub = edPub else { + return (false, "Failed to load identity key.") + } + + // 2. Re-save with new password + do { + try KeyStorage.saveRSAKeys(email: email, privateKey: rsaPriv, publicKey: rsaPub, password: newPwd) + try KeyStorage.saveIdentityKeys(email: email, privateKey: edPriv, publicKey: edPub, password: newPwd) + return (true, "Password changed successfully.") + } catch { + return (false, "Failed to save keys: \(error.localizedDescription)") + } + } + + // MARK: - Confirm Delivery + + func confirmDelivery(convId: String, messageIds: [String]) async { + guard !messageIds.isEmpty else { return } + // Fire-and-forget, non-critical + _ = await sendAndReceive(type: "confirm_delivery", params: [ + "conversation_id": convId, + "message_ids": messageIds, + ]) + } + + // MARK: - Forward Message + + func forwardMessage(targetConvId: String, originalMsg: [String: Any], + targetMembers: [ConversationMember]) async -> (success: Bool, message: String, sentMessage: Message?) { + let text = originalMsg["text"] as? String ?? "" + + var payload: [String: Any] = [ + "sender": username, + "text": text, + "forwarded_from": [ + "sender": originalMsg["sender"] as? String ?? "", + "conversation_id": originalMsg["conversation_id"] as? String ?? "", + "message_id": originalMsg["message_id"] as? String ?? "", + ] as [String: Any], + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + + // Forward image/file metadata (the encrypted blob is already on the server) + if let image = originalMsg["image"] { + payload["image"] = image + if text.isEmpty { payload["text"] = "" } + } + if let file = originalMsg["file"] { + payload["file"] = file + if text.isEmpty { payload["text"] = "" } + } + + var extraPayload: [String: Any] = [:] + if let fwd = payload["forwarded_from"] { + extraPayload["forwarded_from"] = fwd + } + if let image = payload["image"] { + extraPayload["image"] = image + } + if let file = payload["file"] { + extraPayload["file"] = file + } + + return await sendMessage( + convId: targetConvId, text: text, members: targetMembers, + extraPayload: extraPayload + ) + } + + // MARK: - Proof of Work + + static func solvePow(challenge: String, difficulty: Int) -> String { + let targetBytes = difficulty / 8 + let targetBits = difficulty % 8 + let mask: UInt8 = targetBits > 0 ? UInt8((0xFF << (8 - targetBits)) & 0xFF) : 0 + + var nonce: UInt64 = 0 + while true { + let input = "\(challenge)\(nonce)" + let inputData = Data(input.utf8) + let digest = SHA256.hash(data: inputData) + let digestBytes = Array(digest) + + var ok = true + for i in 0.. 0 { + if digestBytes[targetBytes] & mask != 0 { + ok = false + } + } + if ok { + return String(nonce) + } + nonce += 1 + } + } + + // MARK: - TOFU / Contact Verification + + /// Load TOFU and verification stores from disk + private func loadVerificationStores() { + guard !email.isEmpty else { return } + knownIdentityKeys = KeyStorage.loadKnownIdentityKeys(email: email, localKey: localKey) + verifiedContacts = KeyStorage.loadVerifiedContacts(email: email, localKey: localKey) + } + + /// Check a user's identity key against TOFU registry + func checkIdentityKey(userId: String, identityKeyBytes: Data) -> String { + let ikHex = identityKeyBytes.hexString + let now = ISO8601DateFormatter().string(from: Date()) + let known = knownIdentityKeys[userId] + + if known == nil { + // TOFU: trust on first use + knownIdentityKeys[userId] = [ + "identity_key": ikHex, + "first_seen": now, + "last_seen": now, + ] + try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) + return "new" + } + + if known?["identity_key"] == ikHex { + knownIdentityKeys[userId]?["last_seen"] = now + try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) + let verified = verifiedContacts[userId] + if verified != nil && verified?["identity_key"] == ikHex { + return "verified" + } + return "trusted" + } + + // Key has CHANGED + let wasVerified = verifiedContacts[userId] != nil + return wasVerified ? "changed_verified" : "changed" + } + + /// Mark a contact's identity key as explicitly verified + func verifyContact(userId: String, identityKey: Data, method: String = "manual") { + let ikHex = identityKey.hexString + let now = ISO8601DateFormatter().string(from: Date()) + verifiedContacts[userId] = [ + "identity_key": ikHex, + "verified_at": now, + "method": method, + ] + if knownIdentityKeys[userId] == nil { + knownIdentityKeys[userId] = [ + "identity_key": ikHex, + "first_seen": now, + "last_seen": now, + ] + } else { + knownIdentityKeys[userId]?["last_seen"] = now + } + try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey) + try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) + } + + /// Remove explicit verification for a contact + func unverifyContact(userId: String) { + verifiedContacts.removeValue(forKey: userId) + try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey) + } + + /// Accept a changed identity key + func acceptKeyChange(userId: String, newIdentityKey: Data) { + let ikHex = newIdentityKey.hexString + let now = ISO8601DateFormatter().string(from: Date()) + knownIdentityKeys[userId] = [ + "identity_key": ikHex, + "first_seen": now, + "last_seen": now, + ] + verifiedContacts.removeValue(forKey: userId) + try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) + try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey) + } + + /// Get verification status for a user + func getVerificationStatus(userId: String) -> String { + if let verified = verifiedContacts[userId] { + let known = knownIdentityKeys[userId] + if known?["identity_key"] == verified["identity_key"] { + return "verified" + } + } + if knownIdentityKeys[userId] != nil { + return "trusted" + } + return "unverified" + } + + /// Get formatted safety number for a peer + func getSafetyNumber(peerUserId: String) -> String? { + guard let idPub = identityPublic, let myId = userId else { return nil } + let myIK = idPub.rawData + guard let peerIK = getPeerIdentityKeySync(userId: peerUserId) else { return nil } + return ContactVerification.computeSafetyNumber( + myUserId: myId, myIdentityKey: myIK, + theirUserId: peerUserId, theirIdentityKey: peerIK + ) + } + + /// Get formatted fingerprint for own identity key + func getMyFingerprint() -> String? { + guard let idPub = identityPublic, let myId = userId else { return nil } + let fp = ContactVerification.computeFingerprint(userId: myId, identityKey: idPub.rawData) + return ContactVerification.formatFingerprint(fp) + } + + /// Get formatted fingerprint for a peer's identity key + func getPeerFingerprint(peerUserId: String) -> String? { + guard let peerIK = getPeerIdentityKeySync(userId: peerUserId) else { return nil } + let fp = ContactVerification.computeFingerprint(userId: peerUserId, identityKey: peerIK) + return ContactVerification.formatFingerprint(fp) + } + + /// Get QR code payload bytes for own identity + func getVerificationQRData() -> Data? { + guard let idPub = identityPublic, let myId = userId else { return nil } + return ContactVerification.encodeVerificationQR(userId: myId, identityKey: idPub.rawData) + } + + /// Verify a scanned QR code against known identity keys + func verifyQRCode(qrData: Data) -> (success: Bool, userId: String, message: String) { + let decoded: (userId: String, identityKey: Data) + do { + decoded = try ContactVerification.decodeVerificationQR(qrData) + } catch { + return (false, "", "Invalid QR code: \(error.localizedDescription)") + } + guard let peerIK = getPeerIdentityKeySync(userId: decoded.userId) else { + return (false, decoded.userId, "Unknown user — not in your contacts.") + } + guard peerIK == decoded.identityKey else { + return (false, decoded.userId, "Identity key MISMATCH — verification failed!") + } + verifyContact(userId: decoded.userId, identityKey: decoded.identityKey, method: "qr_code") + let name = userCache[decoded.userId]?.username ?? String(decoded.userId.prefix(8)) + return (true, decoded.userId, "Verified \(name) via QR code.") + } + + /// Get a peer's identity key bytes (from cache, TOFU registry, or server) + func getPeerIdentityKey(userId: String) async -> Data? { + // 1. Check local caches first + if let ik = getPeerIdentityKeySync(userId: userId) { + return ik + } + // 2. Fetch from server + if let user = await getUserInfo(userId: userId) { + return user.identityKey + } + return nil + } + + /// Sync lookup: user cache → TOFU registry + private func getPeerIdentityKeySync(userId: String) -> Data? { + if let ik = userCache[userId]?.identityKey { + return ik + } + if let ikHex = knownIdentityKeys[userId]?["identity_key"], + let ikData = Data(hexString: ikHex) { + return ikData + } + return nil + } + + // MARK: - Brute-Force Lockout + + static func checkLockout(email: String) -> TimeInterval { + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return 0 } + let path = dir.appendingPathComponent("login_lockout.json") + guard let data = try? Data(contentsOf: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let lockedUntil = json["locked_until"] as? TimeInterval else { return 0 } + return max(0, lockedUntil - Date().timeIntervalSince1970) + } + + static func recordFailedAttempt(email: String) { + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } + let path = dir.appendingPathComponent("login_lockout.json") + var failed = 0 + if let data = try? Data(contentsOf: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + failed = json["failed_attempts"] as? Int ?? 0 + } + failed += 1 + let delay = min(pow(lockoutBaseSeconds, Double(failed)), lockoutMaxSeconds) + let lockedUntil = Date().timeIntervalSince1970 + delay + let json: [String: Any] = ["failed_attempts": failed, "locked_until": lockedUntil] + if let data = try? JSONSerialization.data(withJSONObject: json) { + try? data.write(to: path, options: .completeFileProtection) + } + } + + static func clearLockout(email: String) { + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } + let path = dir.appendingPathComponent("login_lockout.json") + try? FileManager.default.removeItem(at: path) + } +} diff --git a/ios_client 0.8.5/Kecalek/Core/KeyStorage.swift b/ios_client 0.8.5/Kecalek/Core/KeyStorage.swift new file mode 100644 index 0000000..dbbd3c9 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Core/KeyStorage.swift @@ -0,0 +1,485 @@ +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 writeProtected(pubData, 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 writeProtected(pubData, 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 writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("spk_id.txt")) + } + + 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(data: Data(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 writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("prev_spk_id.txt")) + } + + 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(data: Data(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" + } + + let exported = try ratchet.exportState() + guard let localKey = localKey else { + throw CryptoError.encryptionFailed("localKey required for session storage") + } + let data = try CryptoUtils.encryptLocal(exported, 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) + } + // Migration: try plaintext, immediately re-encrypt + if let ratchet = try? DoubleRatchet.importState(raw) { + try? writeProtected(CryptoUtils.encryptLocal(try ratchet.exportState(), key: localKey), to: path) + return ratchet + } + // Corrupted — delete + try? FileManager.default.removeItem(at: path) + return nil + } + + // No localKey — refuse to load plaintext sessions + return nil + } + + // 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) + + guard let localKey = localKey else { + throw CryptoError.encryptionFailed("localKey required for sender key storage") + } + let data = try CryptoUtils.encryptLocal(state.exportState(), 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) + } + // Migration: try plaintext, immediately re-encrypt + if let state = try? SenderKeyState.importState(raw) { + try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path) + return state + } + try? FileManager.default.removeItem(at: path) + return nil + } + + return nil + } + + 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) + + guard let localKey = localKey else { + throw CryptoError.encryptionFailed("localKey required for sender key storage") + } + let data = try CryptoUtils.encryptLocal(state.exportState(), 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) + } + // Migration: try plaintext, immediately re-encrypt + if let state = try? SenderKeyState.importState(raw) { + try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path) + return state + } + try? FileManager.default.removeItem(at: path) + return nil + } + + return nil + } + + 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, localKey: Data? = nil) throws { + let dir = try getKeyDir(email: email) + let jsonData = try JSONSerialization.data(withJSONObject: Array(favorites)) + let dataToWrite: Data + if let localKey = localKey { + dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: localKey) + } else { + dataToWrite = jsonData + } + try writeProtected(dataToWrite, to: dir.appendingPathComponent("favorites.json")) + } + + static func loadFavorites(email: String, localKey: Data? = nil) -> Set { + guard let dir = try? getKeyDir(email: email) else { return [] } + let path = dir.appendingPathComponent("favorites.json") + guard let raw = try? Data(contentsOf: path) else { return [] } + let jsonData: Data + if let localKey = localKey { + if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) { + jsonData = decrypted + } else { + jsonData = raw // migration fallback + } + } else { + jsonData = raw + } + guard let array = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else { + return [] + } + return Set(array) + } + + // MARK: - TOFU Identity Key Registry + + static func saveKnownIdentityKeys(email: String, keys: [String: [String: String]], localKey: Data?) throws { + let dir = try getKeyDir(email: email) + let jsonObj: [String: Any] = ["version": 1, "keys": keys] + let jsonData = try JSONSerialization.data(withJSONObject: jsonObj) + guard let localKey = localKey else { + try writeProtected(jsonData, to: dir.appendingPathComponent("known_identity_keys.bin")) + return + } + let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey) + try writeProtected(encrypted, to: dir.appendingPathComponent("known_identity_keys.bin")) + } + + static func loadKnownIdentityKeys(email: String, localKey: Data?) -> [String: [String: String]] { + guard let dir = try? getKeyDir(email: email) else { return [:] } + let path = dir.appendingPathComponent("known_identity_keys.bin") + guard let raw = try? Data(contentsOf: path) else { return [:] } + do { + let jsonData: Data + if let localKey = localKey { + jsonData = try CryptoUtils.decryptLocal(raw, key: localKey) + } else { + jsonData = raw + } + guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let keys = obj["keys"] as? [String: [String: String]] else { return [:] } + return keys + } catch { + return [:] + } + } + + // MARK: - Verified Contacts + + static func saveVerifiedContacts(email: String, contacts: [String: [String: String]], localKey: Data?) throws { + let dir = try getKeyDir(email: email) + let jsonObj: [String: Any] = ["version": 1, "contacts": contacts] + let jsonData = try JSONSerialization.data(withJSONObject: jsonObj) + guard let localKey = localKey else { + try writeProtected(jsonData, to: dir.appendingPathComponent("verified_contacts.bin")) + return + } + let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey) + try writeProtected(encrypted, to: dir.appendingPathComponent("verified_contacts.bin")) + } + + static func loadVerifiedContacts(email: String, localKey: Data?) -> [String: [String: String]] { + guard let dir = try? getKeyDir(email: email) else { return [:] } + let path = dir.appendingPathComponent("verified_contacts.bin") + guard let raw = try? Data(contentsOf: path) else { return [:] } + do { + let jsonData: Data + if let localKey = localKey { + jsonData = try CryptoUtils.decryptLocal(raw, key: localKey) + } else { + jsonData = raw + } + guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let contacts = obj["contacts"] as? [String: [String: String]] else { return [:] } + return contacts + } catch { + return [:] + } + } + + // MARK: - Helpers + + private static func writeProtected(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .completeFileProtection) + } +} diff --git a/ios_client 0.8.5/Kecalek/Core/KeychainService.swift b/ios_client 0.8.5/Kecalek/Core/KeychainService.swift new file mode 100644 index 0000000..b5e78fb --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Core/KeychainService.swift @@ -0,0 +1,132 @@ +import Foundation +import Security +import LocalAuthentication + +enum KeychainService { + private static let service = "com.encryptedchat.credentials" + private static let account = "userCredentials" + + struct Credentials: Codable { + let email: String + let password: String + let host: String + let port: UInt16 + } + + /// Check if saved credentials exist without triggering biometric prompt. + static func hasSavedCredentials() -> Bool { + let context = LAContext() + context.interactionNotAllowed = true + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecUseAuthenticationContext as String: context + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + // errSecInteractionNotAllowed means item exists but needs biometric + return status == errSecSuccess || status == errSecInteractionNotAllowed + } + + /// Save credentials to Keychain with biometric protection. + static func saveCredentials(email: String, password: String, host: String, port: UInt16) throws { + // Delete any existing entry first + deleteCredentials() + + let credentials = Credentials(email: email, password: password, host: host, port: port) + let data = try JSONEncoder().encode(credentials) + + var accessError: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + .biometryAny, + &accessError + ) else { + throw KeychainError.accessControlCreationFailed + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessControl as String: accessControl + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + /// Load credentials from Keychain. Triggers biometric prompt. + static func loadCredentials() throws -> Credentials { + let context = LAContext() + context.localizedReason = "Unlock to log in" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let data = result as? Data else { + if status == errSecUserCanceled || status == errSecAuthFailed { + throw KeychainError.biometricFailed + } + throw KeychainError.loadFailed(status) + } + + return try JSONDecoder().decode(Credentials.self, from: data) + } + + /// Delete stored credentials from Keychain. + @discardableResult + static func deleteCredentials() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + /// Check if biometric authentication is available on this device. + static func isBiometricAvailable() -> Bool { + let context = LAContext() + var error: NSError? + return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + } + + enum KeychainError: LocalizedError { + case accessControlCreationFailed + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case biometricFailed + + var errorDescription: String? { + switch self { + case .accessControlCreationFailed: + return "Failed to create biometric access control" + case .saveFailed(let status): + return "Failed to save credentials (error \(status))" + case .loadFailed(let status): + return "Failed to load credentials (error \(status))" + case .biometricFailed: + return "Biometric authentication failed or was cancelled" + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Core/MessageCache.swift b/ios_client 0.8.5/Kecalek/Core/MessageCache.swift new file mode 100644 index 0000000..792d9ab --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Core/MessageCache.swift @@ -0,0 +1,200 @@ +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) + + guard let cacheKey = cacheKey else { + return // Refuse to save plaintext message cache + } + let dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey) + 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 if let parsed = try? JSONSerialization.jsonObject(with: raw) as? [[String: Any]] { + // Migration: re-encrypt plaintext cache and return + try? save(email: email, convId: convId, messages: parsed, cacheKey: cacheKey) + return parsed + } else { + // Corrupted — delete stale cache + try? FileManager.default.removeItem(at: path) + return nil + } + } 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) + } + + // MARK: - Per-Message Cache (for Double Ratchet - messages can only be decrypted once) + + /// Cache a decrypted message by its ID + static func cacheDecryptedMessage(email: String, convId: String, messageId: String, plaintext: Data, cacheKey: Data?) { + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } + let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId) + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + let path = cacheDir.appendingPathComponent("\(messageId).bin") + do { + guard let cacheKey = cacheKey else { return } // Refuse plaintext + let dataToWrite = try CryptoUtils.encryptLocal(plaintext, key: cacheKey) + try dataToWrite.write(to: path, options: .completeFileProtection) + } catch { + #if DEBUG + print("DEBUG MessageCache: failed to cache message \(messageId): \(error)") + #endif + } + } + + /// Load all cached decrypted messages for a conversation. + /// Returns array of (messageId, plaintext) tuples. + static func loadAllCachedMessages(email: String, convId: String, cacheKey: Data?) -> [(String, Data)] { + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [] } + let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId) + guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [] } + + var result: [(String, Data)] = [] + for file in files where file.pathExtension == "bin" { + let messageId = file.deletingPathExtension().lastPathComponent + guard let raw = try? Data(contentsOf: file) else { continue } + if let cacheKey = cacheKey, + let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) { + result.append((messageId, decrypted)) + } else if cacheKey == nil { + result.append((messageId, raw)) + } + } + return result + } + + /// Get a cached decrypted message by ID + static func getCachedMessage(email: String, convId: String, messageId: String, cacheKey: Data?) -> Data? { + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } + let path = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId).appendingPathComponent("\(messageId).bin") + guard let raw = try? Data(contentsOf: path) else { return nil } + + if let cacheKey = cacheKey { + if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) { + return decrypted + } + // Migration: try as plaintext, re-encrypt + if let _ = try? JSONSerialization.jsonObject(with: raw) { + cacheDecryptedMessage(email: email, convId: convId, messageId: messageId, plaintext: raw, cacheKey: cacheKey) + return raw + } + // Corrupted — delete + try? FileManager.default.removeItem(at: path) + return nil + } + return raw + } + + // MARK: - Conversation List Cache + + /// Save conversation list to disk (encrypted with local key) + static func saveConversations(email: String, conversations: [Conversation], cacheKey: Data?) { + guard let cacheKey = cacheKey else { return } + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } + do { + let jsonData = try JSONEncoder().encode(conversations) + let encrypted = try CryptoUtils.encryptLocal(jsonData, key: cacheKey) + try encrypted.write(to: dir.appendingPathComponent("conversation_cache.json"), options: .completeFileProtection) + } catch { + #if DEBUG + print("DEBUG MessageCache: failed to save conversations: \(error)") + #endif + } + } + + /// Load conversation list from disk + static func loadConversations(email: String, cacheKey: Data?) -> [Conversation]? { + guard let cacheKey = cacheKey else { return nil } + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } + let path = dir.appendingPathComponent("conversation_cache.json") + guard let raw = try? Data(contentsOf: path) else { return nil } + guard let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { return nil } + return try? JSONDecoder().decode([Conversation].self, from: decrypted) + } + + // MARK: - Avatar Disk Cache + + /// Save avatar data to disk (encrypted with local key) + static func saveAvatar(email: String, key: String, data: Data, cacheKey: Data?) { + guard let cacheKey = cacheKey else { return } + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } + let cacheDir = dir.appendingPathComponent("avatar_cache") + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + do { + let encrypted = try CryptoUtils.encryptLocal(data, key: cacheKey) + try encrypted.write(to: cacheDir.appendingPathComponent("\(key).dat"), options: .completeFileProtection) + } catch { + #if DEBUG + print("DEBUG MessageCache: failed to save avatar \(key): \(error)") + #endif + } + } + + /// Load avatar data from disk + static func loadAvatar(email: String, key: String, cacheKey: Data?) -> Data? { + guard let cacheKey = cacheKey else { return nil } + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil } + let path = dir.appendingPathComponent("avatar_cache").appendingPathComponent("\(key).dat") + guard let raw = try? Data(contentsOf: path) else { return nil } + return try? CryptoUtils.decryptLocal(raw, key: cacheKey) + } + + /// Load all cached avatars from disk + static func loadAllAvatars(email: String, cacheKey: Data?) -> [String: Data] { + guard let cacheKey = cacheKey else { return [:] } + guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [:] } + let cacheDir = dir.appendingPathComponent("avatar_cache") + guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [:] } + var result: [String: Data] = [:] + for file in files where file.pathExtension == "dat" { + let key = file.deletingPathExtension().lastPathComponent + guard let raw = try? Data(contentsOf: file), + let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { continue } + result[key] = decrypted + } + return result + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift b/ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift new file mode 100644 index 0000000..65ca40b --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift @@ -0,0 +1,162 @@ +import Foundation +import CryptoKit + +/// Contact key verification: fingerprints, safety numbers, QR codes. +/// Matches Python: crypto_utils.py compute_fingerprint, compute_safety_number, etc. +enum ContactVerification { + + /// Version byte for fingerprint computation (Signal's NumericFingerprint). + private static let fingerprintVersion: UInt16 = 0 + + /// Number of SHA-512 iterations for fingerprint computation. + private static let fingerprintIterations = 5200 + + // MARK: - Fingerprint + + /// Compute a 32-byte fingerprint for a user's identity key. + /// + /// Uses iterated SHA-512 (Signal's NumericFingerprint algorithm). + /// Seed: version(2B big-endian) + identity_key(32B) + user_id(UTF-8). + /// Each iteration: SHA-512(previous_hash + identity_key). + /// Output: first 32 bytes of final hash. + static func computeFingerprint(userId: String, identityKey: Data, iterations: Int = fingerprintIterations) -> Data { + let versionBytes = fingerprintVersion.bigEndianData + var data = versionBytes + identityKey + Data(userId.utf8) + for _ in 0.. String { + var groups: [String] = [] + for i in 0..<6 { + let start = i * 5 + let end = min(start + 5, fpBytes.count) + let slice = fpBytes[fpBytes.startIndex + start ..< fpBytes.startIndex + end] + let num = bigEndianUInt64(slice) % 100000 + groups.append(String(format: "%05d", num)) + } + return groups[0..<3].joined(separator: " ") + "\n" + groups[3..<6].joined(separator: " ") + } + + // MARK: - Safety Number + + /// 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. + static func computeSafetyNumber( + myUserId: String, myIdentityKey: Data, + theirUserId: String, theirIdentityKey: Data + ) -> String { + let fpMine = computeFingerprint(userId: myUserId, identityKey: myIdentityKey) + let fpTheirs = computeFingerprint(userId: theirUserId, identityKey: theirIdentityKey) + + let combined: Data + if myUserId < theirUserId { + combined = fpMine + fpTheirs + } else { + combined = fpTheirs + fpMine + } + + // 64 bytes -> 12 groups of 5 digits + var groups: [String] = [] + for i in 0..<12 { + let start = i * 5 + let end = min(start + 5, combined.count) + let slice = combined[combined.startIndex + start ..< combined.startIndex + end] + let num = bigEndianUInt64(slice) % 100000 + groups.append(String(format: "%05d", num)) + } + + return [ + groups[0..<4].joined(separator: " "), + groups[4..<8].joined(separator: " "), + groups[8..<12].joined(separator: " "), + ].joined(separator: "\n") + } + + // MARK: - QR Code + + /// Encode user identity for QR code verification. + /// + /// Format: version(1B=0x01) + uid_len(1B) + uid(UTF-8) + identity_key(32B). + static func encodeVerificationQR(userId: String, identityKey: Data) -> Data { + let uidBytes = Data(userId.utf8) + var data = Data([0x01, UInt8(uidBytes.count)]) + data.append(uidBytes) + data.append(identityKey) + return data + } + + /// Decode QR code verification payload. + /// + /// Returns (userId, identityKey). + /// Throws on invalid format. + static func decodeVerificationQR(_ data: Data) throws -> (userId: String, identityKey: Data) { + guard data.count >= 3 else { + throw VerificationError.qrDataTooShort + } + guard data[data.startIndex] == 0x01 else { + throw VerificationError.unknownQRVersion(data[data.startIndex]) + } + let uidLen = Int(data[data.startIndex + 1]) + guard data.count >= 2 + uidLen + 32 else { + throw VerificationError.qrDataTruncated + } + let uidData = data[data.startIndex + 2 ..< data.startIndex + 2 + uidLen] + guard let userId = String(data: uidData, encoding: .utf8) else { + throw VerificationError.invalidUTF8 + } + let identityKey = Data(data[data.startIndex + 2 + uidLen ..< data.startIndex + 2 + uidLen + 32]) + return (userId, identityKey) + } + + // MARK: - Helpers + + /// Convert up to 8 bytes to UInt64, big-endian. + private static func bigEndianUInt64(_ data: Data) -> UInt64 { + var result: UInt64 = 0 + for byte in data { + result = result << 8 | UInt64(byte) + } + return result + } +} + +// MARK: - UInt16 Big-Endian + +private extension UInt16 { + var bigEndianData: Data { + var value = self.bigEndian + return Data(bytes: &value, count: 2) + } +} + +// MARK: - Verification Errors + +enum VerificationError: Error, LocalizedError { + case qrDataTooShort + case unknownQRVersion(UInt8) + case qrDataTruncated + case invalidUTF8 + + var errorDescription: String? { + switch self { + case .qrDataTooShort: return "QR data too short" + case .unknownQRVersion(let v): return "Unknown QR version: \(v)" + case .qrDataTruncated: return "QR data truncated" + case .invalidUTF8: return "Invalid UTF-8 in QR data" + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift b/ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift new file mode 100644 index 0000000..bf45320 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift @@ -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)" + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift b/ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift new file mode 100644 index 0000000..60e5abb --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift @@ -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.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.authenticationCode(for: Data([0x01]), using: symmetricKey)) + let newChainKey = Data(HMAC.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") + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift b/ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift new file mode 100644 index 0000000..7609498 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift @@ -0,0 +1,393 @@ +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() + /// IMPORTANT: Must match Python's json.dumps() format exactly (with spaces after : and ,) + func serialize() -> Data { + // Python json.dumps produces: {"dh_pub": "...", "n": 0, "pn": 0} + // Note the spaces after colons and commas - this is critical for AAD matching + let jsonString = "{\"dh_pub\": \"\(dhPub.hexString)\", \"n\": \(n), \"pn\": \(pn)}" + return jsonString.data(using: .utf8)! + } + + /// 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 + + // Debug: print ratchet inputs (matching Python _dh_ratchet) + #if DEBUG + print("DEBUG initAlice: shared_secret (root_key) = \(sharedSecret.hexString)") + print("DEBUG initAlice: my_dh_pub = \(X25519Crypto.serializePublic(pub).hexString)") + print("DEBUG initAlice: remote_dh_pub (bob_spk) = \(X25519Crypto.serializePublic(bobSpkPub).hexString)") + #endif + + // Perform DH ratchet to derive send chain + let dhOutput = try X25519Crypto.dh(priv, bobSpkPub) + let (newRK, sendCK) = CryptoUtils.kdfRK(rootKey: sharedSecret, dhOutput: dhOutput) + #if DEBUG + print("DEBUG initAlice: dh_output = \(dhOutput.hexString)") + print("DEBUG initAlice: new_root_key = \(newRK.hexString)") + print("DEBUG initAlice: send_chain_key = \(sendCK.hexString)") + #endif + 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() + + // Debug: print encrypt values (matching Python decrypt) + #if DEBUG + print("DEBUG encrypt: message_key = \(messageKey.hexString)") + print("DEBUG encrypt: aad = \(aad.hexString)") + print("DEBUG encrypt: aad_str = \(String(data: aad, encoding: .utf8) ?? "nil")") + print("DEBUG encrypt: nonce = \(nonce.hexString)") + #endif + + let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad) + #if DEBUG + print("DEBUG encrypt: ciphertext_len = \(ctWithTag.count)") + #endif + + 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 + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift b/ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift new file mode 100644 index 0000000..a71ad96 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift @@ -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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift b/ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift new file mode 100644 index 0000000..c1f6ead --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift @@ -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) + let 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..>= 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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift b/ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift new file mode 100644 index 0000000..16e8dd2 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift @@ -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 + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift b/ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift new file mode 100644 index 0000000..6e06c4f --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Message padding for metadata privacy — hides plaintext length. +/// Matches Python: crypto_utils.py pad_plaintext / unpad_plaintext +enum MessagePadding { + + /// Magic byte prefix to distinguish padded from legacy unpadded messages. + private static let padMagic: UInt8 = 0x01 + + /// Bucket sizes for length hiding (64B to 64KB). + private static let padBuckets = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536] + + /// 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 '{'). + static func pad(_ plaintext: Data) -> Data { + var content = Data([padMagic]) + content.append(plaintext) + + // +4 for the length suffix + let minSize = content.count + 4 + let target = padBuckets.first(where: { $0 >= minSize }) ?? minSize + let padLen = target - content.count + + // random_padding (padLen - 4 bytes) + pad_length (4 bytes big-endian) + var result = content + result.append(Data.randomBytes(padLen - 4)) + result.append(UInt32(padLen).bigEndianData) + return result + } + + /// Remove padding. Returns raw plaintext for both padded and legacy unpadded messages. + static func unpad(_ data: Data) -> Data { + guard !data.isEmpty else { return data } + + // Legacy unpadded message (starts with '{' for JSON) + guard data[data.startIndex] == padMagic else { return data } + + // Too short to be validly padded (magic + at least 4 bytes for length) + guard data.count >= 5 else { return data } + + // Read pad_length from last 4 bytes (big-endian UInt32) + let padLenOffset = data.count - 4 + let padLen = data.withUnsafeBytes { ptr -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dest in + dest.copyBytes(from: UnsafeRawBufferPointer(rebasing: ptr[padLenOffset...])) + } + return UInt32(bigEndian: value) + } + + // Validate padding metadata + guard padLen >= 4, padLen <= data.count - 1 else { return data } + + // Strip: skip magic byte (index 0), take up to (data.count - padLen) + return data[data.startIndex + 1 ..< data.startIndex + data.count - Int(padLen)] + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift b/ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift new file mode 100644 index 0000000..2e23c07 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift @@ -0,0 +1,356 @@ +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? + 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? + 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? + 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? + 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? + 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? + 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: - RSA-OAEP Encrypt / Decrypt (for device pairing) + + /// Encrypt data with RSA-OAEP SHA-256 using a public key + static func encrypt(_ publicKey: SecKey, plaintext: Data) throws -> Data { + var error: Unmanaged? + guard let encrypted = SecKeyCreateEncryptedData( + publicKey, + .rsaEncryptionOAEPSHA256, + plaintext as CFData, + &error + ) as Data? else { + throw CryptoError.rsaOperationFailed("RSA-OAEP encryption failed") + } + return encrypted + } + + /// Decrypt data with RSA-OAEP SHA-256 using a private key + static func decrypt(_ privateKey: SecKey, ciphertext: Data) throws -> Data { + var error: Unmanaged? + guard let decrypted = SecKeyCreateDecryptedData( + privateKey, + .rsaEncryptionOAEPSHA256, + ciphertext as CFData, + &error + ) as Data? else { + throw CryptoError.rsaOperationFailed("RSA-OAEP decryption failed") + } + return decrypted + } + + /// Generate RSA-2048 keypair (for pairing temp keys — smaller for OAEP payload) + static func generateKeypair2048() throws -> (privateKey: SecKey, publicKey: SecKey) { + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + ] + + var error: Unmanaged? + 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: - 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.. 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.. Int { + var off = offset + _ = readASN1Length(data, offset: &off) + return off + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift b/ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift new file mode 100644 index 0000000..63cc53d --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift @@ -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 + ) + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift b/ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift new file mode 100644 index 0000000..5431489 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift @@ -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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Crypto/X3DH.swift b/ios_client 0.8.5/Kecalek/Crypto/X3DH.swift new file mode 100644 index 0000000..31202fa --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Crypto/X3DH.swift @@ -0,0 +1,139 @@ +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.. (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() + + // Debug: print key inputs (matching Python x3dh_respond) + #if DEBUG + print("DEBUG x3dh_initiate: ik_remote_ed = \(Ed25519Crypto.serializePublic(ikPublicRemoteEd).hexString)") + print("DEBUG x3dh_initiate: ik_x25519_remote = \(X25519Crypto.serializePublic(ikX25519Remote).hexString)") + print("DEBUG x3dh_initiate: ek_pub = \(X25519Crypto.serializePublic(ekPub).hexString)") + print("DEBUG x3dh_initiate: spk_remote = \(spkRemoteBytes.hexString)") + #endif + + // 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 + + // Debug: print DH outputs + #if DEBUG + print("DEBUG x3dh_initiate: dh1 = \(dh1.hexString)") + print("DEBUG x3dh_initiate: dh2 = \(dh2.hexString)") + print("DEBUG x3dh_initiate: dh3 = \(dh3.hexString)") + #endif + + var dhConcat = dh1 + dh2 + dh3 + if let opk = opkRemote { + let dh4 = try X25519Crypto.dh(ekPriv, opk) // EK_A, OPK_B + #if DEBUG + print("DEBUG x3dh_initiate: dh4 = \(dh4.hexString)") + #endif + dhConcat += dh4 + } + + // Derive shared secret + let sharedSecret = CryptoUtils.hkdfDerive( + inputKey: dhConcat, + salt: Data(repeating: 0x00, count: 32), + info: Data(Constants.x3dhInfo.utf8), + length: 32 + ) + #if DEBUG + print("DEBUG x3dh_initiate: shared_secret = \(sharedSecret.hexString)") + #endif + + 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 + } +} diff --git a/ios_client 0.8.5/Kecalek/KecalekApp.swift b/ios_client 0.8.5/Kecalek/KecalekApp.swift new file mode 100644 index 0000000..95a97e8 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/KecalekApp.swift @@ -0,0 +1,47 @@ +import SwiftUI + +@main +struct KecalekApp: App { + @State private var appState = AppState() + @State private var authViewModel = AuthViewModel() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + if appState.isLoggedIn { + MainTabView(appState: appState) + } else { + LoginView(viewModel: authViewModel, appState: appState) + } + } + .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .background: + appState.handleEnteredBackground() + case .active: + appState.handleBecameActive() + default: + break + } + } + } +} + +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") + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Models/Conversation.swift b/ios_client 0.8.5/Kecalek/Models/Conversation.swift new file mode 100644 index 0000000..e179d09 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Models/Conversation.swift @@ -0,0 +1,54 @@ +import Foundation + +struct Conversation: Identifiable, Equatable, Hashable, Codable { + 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 + && lhs.name == rhs.name + && lhs.members == rhs.members + && lhs.avatarFile == rhs.avatarFile + && lhs.unreadCount == rhs.unreadCount + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +struct ConversationMember: Identifiable, Equatable, Codable { + let userId: String + var username: String + var email: String + + var id: String { userId } +} diff --git a/ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift b/ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift new file mode 100644 index 0000000..be520bb --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift @@ -0,0 +1,69 @@ +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 + /// Server uses: signed_prekey, signed_prekey_id, one_time_prekey, one_time_prekey_id (base64) + static func fromDict(_ dict: [String: Any], identityKey: Data? = nil) throws -> DeviceBundle { + guard let deviceId = dict["device_id"] as? String else { + throw ChatError.invalidData("Missing device_id") + } + + // Identity key can be passed in (from parent) or in dict + let ik: Data + if let passedIk = identityKey { + ik = passedIk + } else if let ikB64 = dict["identity_key"] as? String, + let ikData = Data(base64Encoded: ikB64) { + ik = ikData + } else { + throw ChatError.invalidData("Missing identity_key") + } + + // SPK - try both naming conventions, base64 encoded + let spkB64 = dict["signed_prekey"] as? String ?? dict["spk"] as? String + guard let spkB64 = spkB64, + let spk = Data(base64Encoded: spkB64) else { + throw ChatError.invalidData("Missing signed_prekey") + } + + // SPK signature - base64 encoded + guard let spkSigB64 = dict["spk_signature"] as? String, + let spkSig = Data(base64Encoded: spkSigB64) else { + throw ChatError.invalidData("Missing spk_signature") + } + + // SPK ID - try both naming conventions + let spkId = dict["signed_prekey_id"] as? String ?? dict["spk_id"] as? String + guard let spkId = spkId else { + throw ChatError.invalidData("Missing signed_prekey_id") + } + + // OPK - optional, base64 encoded + var opk: Data? + var opkId: String? + let opkB64 = dict["one_time_prekey"] as? String ?? dict["opk"] as? String + if let opkB64 = opkB64, let opkData = Data(base64Encoded: opkB64) { + opk = opkData + opkId = dict["one_time_prekey_id"] as? String ?? dict["opk_id"] as? String + } + + return DeviceBundle( + deviceId: deviceId, + identityKey: ik, + spk: spk, + spkSignature: spkSig, + spkId: spkId, + opk: opk, + opkId: opkId + ) + } +} diff --git a/ios_client 0.8.5/Kecalek/Models/Invitation.swift b/ios_client 0.8.5/Kecalek/Models/Invitation.swift new file mode 100644 index 0000000..6b0631c --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Models/Invitation.swift @@ -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 +} diff --git a/ios_client 0.8.5/Kecalek/Models/Message.swift b/ios_client 0.8.5/Kecalek/Models/Message.swift new file mode 100644 index 0000000..4eb18eb --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Models/Message.swift @@ -0,0 +1,210 @@ +import Foundation + +struct MessageReaction: Equatable { + let userId: String + let reaction: String + let createdAt: Date +} + +struct ForwardedFrom: Equatable { + let sender: String + let conversationId: String + let messageId: String +} + +enum ReactionEmoji { + static let allowed = ["thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"] + static let display: [String: String] = [ + "thumbsup": "👍", "heart": "❤️", "laugh": "😂", + "surprised": "😮", "sad": "😢", "thumbsdown": "👎", + ] +} + +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 image: ImageInfo? + var isDeleted: Bool + var readBy: Set + var reactions: [MessageReaction] + var forwardedFrom: ForwardedFrom? + var pinnedAt: Date? + var pinnedBy: 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 // base64 + let iv: String // base64 + let filename: String + let size: Int + let mimeType: String +} + +struct ImageInfo: Equatable { + let fileId: String + let aesKey: String // base64 + let iv: String // base64 + let thumbnail: String? // base64 JPEG thumbnail + let filename: String + let size: Int +} + +// MARK: - Cache Dictionary Conversion + +extension Message { + /// Convert to dictionary matching server JSON format for MessageCache storage + func toCacheDict() -> [String: Any] { + var dict: [String: Any] = [ + "message_id": id, + "conversation_id": conversationId, + "sender_id": senderId, + "sender_username": senderUsername, + "created_at": DateParsing.format(createdAt), + "is_deleted": isDeleted, + ] + if let text = text { dict["text"] = text } + if let replyTo = replyTo { dict["reply_to"] = replyTo } + if let imageFileId = imageFileId { dict["image_file_id"] = imageFileId } + if let file = file { + dict["file"] = [ + "file_id": file.fileId, + "aes_key": file.aesKey, + "iv": file.iv, + "filename": file.filename, + "size": file.size, + "mime_type": file.mimeType, + ] as [String: Any] + } + if let image = image { + var imgDict: [String: Any] = [ + "file_id": image.fileId, + "aes_key": image.aesKey, + "iv": image.iv, + "filename": image.filename, + "size": image.size, + ] + if let thumbnail = image.thumbnail { imgDict["thumbnail"] = thumbnail } + dict["image"] = imgDict + } + if !readBy.isEmpty { dict["read_by"] = Array(readBy) } + if !reactions.isEmpty { + dict["reactions"] = reactions.map { + ["user_id": $0.userId, "reaction": $0.reaction, + "created_at": DateParsing.format($0.createdAt)] as [String: Any] + } + } + if let fwd = forwardedFrom { + dict["forwarded_from"] = ["sender": fwd.sender, + "conversation_id": fwd.conversationId, + "message_id": fwd.messageId] as [String: Any] + } + if let pinnedAt { dict["pinned_at"] = DateParsing.format(pinnedAt) } + if let pinnedBy { dict["pinned_by"] = pinnedBy } + return dict + } + + /// Create Message from cache dictionary (server JSON format) + static func fromCacheDict(_ dict: [String: Any]) -> Message? { + guard let id = dict["message_id"] as? String, + let conversationId = dict["conversation_id"] as? String, + let senderId = dict["sender_id"] as? String, + let createdAtStr = dict["created_at"] as? String, + let createdAt = DateParsing.parse(createdAtStr) else { + return nil + } + + let senderUsername = dict["sender_username"] as? String ?? "" + + var file: FileInfo? + if let fileDict = dict["file"] as? [String: Any], + let fileId = fileDict["file_id"] as? String { + file = FileInfo( + fileId: fileId, + aesKey: fileDict["aes_key"] as? String ?? "", + iv: fileDict["iv"] as? String ?? "", + filename: fileDict["filename"] as? String ?? "", + size: fileDict["size"] as? Int ?? 0, + mimeType: fileDict["mime_type"] as? String ?? "" + ) + } + + var image: ImageInfo? + if let imgDict = dict["image"] as? [String: Any], + let imgFileId = imgDict["file_id"] as? String { + image = ImageInfo( + fileId: imgFileId, + aesKey: imgDict["aes_key"] as? String ?? "", + iv: imgDict["iv"] as? String ?? "", + thumbnail: imgDict["thumbnail"] as? String, + filename: imgDict["filename"] as? String ?? "image.jpg", + size: imgDict["size"] as? Int ?? 0 + ) + } + + let readBy: Set + if let readByArray = dict["read_by"] as? [String] { + readBy = Set(readByArray) + } else { + readBy = [] + } + + var reactions: [MessageReaction] = [] + if let reactionsArr = dict["reactions"] as? [[String: Any]] { + reactions = reactionsArr.compactMap { r in + guard let userId = r["user_id"] as? String, + let reaction = r["reaction"] as? String else { return nil } + let createdAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date() + return MessageReaction(userId: userId, reaction: reaction, createdAt: createdAt) + } + } + + var forwardedFrom: ForwardedFrom? + if let fwd = dict["forwarded_from"] as? [String: Any], + let sender = fwd["sender"] as? String { + forwardedFrom = ForwardedFrom( + sender: sender, + conversationId: fwd["conversation_id"] as? String ?? "", + messageId: fwd["message_id"] as? String ?? "" + ) + } + + let pinnedAt = (dict["pinned_at"] as? String).flatMap { DateParsing.parse($0) } + let pinnedBy = dict["pinned_by"] as? String + + return Message( + id: id, + conversationId: conversationId, + senderId: senderId, + senderUsername: senderUsername, + createdAt: createdAt, + text: dict["text"] as? String, + replyTo: dict["reply_to"] as? String, + imageFileId: dict["image_file_id"] as? String, + file: file, + image: image, + isDeleted: dict["is_deleted"] as? Bool ?? false, + readBy: readBy, + reactions: reactions, + forwardedFrom: forwardedFrom, + pinnedAt: pinnedAt, + pinnedBy: pinnedBy + ) + } +} diff --git a/ios_client 0.8.5/Kecalek/Models/User.swift b/ios_client 0.8.5/Kecalek/Models/User.swift new file mode 100644 index 0000000..0e429b3 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Models/User.swift @@ -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? +} diff --git a/ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift b/ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift new file mode 100644 index 0000000..745a71e --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift @@ -0,0 +1,191 @@ +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) 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 tlsOptions = NWProtocolTLS.Options() + let params = NWParameters(tls: tlsOptions, tcp: .init()) + + let conn = NWConnection(host: nwHost, port: nwPort, using: params) + self.connection = conn + self.receiveBuffer = Data() + + return try await withCheckedThrowingContinuation { continuation in + // nonisolated flag — accessed only from the stateUpdateHandler serial queue + // Use a class wrapper so the closure can mutate it + final class ResumedFlag: @unchecked Sendable { + var value = false + } + let resumed = ResumedFlag() + + conn.stateUpdateHandler = { [weak self] newState in + Task { [weak self] in + guard let self = self else { return } + switch newState { + case .ready: + await self.updateState(.connected) + guard !resumed.value else { return } + resumed.value = true + continuation.resume() + case .failed(let error): + await self.updateState(.failed(error.localizedDescription)) + guard !resumed.value else { return } + resumed.value = true + continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription)) + case .cancelled: + await self.updateState(.disconnected) + guard !resumed.value else { return } + resumed.value = true + continuation.resume(throwing: NetworkError.connectionFailed("Connection cancelled")) + case .waiting(let error): + await self.updateState(.failed(error.localizedDescription)) + guard !resumed.value else { return } + resumed.value = true + 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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift b/ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift new file mode 100644 index 0000000..5e1bae5 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Newline-delimited JSON protocol handler. +/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary +enum ProtocolHandler: Sendable { + + /// Build a request message (newline-terminated JSON). + /// Matches Python: build_request(msg_type, request_id=None, **kwargs) + nonisolated 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). + nonisolated 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) + nonisolated static func parseMessage(_ data: Data) throws -> [String: Any] { + let trimmed = Self.trimmingNewlines(data) + 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) + nonisolated static func encodeBinary(_ data: Data) -> String { + data.base64EncodedString(options: []) + } + + /// Decode base64 string to bytes. + /// Matches Python: decode_binary(data) + nonisolated 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). + nonisolated static func newRequestId() -> String { + UUID().uuidString + } + + // MARK: - Helpers + + private nonisolated static func trimmingNewlines(_ data: Data) -> Data { + var result = data + while let last = result.last, last == 0x0A || last == 0x0D { + result.removeLast() + } + while let first = result.first, first == 0x0A || first == 0x0D { + result.removeFirst() + } + return result + } +} diff --git a/ios_client 0.8.5/Kecalek/Utilities/Constants.swift b/ios_client 0.8.5/Kecalek/Utilities/Constants.swift new file mode 100644 index 0000000..d82e722 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Utilities/Constants.swift @@ -0,0 +1,38 @@ +import Foundation + +enum Constants: Sendable { + nonisolated static let version = "0.8.5" + nonisolated static let maxMessageBytes = 65536 + nonisolated static let maxImageBytes = 5 * 1024 * 1024 // 5 MB + nonisolated static let maxFileBytes = 50 * 1024 * 1024 // 50 MB + nonisolated static let imageChunkSize = 32768 // 32 KB (matches Python IMAGE_CHUNK_SIZE) + nonisolated static let selfDeviceId = "00000000-0000-0000-0000-000000000000" + + nonisolated static let opkReplenishThreshold = 20 + nonisolated static let opkBatchSize = 50 + nonisolated static let spkRotationDays = 7 + + nonisolated static let maxSkip = 256 + nonisolated static let maxSenderKeySkip = 256 + + nonisolated static let deviceBundleCacheTTL: TimeInterval = 300 // 5 minutes + nonisolated static let sendReceiveTimeout: TimeInterval = 30 + nonisolated static let reconnectBaseDelay: TimeInterval = 1 + nonisolated static let reconnectMaxDelay: TimeInterval = 30 + + nonisolated static let pbkdf2Iterations: UInt32 = 600_000 + nonisolated static let ecp1Magic = Data([0x45, 0x43, 0x50, 0x31]) // "ECP1" + + // HKDF info/salt strings matching Python + nonisolated static let x3dhInfo = "EncryptedChat_X3DH" + nonisolated static let rootKeyInfo = "EncryptedChat_RootKey" + nonisolated static let selfEncryptionSalt = "self_encryption" + nonisolated static let selfEncryptionInfo = "EncryptedChat_SelfKey" + nonisolated static let localStorageSalt = "local_storage" + nonisolated static let localStorageInfo = "EncryptedChat_LocalStorage" + nonisolated static let senderKeyChainInfo = "SenderKeyChain" + + // Server connection defaults + nonisolated static let defaultHost = "chat.ai-tech.news" + nonisolated static let defaultPort: UInt16 = 9999 +} diff --git a/ios_client 0.8.5/Kecalek/Utilities/Extensions.swift b/ios_client 0.8.5/Kecalek/Utilities/Extensions.swift new file mode 100644 index 0000000..8cc2add --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Utilities/Extensions.swift @@ -0,0 +1,168 @@ +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.. 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 { + nonisolated var rawData: Data { + Data(rawRepresentation) + } +} + +extension Curve25519.KeyAgreement.PrivateKey { + nonisolated var rawData: Data { + Data(rawRepresentation) + } +} + +extension Curve25519.Signing.PublicKey { + nonisolated var rawData: Data { + Data(rawRepresentation) + } +} + +extension Curve25519.Signing.PrivateKey { + nonisolated var rawData: Data { + Data(rawRepresentation) + } +} + +// MARK: - String helpers + +extension String { + /// Trim whitespace and newlines + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - Date Parsing (server sends ISO8601 with or without timezone) + +enum DateParsing { + private static let iso8601WithTZ: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let iso8601Basic: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let noTZ: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + f.timeZone = TimeZone(identifier: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + /// Parse ISO8601 date string — handles with/without timezone, with/without fractional seconds + static func parse(_ string: String) -> Date? { + iso8601WithTZ.date(from: string) + ?? iso8601Basic.date(from: string) + ?? noTZ.date(from: string) + } + + /// Format Date to ISO8601 string (for after_ts / since_ts parameters) + static func format(_ date: Date) -> String { + iso8601WithTZ.string(from: date) + } +} + +// MARK: - Dictionary merge helper + +extension Dictionary where Key == String, Value == Any { + nonisolated func string(for key: String) -> String? { + self[key] as? String + } + + nonisolated 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 + } + + nonisolated func dict(for key: String) -> [String: Any]? { + self[key] as? [String: Any] + } + + nonisolated func array(for key: String) -> [[String: Any]]? { + self[key] as? [[String: Any]] + } + + nonisolated func data(for key: String) -> Data? { + if let hex = self[key] as? String { + return Data(hexString: hex) + } + return nil + } + + nonisolated 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 + } +} diff --git a/ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift b/ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift new file mode 100644 index 0000000..fb7edf9 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift @@ -0,0 +1,192 @@ +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 hasSavedCredentials = false + var isBiometricLoading = false + + enum AuthMode { + case login, register, pairing + } + var mode: AuthMode = .login + + func checkSavedCredentials() { + hasSavedCredentials = KeychainService.hasSavedCredentials() && KeychainService.isBiometricAvailable() + } + + func login(appState: AppState) async { + guard !email.isEmpty, !password.isEmpty else { + errorMessage = "Email and password are required" + return + } + + isLoading = true + errorMessage = nil + + // Only connect if not already connected + if await !appState.chatClient.isConnected { + do { + let port = UInt16(serverPort) ?? Constants.defaultPort + try await appState.chatClient.connect(host: serverHost, port: port) + } 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 + appState.startConnectionMonitor() + if let userId = await appState.chatClient.userId { + appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email) + } + + // Save credentials for biometric login next time + if KeychainService.isBiometricAvailable() { + let port = UInt16(serverPort) ?? Constants.defaultPort + try? KeychainService.saveCredentials( + email: email, password: password, + host: serverHost, port: port + ) + } + + // Clear password from memory after successful login + password = "" + confirmPassword = "" + } else { + errorMessage = message + } + } + + func biometricLogin(appState: AppState) async { + isBiometricLoading = true + errorMessage = nil + + do { + let creds = try KeychainService.loadCredentials() + email = creds.email + password = creds.password + serverHost = creds.host + serverPort = String(creds.port) + isBiometricLoading = false + + await login(appState: appState) + + // If login failed, reset to defaults so the form isn't stuck on stale values + if !appState.isLoggedIn { + serverHost = Constants.defaultHost + serverPort = String(Constants.defaultPort) + password = "" + KeychainService.deleteCredentials() + hasSavedCredentials = false + } + } catch KeychainService.KeychainError.biometricFailed { + isBiometricLoading = false + // User cancelled — just let them type manually + } catch { + isBiometricLoading = false + errorMessage = error.localizedDescription + } + } + + 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 + + #if DEBUG + print("DEBUG register: connecting to \(serverHost):\(serverPort)") + #endif + if await !appState.chatClient.isConnected { + do { + let port = UInt16(serverPort) ?? Constants.defaultPort + try await appState.chatClient.connect(host: serverHost, port: port) + #if DEBUG + print("DEBUG register: connected successfully") + #endif + } catch { + isLoading = false + #if DEBUG + print("DEBUG register: connection failed - \(error)") + #endif + errorMessage = "Connection failed: \(error.localizedDescription)" + return + } + } + + #if DEBUG + print("DEBUG register: calling chatClient.register") + #endif + let (success, message) = await appState.chatClient.register(username: username, password: password, email: email) + isLoading = false + + #if DEBUG + print("DEBUG AuthViewModel: register returned success=\(success), message=\(message)") + #endif + + if success { + registrationMessage = message + showConfirmation = true + #if DEBUG + print("DEBUG AuthViewModel: showConfirmation set to true") + #endif + } else { + errorMessage = message + #if DEBUG + print("DEBUG AuthViewModel: errorMessage set to \(message)") + #endif + } + } + + 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 + } + } +} diff --git a/ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift b/ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift new file mode 100644 index 0000000..19b11e9 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift @@ -0,0 +1,356 @@ +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? + + func loadMessages(convId: String, chatClient: ChatClient) async { + let email = await chatClient.email + let cacheKey = await chatClient.cacheKey + + // 1. Load from cache + let cachedDicts = MessageCache.load(email: email, convId: convId, cacheKey: cacheKey) + let cached = cachedDicts?.compactMap { Message.fromCacheDict($0) } ?? [] + + if !cached.isEmpty { + // Cache hit — show immediately, no spinner + messages = cached.sorted { $0.createdAt < $1.createdAt } + } else { + // No cache — show spinner (first open) + isLoading = true + } + + // 2. Determine after_ts from newest cached message + let newestCached = messages.last + + // 3. Fetch from server + let serverMessages: [Message] + if let newest = newestCached { + let afterTs = DateParsing.format(newest.createdAt) + #if DEBUG + print("DEBUG getMessages after_ts=\(afterTs)") + #endif + serverMessages = await chatClient.getMessages(convId: convId, limit: 50, afterTs: afterTs) + } else { + serverMessages = await chatClient.getMessages(convId: convId, limit: 50) + } + + // 4. Merge + if newestCached != nil { + // Incremental: dedup by ID, append new, sort + let existingIds = Set(messages.map(\.id)) + let newMessages = serverMessages.filter { !existingIds.contains($0.id) } + if !newMessages.isEmpty { + messages.append(contentsOf: newMessages) + messages.sort { $0.createdAt < $1.createdAt } + } + } else { + // Full fetch: replace + messages = serverMessages + } + + // 5. Sync deleted (only for incremental) + if let newest = newestCached { + let afterTs = DateParsing.format(newest.createdAt) + #if DEBUG + print("DEBUG get_deleted_since since_ts=\(afterTs)") + #endif + let deletedIds = await chatClient.getDeletedSince(convId: convId, sinceTs: afterTs) + if !deletedIds.isEmpty { + messages.removeAll { deletedIds.contains($0.id) } + } + } + + // 6. Loading done + isLoading = false + + // 7. Save to cache + await saveCache(convId: convId, chatClient: chatClient) + + // 8. Mark entire conversation as read (server-side bulk mark) + // This handles messages not in cache (e.g. failed to decrypt or never fetched) + await chatClient.markConversationRead(convId: convId) + // Update local readBy for cached messages so cache reflects read state + let currentUserId = await chatClient.userId ?? "" + var anyUpdated = false + for i in messages.indices { + if !messages[i].isMine(currentUserId: currentUserId) && !messages[i].readBy.contains(currentUserId) { + messages[i].readBy.insert(currentUserId) + anyUpdated = true + } + } + if anyUpdated { + await saveCache(convId: convId, chatClient: chatClient) + } + } + + 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) + await saveCache(convId: convId, chatClient: chatClient) + } + + 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, sentMessage) = await chatClient.sendMessage( + convId: convId, text: text, members: members, replyTo: replyTo + ) + + isSending = false + + if !success { + errorMessage = msg + } else if let sentMessage = sentMessage { + // Append locally — don't reload from server (ratchet keys are one-time) + if !messages.contains(where: { $0.id == sentMessage.id }) { + messages.append(sentMessage) + } + await saveCache(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 } + await saveCache(convId: convId, chatClient: chatClient) + } + } + + func saveCache(convId: String, chatClient: ChatClient) async { + let email = await chatClient.email + let cacheKey = await chatClient.cacheKey + let dicts = messages.map { $0.toCacheDict() } + try? MessageCache.save(email: email, convId: convId, messages: dicts, cacheKey: cacheKey) + } + + 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.makeNotificationStream() { + 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 { + Task { + if let message = await chatClient.decryptNotification(data) { + await MainActor.run { + // Deduplicate — sent messages are already appended locally + if !messages.contains(where: { $0.id == message.id }) { + messages.append(message) + } + } + await saveCache(convId: convId, chatClient: chatClient) + // Only mark as read if from someone else + let myId = await chatClient.userId ?? "" + if message.senderId != myId { + await chatClient.markRead(convId: convId, messageIds: [message.id]) + } + await chatClient.flushSelfEncrypt() + } + } + } + case .messageDeleted(let data): + if let msgId = data["message_id"] as? String { + messages.removeAll { $0.id == msgId } + Task { + await saveCache(convId: convId, chatClient: chatClient) + } + } + 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) + } + } + } + case .messageReacted(let data): + if let msgId = data["message_id"] as? String, + let reactUserId = data["user_id"] as? String, + let reaction = data["reaction"] as? String, + let action = data["action"] as? String, + let idx = messages.firstIndex(where: { $0.id == msgId }) { + if action == "add" { + let newReaction = MessageReaction(userId: reactUserId, reaction: reaction, createdAt: Date()) + if !messages[idx].reactions.contains(where: { $0.userId == reactUserId && $0.reaction == reaction }) { + messages[idx].reactions.append(newReaction) + } + } else { + messages[idx].reactions.removeAll { $0.userId == reactUserId && $0.reaction == reaction } + } + Task { await saveCache(convId: convId, chatClient: chatClient) } + } + case .messagePinned(let data): + if let msgId = data["message_id"] as? String, + let pinUserId = data["user_id"] as? String, + let idx = messages.firstIndex(where: { $0.id == msgId }) { + messages[idx].pinnedAt = Date() + messages[idx].pinnedBy = pinUserId + Task { await saveCache(convId: convId, chatClient: chatClient) } + } + case .messageUnpinned(let data): + if let msgId = data["message_id"] as? String, + let idx = messages.firstIndex(where: { $0.id == msgId }) { + messages[idx].pinnedAt = nil + messages[idx].pinnedBy = nil + Task { await saveCache(convId: convId, chatClient: chatClient) } + } + case .messageDelivered(let data): + // Delivery receipt — message was successfully received by recipient + if let msgId = data["message_id"] as? String, + let idx = messages.firstIndex(where: { $0.id == msgId }) { + messages[idx].readBy.insert("__delivered__") + Task { await saveCache(convId: convId, chatClient: chatClient) } + } + default: + break + } + } + + func reactToMessage(messageId: String, convId: String, reaction: String, + currentUserId: String, chatClient: ChatClient) async { + guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return } + let existingReaction = messages[idx].reactions.first { $0.userId == currentUserId } + let hasSameReaction = existingReaction?.reaction == reaction + let savedReactions = messages[idx].reactions + + // Optimistic update + if hasSameReaction { + // Tapping same emoji — remove it + messages[idx].reactions.removeAll { $0.userId == currentUserId } + } else { + // Remove any previous reaction from this user, then add new one + messages[idx].reactions.removeAll { $0.userId == currentUserId } + messages[idx].reactions.append(MessageReaction(userId: currentUserId, reaction: reaction, createdAt: Date())) + } + + // If user had a different reaction, remove it on server first + if let old = existingReaction, old.reaction != reaction { + let _ = await chatClient.reactMessage(messageId: messageId, conversationId: convId, + reaction: old.reaction, action: "remove") + } + + // Add or remove the target reaction on server + let action = hasSameReaction ? "remove" : "add" + let success = await chatClient.reactMessage(messageId: messageId, conversationId: convId, + reaction: reaction, action: action) + if !success { + // Revert on failure + messages[idx].reactions = savedReactions + } + await saveCache(convId: convId, chatClient: chatClient) + } + + func pinMessage(messageId: String, convId: String, pin: Bool, + chatClient: ChatClient) async { + guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return } + + // Optimistic update + if pin { + messages[idx].pinnedAt = Date() + messages[idx].pinnedBy = await chatClient.userId + } else { + messages[idx].pinnedAt = nil + messages[idx].pinnedBy = nil + } + + let success = await chatClient.pinMessage(messageId: messageId, conversationId: convId, + action: pin ? "pin" : "unpin") + if !success { + // Revert on failure + if pin { + messages[idx].pinnedAt = nil + messages[idx].pinnedBy = nil + } + } + await saveCache(convId: convId, chatClient: chatClient) + } + + // MARK: - Forward Message + + func forwardMessage(message: Message, targetConvId: String, + targetMembers: [ConversationMember], chatClient: ChatClient) async -> Bool { + var originalMsg: [String: Any] = [ + "text": message.text ?? "", + "sender": message.senderUsername, + "conversation_id": message.conversationId, + "message_id": message.id, + ] + if let file = message.file { + originalMsg["file"] = [ + "file_id": file.fileId, + "aes_key": file.aesKey, + "iv": file.iv, + "filename": file.filename, + "size": file.size, + "mime_type": file.mimeType, + ] as [String: Any] + } + if let image = message.image { + var imgDict: [String: Any] = [ + "file_id": image.fileId, + "aes_key": image.aesKey, + "iv": image.iv, + "filename": image.filename, + "size": image.size, + ] + if let thumb = image.thumbnail { imgDict["thumbnail"] = thumb } + originalMsg["image"] = imgDict + } + + let (success, _, _) = await chatClient.forwardMessage( + targetConvId: targetConvId, originalMsg: originalMsg, + targetMembers: targetMembers + ) + return success + } + + func stop() { + notificationTask?.cancel() + notificationTask = nil + } +} diff --git a/ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift b/ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift new file mode 100644 index 0000000..d20c846 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift @@ -0,0 +1,246 @@ +import Foundation +import SwiftUI + +@Observable +final class ConversationListVM { + var conversations: [Conversation] = [] + var invitations: [Invitation] = [] + var onlineUsers: Set = [] + var unreadCounts: [String: Int] = [:] + var favorites: Set = [] + var avatarCache: [String: Data] = [:] // convId -> avatar image data + var isLoading = false + + private var notificationTask: Task? + private var avatarTask: Task? + private var refreshTask: Task? + private var localKey: Data? + private var email: String = "" + private var lastRefreshTime: Date = .distantPast + + func load(chatClient: ChatClient, email: String) async { + isLoading = true + self.email = email + + // Load favorites from disk (encrypted with localKey) + localKey = await chatClient.localKey + favorites = KeyStorage.loadFavorites(email: email, localKey: localKey) + + let currentUserId = await chatClient.userId ?? "" + + // Load cached conversations immediately (show while fetching from server) + if let cached = MessageCache.loadConversations(email: email, cacheKey: localKey) { + conversations = sortConversations(cached, currentUserId: currentUserId) + for conv in conversations where conv.unreadCount > 0 { + unreadCounts[conv.id] = conv.unreadCount + } + } + + // Load cached avatars from disk + let diskAvatars = MessageCache.loadAllAvatars(email: email, cacheKey: localKey) + if !diskAvatars.isEmpty { + avatarCache = diskAvatars + } + + // Fetch conversations from server + let convs = await chatClient.listConversations() + if !convs.isEmpty { + // Sync unread counts from server (authoritative source) + for conv in convs { + unreadCounts[conv.id] = conv.unreadCount + } + conversations = sortConversations(convs, currentUserId: currentUserId) + // Save to cache + MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey) + } + + // Fetch invitations + invitations = await chatClient.listInvitations() + + isLoading = false + lastRefreshTime = Date() + + // Start notification listener + startNotificationListener(chatClient: chatClient, email: email) + + // Read initial online users stored in ChatClient + // (online_users notification arrives during login before any subscriber exists) + onlineUsers = await chatClient.onlineUserIds + + // Load avatars in background (non-blocking) + avatarTask?.cancel() + avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) } + } + + func refresh(chatClient: ChatClient) async { + // Debounce: skip if refreshed < 2s ago + guard Date().timeIntervalSince(lastRefreshTime) > 2 else { + #if DEBUG + print("DEBUG ConversationListVM: refresh debounced") + #endif + return + } + lastRefreshTime = Date() + + let currentUserId = await chatClient.userId ?? "" + let convs = await chatClient.listConversations() + if !convs.isEmpty { + // Sync unread counts from server (authoritative source) + for conv in convs { + unreadCounts[conv.id] = conv.unreadCount + } + conversations = sortConversations(convs, currentUserId: currentUserId) + // Save to cache + MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey) + } + invitations = await chatClient.listInvitations() + + // Refresh avatars in background + avatarTask?.cancel() + avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) } + } + + func toggleFavorite(convId: String, email: String) { + if favorites.contains(convId) { + favorites.remove(convId) + } else { + favorites.insert(convId) + } + try? KeyStorage.saveFavorites(email: email, favorites: favorites, localKey: localKey) + + // Re-sort + let userId = conversations.first?.createdBy ?? "" + conversations = sortConversations(conversations, currentUserId: userId) + } + + func forceRefresh(chatClient: ChatClient) async { + lastRefreshTime = .distantPast + await refresh(chatClient: chatClient) + } + + func updateAvatar(convId: String, data: Data) { + avatarCache[convId] = data + // Persist to disk so it survives load() re-reads + MessageCache.saveAvatar(email: email, key: convId, data: data, cacheKey: localKey) + } + + 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.makeNotificationStream() { + 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, .conversationDeleted: + refreshTask?.cancel() + refreshTask = Task { await refresh(chatClient: chatClient) } + case .groupInvitation: + Task { invitations = await chatClient.listInvitations() } + case .reconnected: + #if DEBUG + print("DEBUG ConversationListVM: reconnected — refreshing") + #endif + refreshTask?.cancel() + refreshTask = Task { await refresh(chatClient: chatClient) } + case .connectionStateChanged(let connected): + if !connected { + #if DEBUG + print("DEBUG ConversationListVM: disconnected") + #endif + } + default: + break + } + } + + private func loadAvatars(chatClient: ChatClient, currentUserId: String) async { + await withTaskGroup(of: (String, Data?).self) { group in + for conv in conversations { + let convId = conv.id + // Skip if already cached in memory + if avatarCache[convId] != nil { continue } + if conv.isGroup { + // Only fetch if group has an avatar file + if conv.avatarFile != nil { + group.addTask { + let data = await chatClient.getGroupAvatar(convId: convId) + return (convId, data) + } + } + } else { + // DM: fetch partner's avatar + if let partnerId = conv.dmPartnerId(currentUserId: currentUserId) { + group.addTask { + let data = await chatClient.getAvatar(userId: partnerId) + return (convId, data) + } + } + } + } + + let emailCapture = email + let keyCapture = localKey + for await (convId, data) in group { + if let data = data { + avatarCache[convId] = data + // Save to disk cache + MessageCache.saveAvatar(email: emailCapture, key: convId, data: data, cacheKey: keyCapture) + } + } + } + } + + func stop() { + notificationTask?.cancel() + notificationTask = nil + avatarTask?.cancel() + avatarTask = nil + refreshTask?.cancel() + refreshTask = nil + } +} diff --git a/ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift b/ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..43ad02a --- /dev/null +++ b/ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift @@ -0,0 +1,98 @@ +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 clientUserId = await chatClient.userId + let uid = userId ?? clientUserId ?? "" + if !uid.isEmpty { + avatarData = await chatClient.getAvatar(userId: uid) + } + } + + @discardableResult + func saveProfile(chatClient: ChatClient) async -> Bool { + 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" + } + return success + } + + func uploadAvatar(imageData: Data, chatClient: ChatClient) async { + isSaving = true + errorMessage = nil + let (success, msg) = await chatClient.updateAvatar(imageData: imageData) + isSaving = false + + if success { + // Reload avatar from server (it was resized/compressed) + let clientUserId = await chatClient.userId ?? "" + avatarData = await chatClient.getAvatar(userId: clientUserId) + } else { + errorMessage = msg.isEmpty ? "Failed to upload avatar" : msg + } + } + + // MARK: - Username Change + + func changeUsername(newUsername: String, chatClient: ChatClient) async -> Bool { + isSaving = true + errorMessage = nil + let (success, msg) = await chatClient.changeUsername(newUsername: newUsername) + isSaving = false + if !success { + errorMessage = msg + } + return success + } + + // MARK: - Password Change + + func changePassword(oldPassword: String, newPassword: String, chatClient: ChatClient) async -> Bool { + isSaving = true + errorMessage = nil + let (success, msg) = await chatClient.changePassword(oldPassword: oldPassword, newPassword: newPassword) + isSaving = false + if !success { + errorMessage = msg + } + return success + } +} diff --git a/ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift b/ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift new file mode 100644 index 0000000..fd78d2f --- /dev/null +++ b/ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftUI + +@Observable +final class VerificationVM { + var safetyNumber: String? + var myFingerprint: String? + var peerFingerprint: String? + var verificationStatus: String = "unverified" // "verified", "trusted", "unverified" + var qrCodeData: Data? + var scanResult: String? + var scanSuccess: Bool? + var isLoading = false + var errorMessage: String? + + func loadVerification(peerUserId: String, chatClient: ChatClient) async { + isLoading = true + + // Ensure peer's identity key is fetched (needed for safety number & verification) + _ = await chatClient.getPeerIdentityKey(userId: peerUserId) + + // Get safety number + safetyNumber = await chatClient.getSafetyNumber(peerUserId: peerUserId) + + // Get fingerprints + myFingerprint = await chatClient.getMyFingerprint() + peerFingerprint = await chatClient.getPeerFingerprint(peerUserId: peerUserId) + + // Get verification status + verificationStatus = await chatClient.getVerificationStatus(userId: peerUserId) + + // Get QR code data for display + qrCodeData = await chatClient.getVerificationQRData() + + isLoading = false + } + + func verifyContact(peerUserId: String, chatClient: ChatClient) async { + guard let peerIK = await chatClient.getPeerIdentityKey(userId: peerUserId) else { + errorMessage = "No identity key on record for this user." + return + } + await chatClient.verifyContact(userId: peerUserId, identityKey: peerIK, method: "manual") + verificationStatus = "verified" + } + + func unverifyContact(peerUserId: String, chatClient: ChatClient) async { + await chatClient.unverifyContact(userId: peerUserId) + verificationStatus = "trusted" + } + + func verifyQRCode(data: Data, chatClient: ChatClient) async { + let (success, _, message) = await chatClient.verifyQRCode(qrData: data) + scanSuccess = success + scanResult = message + if success { + verificationStatus = "verified" + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Auth/AuthorizeDeviceView.swift b/ios_client 0.8.5/Kecalek/Views/Auth/AuthorizeDeviceView.swift new file mode 100644 index 0000000..fa1ff20 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Auth/AuthorizeDeviceView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct AuthorizeDeviceView: View { + var appState: AppState + @State private var code = "" + @State private var isAuthorizing = false + @State private var statusMessage: String? + @State private var isError = false + @State private var isDone = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "iphone.badge.checkmark") + .font(.system(size: 48)) + .foregroundStyle(.blue) + + Text("Authorize New Device") + .font(.title2.bold()) + + Text("Enter the 8-digit pairing code shown on the new device.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + TextField("Pairing Code", text: $code) + .font(.system(size: 24, weight: .bold, design: .monospaced)) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + + Button("Authorize") { + Task { await authorize() } + } + .buttonStyle(.borderedProminent) + .disabled(code.count < 8 || isAuthorizing || isDone) + + if isAuthorizing { + ProgressView("Preparing history & sending keys...") + } + + if let status = statusMessage { + Text(status) + .font(.caption) + .foregroundStyle(isError ? .red : .green) + .multilineTextAlignment(.center) + } + + if isDone { + Button("Done") { dismiss() } + .buttonStyle(.bordered) + } + } + .padding(32) + } + .navigationTitle("Authorize Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + } + } + } + + private func authorize() async { + isAuthorizing = true + isError = false + statusMessage = nil + + let (success, msg) = await appState.chatClient.authorizeDevice(code: code) + isAuthorizing = false + + statusMessage = msg + isError = !success + if success { + isDone = true + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift b/ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift new file mode 100644 index 0000000..321bbf3 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +struct LoginView: View { + @Bindable var viewModel: AuthViewModel + var appState: AppState + @State private var showPairing = false + @State private var didAttemptBiometric = false + + 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) + } + .padding(.horizontal) + + if viewModel.mode == .register { + TextField("Username", text: $viewModel.username) + .textContentType(.username) + .autocapitalization(.none) + .textFieldStyle(.roundedBorder) + } + + TextField("Email", text: $viewModel.email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .textFieldStyle(.roundedBorder) + + SecureField("Password", text: $viewModel.password) + .textContentType(viewModel.mode == .login ? .password : .oneTimeCode) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .textFieldStyle(.roundedBorder) + + if viewModel.mode == .register { + SecureField("Confirm Password", text: $viewModel.confirmPassword) + .textContentType(.oneTimeCode) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .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) + + if viewModel.hasSavedCredentials && viewModel.mode == .login { + Divider() + .padding(.vertical, 4) + + Button { + Task { await viewModel.biometricLogin(appState: appState) } + } label: { + if viewModel.isBiometricLoading { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Label("Sign in with Face ID", systemImage: "faceid") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.bordered) + .disabled(viewModel.isLoading || viewModel.isBiometricLoading) + } + + Divider() + .padding(.vertical, 4) + + Button("Pair from existing device") { + showPairing = true + } + .font(.caption) + } + .padding(.horizontal, 32) + } + } + .task { + viewModel.checkSavedCredentials() + if viewModel.hasSavedCredentials && !didAttemptBiometric { + didAttemptBiometric = true + await viewModel.biometricLogin(appState: appState) + } + } + .sheet(isPresented: $viewModel.showConfirmation) { + ConfirmationSheet(viewModel: viewModel, appState: appState) + } + .sheet(isPresented: $showPairing) { + PairingView(appState: appState, authViewModel: viewModel) + } + } + } +} + +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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift b/ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift new file mode 100644 index 0000000..489f067 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift @@ -0,0 +1,175 @@ +import SwiftUI + +struct PairingView: View { + var appState: AppState + @Bindable var authViewModel: AuthViewModel + @State private var email = "" + @State private var password = "" + @State private var pairingCode: String? + @State private var isStarting = false + @State private var isWaiting = false + @State private var statusMessage: String? + @State private var isError = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "iphone.and.arrow.forward") + .font(.system(size: 48)) + .foregroundStyle(.blue) + + Text("Device Pairing") + .font(.title2.bold()) + + Text("Transfer your keys from an existing device to this one.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if pairingCode == nil { + // Phase 1: Enter email and start pairing + VStack(spacing: 16) { + // Server config + DisclosureGroup("Server") { + TextField("Host", text: $authViewModel.serverHost) + .textContentType(.URL) + .autocapitalization(.none) + TextField("Port", text: $authViewModel.serverPort) + .keyboardType(.numberPad) + } + + TextField("Email", text: $email) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .textFieldStyle(.roundedBorder) + + SecureField("Password (for key encryption)", text: $password) + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .textFieldStyle(.roundedBorder) + + Button("Start Pairing") { + Task { await startPairing() } + } + .buttonStyle(.borderedProminent) + .disabled(email.isEmpty || password.isEmpty || isStarting) + + if isStarting { + ProgressView("Connecting...") + } + } + } else { + // Phase 2: Show code and wait for authorization + VStack(spacing: 16) { + Text("Pairing Code") + .font(.headline) + + Text(pairingCode!) + .font(.system(size: 36, weight: .bold, design: .monospaced)) + .padding() + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Text("Enter this code on your already logged-in device\nto authorize this device.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if isWaiting { + ProgressView("Waiting for authorization...") + .padding() + } + } + } + + if let status = statusMessage { + Text(status) + .font(.caption) + .foregroundStyle(isError ? .red : .green) + .multilineTextAlignment(.center) + } + } + .padding(32) + } + .navigationTitle("Pair Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + } + } + } + + private func startPairing() async { + isStarting = true + isError = false + statusMessage = nil + + // Connect to server + if await !appState.chatClient.isConnected { + do { + let port = UInt16(authViewModel.serverPort) ?? Constants.defaultPort + try await appState.chatClient.connect( + host: authViewModel.serverHost, port: port + ) + } catch { + isStarting = false + statusMessage = "Connection failed: \(error.localizedDescription)" + isError = true + return + } + } + + let (success, codeOrMsg) = await appState.chatClient.pairingStart(email: email) + isStarting = false + + if success { + pairingCode = codeOrMsg + // Start waiting for authorization + isWaiting = true + Task { await waitForAuthorization() } + } else { + statusMessage = codeOrMsg + isError = true + } + } + + private func waitForAuthorization() async { + let (success, msg) = await appState.chatClient.pairingWait( + code: pairingCode!, email: email, password: password + ) + isWaiting = false + + if success { + statusMessage = msg + isError = false + // Auto-login + let (loginOk, loginMsg) = await appState.chatClient.login(email: email, password: password) + if loginOk { + appState.email = email + appState.isLoggedIn = true + appState.connectionStatus = .connected + appState.startConnectionMonitor() + if let userId = await appState.chatClient.userId { + appState.currentUser = User( + id: userId, + username: await appState.chatClient.username, + email: email + ) + } + dismiss() + } else { + statusMessage = "Keys imported but login failed: \(loginMsg)" + isError = true + } + } else { + statusMessage = msg + isError = true + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift b/ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift new file mode 100644 index 0000000..04c0b57 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift @@ -0,0 +1,4 @@ +import SwiftUI + +// Registration is handled within LoginView via mode toggle. +// This file exists for potential future separation. diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift new file mode 100644 index 0000000..8c7fd32 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift @@ -0,0 +1,382 @@ +import SwiftUI + +struct ChatView: View { + @State private var conversation: Conversation + var appState: AppState + var conversationListVM: ConversationListVM? + + init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) { + self._conversation = State(initialValue: conversation) + self.appState = appState + self.conversationListVM = conversationListVM + } + @State private var viewModel = ChatViewModel() + @State private var inputText = "" + @State private var replyTo: Message? + @State private var showGroupInfo = false + @State private var showDMInfo = false + @State private var showSearch = false + @State private var showDeleteConfirm = false + @State private var showError = false + @State private var memberListenerTask: Task? + @State private var forwardingMessage: Message? + @State private var showForwardPicker = false + @State private var showPinnedMessages = false + @State private var scrollTarget: String? + @State private var showVerification = false + @State private var verificationStatus: String = "unverified" + + private var currentUserId: String { + appState.currentUser?.id ?? "" + } + + private var isPartnerOnline: Bool { + guard !conversation.isGroup, + let partnerId = conversation.dmPartnerId(currentUserId: currentUserId), + let listVM = conversationListVM else { + return false + } + return listVM.onlineUsers.contains(partnerId) + } + + var body: some View { + VStack(spacing: 0) { + searchBar + messagesScrollView + replyPreview + inputView + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .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.") + } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "Unknown error") + } + .sheet(isPresented: $showGroupInfo) { + GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM) + } + .sheet(isPresented: $showDMInfo) { + if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) { + ProfileView(appState: appState, isOwnProfile: false, userId: partnerId) + } + } + .sheet(isPresented: $showForwardPicker) { + if let msg = forwardingMessage { + ForwardPickerView(message: msg, appState: appState) + } + } + .sheet(isPresented: $showVerification) { + if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) { + NavigationStack { + SafetyNumberView( + peerUserId: partnerId, + peerUsername: conversation.displayName(currentUserId: currentUserId), + chatClient: appState.chatClient + ) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { showVerification = false } + } + } + } + } + } + .sheet(isPresented: $showPinnedMessages) { + PinnedMessagesView( + messages: viewModel.messages.filter { $0.pinnedAt != nil }, + onScrollTo: { scrollTarget = $0 } + ) + } + .task { + // Use already-loaded data from conversation list (avoid redundant list_conversations call) + if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) { + conversation = updated + } + conversationListVM?.markConversationRead(convId: conversation.id) + await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient) + viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient) + + // Load verification status for DM partner + if !conversation.isGroup, + let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) { + verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId) + } + + memberListenerTask = Task { + for await notification in await appState.chatClient.makeNotificationStream() { + switch notification { + case .memberAdded, .memberRemoved, .conversationRenamed: + let refreshed = await appState.chatClient.listConversations() + if let updated = refreshed.first(where: { $0.id == conversation.id }) { + await MainActor.run { conversation = updated } + } + default: + break + } + } + } + } + .onDisappear { + viewModel.stop() + memberListenerTask?.cancel() + memberListenerTask = nil + } + } + + // MARK: - Search Bar + + @ViewBuilder + private var searchBar: some View { + 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: "") } + ) + } + } + + // MARK: - Messages + + private var messagesScrollView: some View { + 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 + messageBubble(for: message) + .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) } + } + } + .onChange(of: scrollTarget) { + if let target = scrollTarget { + withAnimation { proxy.scrollTo(target, anchor: .center) } + scrollTarget = nil + } + } + } + } + + private func messageBubble(for message: Message) -> some View { + let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex) + && viewModel.searchResults[viewModel.currentSearchIndex] == message.id + return MessageBubbleView( + message: message, + isMine: message.isMine(currentUserId: currentUserId), + isGroup: conversation.isGroup, + isHighlighted: viewModel.searchResults.contains(message.id), + isCurrentSearchResult: isCurrentSearch, + chatClient: appState.chatClient, + currentUserId: currentUserId, + onReply: { replyTo = message }, + onReact: { reaction in + Task { + await viewModel.reactToMessage( + messageId: message.id, convId: conversation.id, + reaction: reaction, currentUserId: currentUserId, + chatClient: appState.chatClient + ) + } + }, + onForward: { + forwardingMessage = message + showForwardPicker = true + }, + onPin: { pin in + Task { + await viewModel.pinMessage( + messageId: message.id, convId: conversation.id, + pin: pin, chatClient: appState.chatClient + ) + } + }, + onDelete: { + Task { + await viewModel.deleteMessage( + messageId: message.id, convId: conversation.id, + chatClient: appState.chatClient + ) + } + } + ) + } + + // MARK: - Reply Preview + + @ViewBuilder + private var replyPreview: some View { + 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) + } + } + + // MARK: - Input + + private var inputView: some View { + 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 + ) + } + }, + onImageSelected: { imageData in + Task { + viewModel.isSending = true + let (success, msg, sentMessage) = await appState.chatClient.sendImage( + convId: conversation.id, imageData: imageData, + members: conversation.members + ) + viewModel.isSending = false + if success, let sentMessage { + if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) { + viewModel.messages.append(sentMessage) + } + await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient) + } else if !success { + viewModel.errorMessage = msg + showError = true + } + } + }, + onFileSelected: { fileData, filename, mimeType in + Task { + viewModel.isSending = true + let (success, msg, sentMessage) = await appState.chatClient.sendFile( + convId: conversation.id, fileData: fileData, + filename: filename, mimeType: mimeType, + members: conversation.members + ) + viewModel.isSending = false + if success, let sentMessage { + if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) { + viewModel.messages.append(sentMessage) + } + await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient) + } else if !success { + viewModel.errorMessage = msg + showError = true + } + } + }, + members: conversation.members + ) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .principal) { + HStack(spacing: 8) { + CircularAvatarView( + name: conversation.displayName(currentUserId: currentUserId), + imageData: conversationListVM?.avatarCache[conversation.id], + size: 28, + isGroup: conversation.isGroup + ) + Text(conversation.displayName(currentUserId: currentUserId)) + .font(.headline) + if !conversation.isGroup && verificationStatus == "verified" { + Image(systemName: "checkmark.shield.fill") + .font(.caption) + .foregroundStyle(.green) + } + if isPartnerOnline { + Circle().fill(.green).frame(width: 8, height: 8) + } + } + } + + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + if !conversation.isGroup { + Button(action: { showVerification = true }) { + Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield") + .foregroundStyle(verificationStatus == "verified" ? .green : .secondary) + } + } + Button(action: { showPinnedMessages = true }) { + Image(systemName: "pin") + } + Button(action: { showSearch.toggle() }) { + Image(systemName: "magnifyingglass") + } + if conversation.isGroup { + Button(action: { showGroupInfo = true }) { + Image(systemName: "info.circle") + } + } else { + Button(action: { showDMInfo = true }) { + Image(systemName: "info.circle") + } + } + if !conversation.isGroup || conversation.createdBy == currentUserId { + Button(action: { showDeleteConfirm = true }) { + Image(systemName: "trash").foregroundStyle(.red) + } + } + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift new file mode 100644 index 0000000..cf2eeda --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct ForwardPickerView: View { + let message: Message + let appState: AppState + + @Environment(\.dismiss) private var dismiss + @State private var conversations: [Conversation] = [] + @State private var isLoading = true + @State private var isSending = false + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Loading conversations...") + } else if conversations.isEmpty { + Text("No conversations available") + .foregroundStyle(.secondary) + } else { + List(conversations) { conv in + Button { + forwardTo(conv) + } label: { + HStack { + CircularAvatarView( + name: conv.displayName(currentUserId: appState.currentUser?.id ?? ""), + size: 36, + isGroup: conv.isGroup + ) + Text(conv.displayName(currentUserId: appState.currentUser?.id ?? "")) + Spacer() + } + } + .disabled(isSending) + } + } + } + .navigationTitle("Forward to...") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .task { + conversations = await appState.chatClient.listConversations() + isLoading = false + } + } + + private func forwardTo(_ conv: Conversation) { + isSending = true + Task { + let forwardPayload: [String: Any] = [ + "forwarded_from": [ + "sender": message.senderUsername, + "conversation_id": message.conversationId, + "message_id": message.id, + ] as [String: Any] + ] + let (success, _, _) = await appState.chatClient.sendMessage( + convId: conv.id, + text: message.text ?? "", + members: conv.members, + extraPayload: forwardPayload + ) + await MainActor.run { + isSending = false + if success { + dismiss() + } + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift new file mode 100644 index 0000000..ab7ad19 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import UIKit +import Photos + +struct ImageViewerView: View { + let imageData: Data + @State private var scale: CGFloat = 1.0 + @State private var saved = false + @State private var saveError: String? + @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) + } + } + .overlay(alignment: .bottom) { + if let error = saveError { + Text(error) + .font(.caption) + .foregroundStyle(.white) + .padding(8) + .background(Capsule().fill(.red.opacity(0.8))) + .padding(.bottom, 40) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundStyle(.white) + } + } + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + // Share + if let uiImage = UIImage(data: imageData) { + ShareLink(item: Image(uiImage: uiImage), preview: SharePreview("Image", image: Image(uiImage: uiImage))) { + Image(systemName: "square.and.arrow.up") + .foregroundStyle(.white) + } + } + + // Save to Photos + Button { + saveToPhotos() + } label: { + Image(systemName: saved ? "checkmark.circle.fill" : "arrow.down.to.line") + .foregroundStyle(saved ? .green : .white) + } + } + } + } + .toolbarBackground(.hidden, for: .navigationBar) + .background(.black) + } + } + + private func saveToPhotos() { + guard let uiImage = UIImage(data: imageData) else { + withAnimation { saveError = "Invalid image data" } + return + } + + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + DispatchQueue.main.async { + switch status { + case .authorized, .limited: + PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: uiImage) + } completionHandler: { success, error in + DispatchQueue.main.async { + if success { + withAnimation { saved = true; saveError = nil } + } else { + withAnimation { saveError = error?.localizedDescription ?? "Save failed" } + } + } + } + case .denied, .restricted: + withAnimation { saveError = "Photo library access denied. Check Settings." } + default: + withAnimation { saveError = "Photo library access required" } + } + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift new file mode 100644 index 0000000..b8747c5 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift @@ -0,0 +1,558 @@ +import SwiftUI +import UIKit + +struct MessageBubbleView: View { + let message: Message + let isMine: Bool + var isGroup: Bool = false + var isHighlighted: Bool = false + var isCurrentSearchResult: Bool = false + var chatClient: ChatClient? + var currentUserId: String = "" + var onReply: (() -> Void)? + var onReact: ((String) -> Void)? + var onForward: (() -> Void)? + var onPin: ((Bool) -> Void)? + var onDelete: (() -> Void)? + + @State private var fullImageData: Data? + @State private var showFullImage = false + @State private var isLoadingImage = false + @State private var isLoadingFile = false + @State private var downloadedFileURL: URL? + @State private var showShareSheet = false + @State private var imageError: String? + + var body: some View { + HStack { + if isMine { Spacer(minLength: 60) } + + VStack(alignment: isMine ? .trailing : .leading, spacing: 4) { + if !isMine && isGroup { + 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 { + // Forwarded header + if let fwd = message.forwardedFrom { + HStack(spacing: 4) { + Rectangle().fill(.cyan).frame(width: 3) + VStack(alignment: .leading, spacing: 1) { + Text("Forwarded from").font(.caption2).foregroundStyle(.secondary) + Text(fwd.sender).font(.caption.bold()).foregroundStyle(.cyan) + } + } + .padding(.horizontal, 8).padding(.top, 4) + } + + // Reply reference + if message.replyTo != nil { + HStack(spacing: 4) { + Rectangle() + .fill(.blue.opacity(0.5)) + .frame(width: 2) + Text("Reply to message") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + } + + // Image thumbnail + if let imageInfo = message.image { + imageView(imageInfo: imageInfo) + } + + // File card + if let file = message.file { + VStack(alignment: .leading, spacing: 4) { + HStack { + if isLoadingFile { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: fileIcon(for: file.filename)) + } + Text(file.filename) + .lineLimit(1) + } + .font(.subheadline) + + Text(formatFileSize(file.size)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(12) + .background(Color(.systemGray5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { + downloadAndShareFile(file: file) + } + } + + // Text content with link detection + if let text = message.text, !text.isEmpty { + LinkText(text: text, isMine: isMine) + .padding(12) + .background( + isMine ? Color.blue : Color(.systemGray5) + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + // Timestamp + checkmarks + reactions — all on one line + HStack(spacing: 4) { + if message.pinnedAt != nil { + Image(systemName: "pin.fill").font(.caption2).foregroundStyle(.orange) + } + Text(formatTime(message.createdAt)).font(.caption2).foregroundStyle(.secondary) + if isMine { + deliveryIndicator + } + if !message.reactions.isEmpty { + inlineReactionBadges + } + } + .frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading) + } + } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isCurrentSearchResult ? Color.orange.opacity(0.3) : + isHighlighted ? Color.yellow.opacity(0.2) : Color.clear) + ) + .contextMenu { + if !message.isDeleted { + Button(action: { onReply?() }) { + Label("Reply", systemImage: "arrowshape.turn.up.left") + } + + Menu { + ForEach(ReactionEmoji.allowed, id: \.self) { key in + Button("\(ReactionEmoji.display[key] ?? "") \(key)") { onReact?(key) } + } + } label: { + Label("React", systemImage: "face.smiling") + } + + Button(action: { + UIPasteboard.general.string = message.text ?? "" + // Auto-clear clipboard after 30 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 30) { + if UIPasteboard.general.string == message.text { + UIPasteboard.general.string = "" + } + } + }) { + Label("Copy", systemImage: "doc.on.doc") + } + + Button(action: { onForward?() }) { + Label("Forward", systemImage: "arrowshape.turn.up.right") + } + + Button(action: { onPin?(message.pinnedAt == nil) }) { + Label(message.pinnedAt == nil ? "Pin" : "Unpin", + systemImage: message.pinnedAt == nil ? "pin" : "pin.slash") + } + + if isMine { + Button(role: .destructive, action: { onDelete?() }) { + Label("Delete", systemImage: "trash") + } + } + } + } + + if !isMine { Spacer(minLength: 60) } + } + .sheet(isPresented: $showFullImage) { + if let data = fullImageData { + ImageViewerView(imageData: data) + } + } + .sheet(isPresented: $showShareSheet, onDismiss: { + // Clean up decrypted temp file after sharing + if let fileURL = downloadedFileURL { + try? FileManager.default.removeItem(at: fileURL) + downloadedFileURL = nil + } + }) { + if let fileURL = downloadedFileURL { + ActivityViewController(activityItems: [fileURL]) + } + } + } + + // MARK: - Reaction Badges (inline — used in timestamp row) + + private var inlineReactionBadges: some View { + let grouped = Dictionary(grouping: message.reactions, by: \.reaction) + return HStack(spacing: 2) { + ForEach(grouped.sorted(by: { $0.key < $1.key }), id: \.key) { reaction, users in + Button { + onReact?(reaction) + } label: { + Text(ReactionEmoji.display[reaction] ?? reaction) + .font(.caption) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Delivery Indicator (checkmarks) + + @ViewBuilder + private var deliveryIndicator: some View { + let isRead = message.readBy.contains(where: { $0 != "__delivered__" && $0 != currentUserId }) + let isDelivered = message.readBy.contains("__delivered__") + + if isRead { + // Read: 2 green checkmarks + HStack(spacing: -4) { + Image(systemName: "checkmark").font(.system(size: 9, weight: .bold)) + Image(systemName: "checkmark").font(.system(size: 9, weight: .bold)) + } + .foregroundStyle(.green) + } else if isDelivered { + // Delivered: 2 gray checkmarks + HStack(spacing: -4) { + Image(systemName: "checkmark").font(.system(size: 9, weight: .bold)) + Image(systemName: "checkmark").font(.system(size: 9, weight: .bold)) + } + .foregroundStyle(.secondary) + } else { + // Sent: 1 gray checkmark + Image(systemName: "checkmark").font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + } + + // MARK: - Image View + + @ViewBuilder + private func imageView(imageInfo: ImageInfo) -> some View { + VStack(spacing: 4) { + if let thumbB64 = imageInfo.thumbnail, + let thumbData = Data(base64Encoded: thumbB64), + let uiImage = UIImage(data: thumbData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 220, maxHeight: 220) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay { + if isLoadingImage { + RoundedRectangle(cornerRadius: 12) + .fill(.black.opacity(0.4)) + ProgressView() + .tint(.white) + } + } + .onTapGesture { + downloadAndShowFullImage(imageInfo: imageInfo) + } + } else { + // No thumbnail available — show placeholder + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray5)) + .frame(width: 160, height: 120) + .overlay { + if isLoadingImage { + ProgressView() + } else { + VStack(spacing: 6) { + Image(systemName: "photo") + .font(.title2) + Text(imageInfo.filename) + .font(.caption) + .lineLimit(1) + } + .foregroundStyle(.secondary) + } + } + .onTapGesture { + downloadAndShowFullImage(imageInfo: imageInfo) + } + } + + if let error = imageError { + Text(error) + .font(.caption2) + .foregroundStyle(.red) + .onTapGesture { + imageError = nil + downloadAndShowFullImage(imageInfo: imageInfo) + } + } + } + } + + private func downloadAndShowFullImage(imageInfo: ImageInfo) { + guard !isLoadingImage, let client = chatClient else { return } + // If already downloaded, show immediately + if fullImageData != nil { + showFullImage = true + return + } + imageError = nil + isLoadingImage = true + Task { + guard let aesKey = try? ProtocolHandler.decodeBinary(imageInfo.aesKey), + let iv = try? ProtocolHandler.decodeBinary(imageInfo.iv) else { + await MainActor.run { + isLoadingImage = false + imageError = "Failed to decode image keys" + } + return + } + let data = await client.downloadFile(fileId: imageInfo.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId) + await MainActor.run { + isLoadingImage = false + if let data = data { + fullImageData = data + showFullImage = true + } else { + imageError = "Download failed, tap to retry" + } + } + } + } + + // MARK: - File Download + + private func downloadAndShareFile(file: FileInfo) { + guard !isLoadingFile, let client = chatClient else { return } + // If already downloaded, show share sheet immediately + if downloadedFileURL != nil { + showShareSheet = true + return + } + isLoadingFile = true + Task { + guard let aesKey = try? ProtocolHandler.decodeBinary(file.aesKey), + let iv = try? ProtocolHandler.decodeBinary(file.iv) else { + await MainActor.run { isLoadingFile = false } + return + } + let data = await client.downloadFile(fileId: file.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId) + await MainActor.run { + isLoadingFile = false + if let data = data { + // Save to temp with file protection, clean up on dismiss + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent(file.filename) + try? data.write(to: fileURL, options: .completeFileProtection) + downloadedFileURL = fileURL + showShareSheet = true + } + } + } + } + + private func fileIcon(for filename: String) -> String { + let ext = (filename as NSString).pathExtension.lowercased() + switch ext { + case "pdf": return "doc.richtext" + case "doc", "docx": return "doc.text" + case "xls", "xlsx": return "tablecells" + case "ppt", "pptx": return "rectangle.on.rectangle" + case "zip", "rar", "7z": return "doc.zipper" + case "mp3", "wav", "m4a": return "music.note" + case "mp4", "mov", "avi": return "film" + case "txt": return "doc.plaintext" + default: return "paperclip" + } + } + + // MARK: - Helpers + + 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)) + } +} + +// MARK: - Link Text + +struct LinkText: View { + let text: String + let isMine: Bool + + var body: some View { + Text(buildAttributedString()) + .environment(\.openURL, OpenURLAction { url in + UIApplication.shared.open(url) + return .handled + }) + } + + private func buildAttributedString() -> AttributedString { + var result = AttributedString() + + guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { + return appendPlainWithMentions(text[text.startIndex.. AttributedString { + let str = String(substring) + let nsRange = NSRange(str.startIndex..., in: str) + let matches = Self.mentionRegex.matches(in: str, range: nsRange) + + if matches.isEmpty { + var plain = AttributedString(str) + plain.foregroundColor = isMine ? .white : .primary + result.append(plain) + return result + } + + let mentionColor = Color(red: 0.537, green: 0.706, blue: 0.980) + var lastEnd = str.startIndex + + for match in matches { + guard let matchRange = Range(match.range, in: str) else { continue } + + if lastEnd < matchRange.lowerBound { + var plain = AttributedString(str[lastEnd.. CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth && x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: maxWidth, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX && x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + +// MARK: - Share Sheet + +struct ActivityViewController: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift new file mode 100644 index 0000000..c682256 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import PhotosUI +import UniformTypeIdentifiers +import UIKit + +struct MessageInputView: View { + @Binding var text: String + let isSending: Bool + let onSend: () -> Void + var onImageSelected: ((Data) -> Void)? + var onFileSelected: ((Data, String, String) -> Void)? // data, filename, mimeType + var members: [ConversationMember] = [] + + @State private var isProcessing = false + @State private var showFilePicker = false + @State private var showPhotoPicker = false + @State private var showMentionPopup = false + @State private var mentionCandidates: [ConversationMember] = [] + + var body: some View { + VStack(spacing: 0) { + // Mention autocomplete popup + if showMentionPopup && !mentionCandidates.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(mentionCandidates) { member in + Button { + completeMention(member: member) + } label: { + Text("@\(member.username)") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + Divider() + } + } + } + .frame(maxHeight: 150) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + } + + HStack(spacing: 8) { + // Attach button + Menu { + Button { + showPhotoPicker = true + } label: { + Label("Photo", systemImage: "photo") + } + Button { + showFilePicker = true + } label: { + Label("File", systemImage: "doc") + } + } label: { + if isProcessing { + ProgressView() + .scaleEffect(0.8) + } else { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(.blue) + } + } + .disabled(isProcessing || isSending) + + // 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) + .sheet(isPresented: $showPhotoPicker) { + ImagePickerView { data in + isProcessing = true + onImageSelected?(data) + isProcessing = false + } + } + .sheet(isPresented: $showFilePicker) { + DocumentPickerView { url in + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + guard let data = try? Data(contentsOf: url) else { return } + let filename = url.lastPathComponent + let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + onFileSelected?(data, filename, mimeType) + } + } + .onChange(of: text) { + updateMentionCandidates() + } + } // end VStack + } + + private func updateMentionCandidates() { + // Look for @prefix at end of text + guard let atRange = text.range(of: "@\\w*$", options: .regularExpression) else { + showMentionPopup = false + mentionCandidates = [] + return + } + let prefix = String(text[atRange]).dropFirst().lowercased() // remove @ + mentionCandidates = members.filter { member in + prefix.isEmpty || member.username.lowercased().hasPrefix(prefix) + } + showMentionPopup = !mentionCandidates.isEmpty + } + + private func completeMention(member: ConversationMember) { + if let atRange = text.range(of: "@\\w*$", options: .regularExpression) { + text.replaceSubrange(atRange, with: "@\(member.username) ") + } + showMentionPopup = false + mentionCandidates = [] + } +} + +// MARK: - Image Picker (UIKit PHPicker wrapper) + +struct ImagePickerView: UIViewControllerRepresentable { + let onImagePicked: (Data) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onImagePicked: onImagePicked) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let onImagePicked: (Data) -> Void + + init(onImagePicked: @escaping (Data) -> Void) { + self.onImagePicked = onImagePicked + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, + provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in + guard let uiImage = image as? UIImage, + let data = uiImage.jpegData(compressionQuality: 0.9) else { return } + DispatchQueue.main.async { + self?.onImagePicked(data) + } + } + } + } +} + +// MARK: - Document Picker (UIKit wrapper) + +struct DocumentPickerView: UIViewControllerRepresentable { + let onPick: (URL) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onPick: onPick) + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item]) + picker.delegate = context.coordinator + picker.allowsMultipleSelection = false + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + + class Coordinator: NSObject, UIDocumentPickerDelegate { + let onPick: (URL) -> Void + + init(onPick: @escaping (URL) -> Void) { + self.onPick = onPick + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + onPick(url) + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift new file mode 100644 index 0000000..17d1dfa --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct PinnedMessagesView: View { + let messages: [Message] + var onScrollTo: ((String) -> Void)? + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if messages.isEmpty { + Text("No pinned messages") + .foregroundStyle(.secondary) + } else { + List(messages) { message in + Button { + dismiss() + onScrollTo?(message.id) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "pin.fill") + .font(.caption) + .foregroundStyle(.orange) + Text(message.senderUsername) + .font(.caption.bold()) + Spacer() + Text(formatTime(message.createdAt)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(message.text ?? "") + .font(.body) + .lineLimit(3) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } + } + .navigationTitle("Pinned Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + } + + 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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift b/ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift new file mode 100644 index 0000000..98bc3b4 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift @@ -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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Components/CircularAvatarView.swift b/ios_client 0.8.5/Kecalek/Views/Components/CircularAvatarView.swift new file mode 100644 index 0000000..787a349 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Components/CircularAvatarView.swift @@ -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] + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Components/ConnectionIndicator.swift b/ios_client 0.8.5/Kecalek/Views/Components/ConnectionIndicator.swift new file mode 100644 index 0000000..bfa0fec --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Components/ConnectionIndicator.swift @@ -0,0 +1,36 @@ +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, .reconnecting: return .orange + case .disconnected: return .red + } + } + + private var statusText: String { + switch status { + case .connected: return "" + case .connecting: return "Connecting..." + case .reconnecting: return "Reconnecting..." + case .disconnected: return "Disconnected" + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Components/OnlineDotOverlay.swift b/ios_client 0.8.5/Kecalek/Views/Components/OnlineDotOverlay.swift new file mode 100644 index 0000000..4a2b645 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Components/OnlineDotOverlay.swift @@ -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) + ) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Conversations/ConversationListView.swift b/ios_client 0.8.5/Kecalek/Views/Conversations/ConversationListView.swift new file mode 100644 index 0000000..5ebb5c7 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Conversations/ConversationListView.swift @@ -0,0 +1,101 @@ +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, + avatarData: viewModel.avatarCache[conversation.id] + ) + } + .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, + conversationListVM: viewModel + ) + } + .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) + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Conversations/ConversationRowView.swift b/ios_client 0.8.5/Kecalek/Views/Conversations/ConversationRowView.swift new file mode 100644 index 0000000..b6f1df2 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Conversations/ConversationRowView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct ConversationRowView: View { + let conversation: Conversation + let currentUserId: String + let isOnline: Bool + let unreadCount: Int + var avatarData: Data? + + var body: some View { + HStack(spacing: 12) { + // Avatar + ZStack(alignment: .bottomTrailing) { + CircularAvatarView( + name: conversation.displayName(currentUserId: currentUserId), + imageData: avatarData, + 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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Conversations/NewConversationSheet.swift b/ios_client 0.8.5/Kecalek/Views/Conversations/NewConversationSheet.swift new file mode 100644 index 0000000..c0ffb31 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Conversations/NewConversationSheet.swift @@ -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 + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Groups/CreateGroupSheet.swift b/ios_client 0.8.5/Kecalek/Views/Groups/CreateGroupSheet.swift new file mode 100644 index 0000000..cbeaa75 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Groups/CreateGroupSheet.swift @@ -0,0 +1,4 @@ +import SwiftUI + +// Group creation is handled within NewConversationSheet via the isGroup toggle. +// This file exists for potential future separation. diff --git a/ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift b/ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift new file mode 100644 index 0000000..da1fb3c --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift @@ -0,0 +1,301 @@ +import SwiftUI +import PhotosUI + +struct GroupInfoView: View { + @Binding var conversation: Conversation + var appState: AppState + var conversationListVM: ConversationListVM? + @State private var showRenameSheet = false + @State private var showLeaveConfirm = false + @State private var showAddMember = false + @State private var showRemoveConfirm = false + @State private var showAvatarPicker = false + @State private var newName = "" + @State private var addMemberEmail = "" + @State private var memberToRemove: ConversationMember? + @State private var errorMessage: String? + @State private var showError = false + @State private var isUploadingAvatar = false + @State private var groupAvatarData: Data? + @Environment(\.dismiss) private var dismiss + + private var isCreator: Bool { + conversation.createdBy == appState.currentUser?.id + } + + private func refreshConversation() async { + let convs = await appState.chatClient.listConversations() + if let updated = convs.first(where: { $0.id == conversation.id }) { + conversation = updated + } + await conversationListVM?.forceRefresh(chatClient: appState.chatClient) + } + + var body: some View { + NavigationStack { + List { + // Avatar section + Section { + HStack { + Spacer() + VStack(spacing: 8) { + CircularAvatarView( + name: conversation.name ?? "Group", + imageData: groupAvatarData ?? conversationListVM?.avatarCache[conversation.id], + 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("Add Member") { + addMemberEmail = "" + showAddMember = true + } + + Button("Rename Group") { + newName = conversation.name ?? "" + showRenameSheet = true + } + + Button { + showAvatarPicker = true + } label: { + HStack { + Text("Change Avatar") + if isUploadingAvatar { + Spacer() + ProgressView() + .scaleEffect(0.8) + } + } + } + .disabled(isUploadingAvatar) + } + } + + // 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) + } + } + .contextMenu { + if isCreator && member.userId != appState.currentUser?.id { + Button("Remove from Group", role: .destructive) { + memberToRemove = member + showRemoveConfirm = true + } + } + } + } + } + + // 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("Remove Member?", isPresented: $showRemoveConfirm) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { + if let member = memberToRemove { + Task { + let (success, msg) = await appState.chatClient.removeMember( + convId: conversation.id, userId: member.userId + ) + if success { + await refreshConversation() + } else { + errorMessage = msg + showError = true + } + } + } + } + } message: { + if let member = memberToRemove { + Text("Remove \(member.username) from the group?") + } + } + .alert("Add Member", isPresented: $showAddMember) { + TextField("Email", text: $addMemberEmail) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + Button("Cancel", role: .cancel) {} + Button("Add") { + let email = addMemberEmail.trimmingCharacters(in: .whitespacesAndNewlines) + guard !email.isEmpty else { return } + Task { + let (success, msg) = await appState.chatClient.addMember( + convId: conversation.id, email: email + ) + if success { + await refreshConversation() + } else { + errorMessage = msg + showError = true + } + } + } + } + .alert("Rename Group", isPresented: $showRenameSheet) { + TextField("Group Name", text: $newName) + Button("Cancel", role: .cancel) {} + Button("Rename") { + let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + // Optimistic update - immediately reflect in UI + conversation.name = trimmedName + Task { + let (success, _) = await appState.chatClient.renameConversation(convId: conversation.id, name: trimmedName) + if success { + await refreshConversation() + } else { + // Revert on failure + await refreshConversation() + } + } + } + } + .alert("Error", isPresented: $showError) { + Button("OK") {} + } message: { + Text(errorMessage ?? "") + } + .sheet(isPresented: $showAvatarPicker) { + AvatarPickerView { imageData in + isUploadingAvatar = true + Task { + let success = await appState.chatClient.updateGroupAvatar( + convId: conversation.id, imageData: imageData + ) + isUploadingAvatar = false + if success { + // Update local avatar cache (memory + disk) + groupAvatarData = imageData + conversationListVM?.updateAvatar(convId: conversation.id, data: imageData) + await refreshConversation() + } else { + errorMessage = "Failed to update avatar" + showError = true + } + } + } + } + .task { + // Load current group avatar + if groupAvatarData == nil, let cached = conversationListVM?.avatarCache[conversation.id] { + groupAvatarData = cached + } else if groupAvatarData == nil { + groupAvatarData = await appState.chatClient.getGroupAvatar(convId: conversation.id) + } + await refreshConversation() + } + } + } +} + +// MARK: - Avatar Picker (PHPicker wrapper for avatar selection) + +private struct AvatarPickerView: UIViewControllerRepresentable { + let onImagePicked: (Data) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onImagePicked: onImagePicked) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let onImagePicked: (Data) -> Void + + init(onImagePicked: @escaping (Data) -> Void) { + self.onImagePicked = onImagePicked + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard let provider = results.first?.itemProvider, + provider.canLoadObject(ofClass: UIImage.self) else { + picker.dismiss(animated: true) + return + } + provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in + guard let uiImage = image as? UIImage, + let data = uiImage.jpegData(compressionQuality: 0.8) else { + DispatchQueue.main.async { picker.dismiss(animated: true) } + return + } + DispatchQueue.main.async { + self?.onImagePicked(data) + picker.dismiss(animated: true) + } + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift b/ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift new file mode 100644 index 0000000..5f9c877 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift @@ -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) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Profile/EditProfileView.swift b/ios_client 0.8.5/Kecalek/Views/Profile/EditProfileView.swift new file mode 100644 index 0000000..b993ca0 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Profile/EditProfileView.swift @@ -0,0 +1,4 @@ +import SwiftUI + +// Profile editing is handled within ProfileView when isOwnProfile = true. +// This file exists for potential future separation. diff --git a/ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift b/ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..542c64f --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift @@ -0,0 +1,277 @@ +import SwiftUI +import PhotosUI +import UIKit + +struct ProfileView: View { + var appState: AppState + var isOwnProfile: Bool + var userId: String? + @State private var viewModel = ProfileViewModel() + @State private var showLogoutConfirm = false + @State private var showAvatarPicker = false + @State private var showAuthorizeDevice = false + @State private var showRotateKeys = false + @State private var rotatePassword = "" + @State private var isRotating = false + @State private var rotateMessage: String? + @State private var rotateIsError = false + @State private var showChangeUsername = false + @State private var newUsername = "" + @State private var showChangePassword = false + @State private var oldPassword = "" + @State private var newPassword = "" + @State private var confirmNewPassword = "" + @State private var showVerification = false + @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") { + showAvatarPicker = true + } + .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 !isOwnProfile, let uid = userId { + Section("Security") { + NavigationLink { + SafetyNumberView( + peerUserId: uid, + peerUsername: viewModel.profile?.username ?? "User", + chatClient: appState.chatClient + ) + } label: { + Label("Verify Identity", systemImage: "checkmark.shield") + } + } + } + + if let error = viewModel.errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + } + } + + if isOwnProfile { + Section("Account") { + Button { + newUsername = viewModel.profile?.username ?? "" + showChangeUsername = true + } label: { + Label("Change Username", systemImage: "person.text.rectangle") + } + + Button { + showChangePassword = true + } label: { + Label("Change Password", systemImage: "key") + } + } + + Section("Security") { + Button { + showAuthorizeDevice = true + } label: { + Label("Authorize New Device", systemImage: "iphone.badge.checkmark") + } + + Button { + showRotateKeys = true + } label: { + Label("Rotate Keys", systemImage: "arrow.triangle.2.circlepath") + } + } + + Section { + Button(role: .destructive) { + showLogoutConfirm = true + } label: { + HStack { + Spacer() + Text("Logout") + Spacer() + } + } + } + } + } + .navigationTitle(isOwnProfile ? "My Profile" : "Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if isOwnProfile { + Button("Save") { + Task { + let success = await viewModel.saveProfile(chatClient: appState.chatClient) + if success { + dismiss() + } + } + } + .disabled(viewModel.isSaving) + } + } + ToolbarItem(placement: .topBarLeading) { + if !isOwnProfile { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + } + } + } + .alert("Logout", isPresented: $showLogoutConfirm) { + Button("Cancel", role: .cancel) {} + Button("Logout", role: .destructive) { + Task { + await appState.logout() + } + } + } message: { + Text("Are you sure you want to logout?") + } + .sheet(isPresented: $showAvatarPicker) { + ImagePickerView { data in + Task { + await viewModel.uploadAvatar(imageData: data, chatClient: appState.chatClient) + } + } + } + .sheet(isPresented: $showAuthorizeDevice) { + AuthorizeDeviceView(appState: appState) + } + .alert("Rotate Keys", isPresented: $showRotateKeys) { + SecureField("Password", text: $rotatePassword) + Button("Cancel", role: .cancel) { rotatePassword = "" } + Button("Rotate") { + Task { + isRotating = true + let (success, msg) = await appState.chatClient.rotateKeys(password: rotatePassword) + rotatePassword = "" + isRotating = false + rotateMessage = msg + rotateIsError = !success + } + } + } message: { + Text("Enter your password to generate new keys. All other devices will be disconnected.") + } + .alert(rotateIsError ? "Error" : "Success", isPresented: Binding( + get: { rotateMessage != nil }, + set: { if !$0 { rotateMessage = nil } } + )) { + Button("OK") { rotateMessage = nil } + } message: { + Text(rotateMessage ?? "") + } + .alert("Change Username", isPresented: $showChangeUsername) { + TextField("New username", text: $newUsername) + Button("Cancel", role: .cancel) { newUsername = "" } + Button("Change") { + Task { + let success = await viewModel.changeUsername(newUsername: newUsername, chatClient: appState.chatClient) + if success { + await viewModel.loadProfile(chatClient: appState.chatClient) + } + newUsername = "" + } + } + } message: { + Text("Enter a new display name.") + } + .alert("Change Password", isPresented: $showChangePassword) { + SecureField("Current password", text: $oldPassword) + SecureField("New password", text: $newPassword) + SecureField("Confirm new password", text: $confirmNewPassword) + Button("Cancel", role: .cancel) { + oldPassword = "" + newPassword = "" + confirmNewPassword = "" + } + Button("Change") { + Task { + guard newPassword == confirmNewPassword else { + viewModel.errorMessage = "Passwords don't match" + return + } + _ = await viewModel.changePassword( + oldPassword: oldPassword, newPassword: newPassword, + chatClient: appState.chatClient + ) + oldPassword = "" + newPassword = "" + confirmNewPassword = "" + } + } + } message: { + Text("Enter your current password and a new password.") + } + .task { + await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient) + } + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Verification/QRCodeScannerView.swift b/ios_client 0.8.5/Kecalek/Views/Verification/QRCodeScannerView.swift new file mode 100644 index 0000000..899cf39 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Verification/QRCodeScannerView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import AVFoundation + +struct QRCodeScannerView: View { + let onScan: (Data) -> Void + @Environment(\.dismiss) private var dismiss + @State private var cameraPermission: CameraPermission = .unknown + + enum CameraPermission { + case unknown, granted, denied + } + + var body: some View { + NavigationStack { + ZStack { + switch cameraPermission { + case .unknown: + ProgressView("Requesting camera access...") + case .denied: + VStack(spacing: 16) { + Image(systemName: "camera.fill") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("Camera access is required to scan QR codes.") + .multilineTextAlignment(.center) + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .buttonStyle(.borderedProminent) + } + .padding() + case .granted: + ScannerRepresentable(onScan: { data in + onScan(data) + dismiss() + }) + .ignoresSafeArea() + } + } + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + } + } + .task { + await checkCameraPermission() + } + } + + private func checkCameraPermission() async { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + cameraPermission = .granted + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: .video) + cameraPermission = granted ? .granted : .denied + default: + cameraPermission = .denied + } + } +} + +// MARK: - Scanner UIKit wrapper + +private struct ScannerRepresentable: UIViewControllerRepresentable { + let onScan: (Data) -> Void + + func makeUIViewController(context: Context) -> ScannerViewController { + let vc = ScannerViewController() + vc.onScan = onScan + return vc + } + + func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {} +} + +final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onScan: ((Data) -> Void)? + private var captureSession: AVCaptureSession? + private var previewLayer: AVCaptureVideoPreviewLayer? + private var hasScanned = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupCamera() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + captureSession?.stopRunning() + } + + private func setupCamera() { + let session = AVCaptureSession() + captureSession = session + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { + return + } + session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.addSublayer(layer) + previewLayer = layer + + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + guard !hasScanned, + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr else { return } + + hasScanned = true + captureSession?.stopRunning() + + // QR codes contain base64-encoded binary data (matching Python client) + if let stringValue = object.stringValue, + let data = Data(base64Encoded: stringValue) { + onScan?(data) + } + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Verification/SafetyNumberView.swift b/ios_client 0.8.5/Kecalek/Views/Verification/SafetyNumberView.swift new file mode 100644 index 0000000..389e51b --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Verification/SafetyNumberView.swift @@ -0,0 +1,144 @@ +import SwiftUI +import CoreImage.CIFilterBuiltins + +struct SafetyNumberView: View { + let peerUserId: String + let peerUsername: String + var chatClient: ChatClient + @State private var vm = VerificationVM() + @State private var showQRScanner = false + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Verification status badge + VerificationStatusView(status: vm.verificationStatus) + .padding(.top) + + // Safety number + if let safetyNumber = vm.safetyNumber { + VStack(spacing: 8) { + Text("Safety Number") + .font(.headline) + + Text("If both you and \(peerUsername) see the same number, your communication is secure.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Text(safetyNumber) + .font(.system(.title2, design: .monospaced)) + .padding() + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + + // QR Code + if let qrData = vm.qrCodeData { + VStack(spacing: 8) { + Text("Your QR Code") + .font(.headline) + + if let qrImage = generateQRCode(from: qrData) { + Image(uiImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .padding() + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } + + // Fingerprints + VStack(spacing: 12) { + if let myFP = vm.myFingerprint { + VStack(spacing: 4) { + Text("Your Fingerprint") + .font(.subheadline.bold()) + Text(myFP) + .font(.system(.caption, design: .monospaced)) + } + } + + if let peerFP = vm.peerFingerprint { + VStack(spacing: 4) { + Text("\(peerUsername)'s Fingerprint") + .font(.subheadline.bold()) + Text(peerFP) + .font(.system(.caption, design: .monospaced)) + } + } + } + + // Actions + VStack(spacing: 12) { + if vm.verificationStatus != "verified" { + Button { + Task { await vm.verifyContact(peerUserId: peerUserId, chatClient: chatClient) } + } label: { + Label("Mark as Verified", systemImage: "checkmark.shield.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Button { + showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } else { + Button(role: .destructive) { + Task { await vm.unverifyContact(peerUserId: peerUserId, chatClient: chatClient) } + } label: { + Label("Remove Verification", systemImage: "xmark.shield") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + } + .padding(.horizontal) + + // Scan result + if let result = vm.scanResult { + Text(result) + .font(.callout) + .foregroundStyle(vm.scanSuccess == true ? .green : .red) + .padding() + } + } + .padding() + } + .navigationTitle("Verify \(peerUsername)") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showQRScanner) { + QRCodeScannerView { scannedData in + showQRScanner = false + Task { await vm.verifyQRCode(data: scannedData, chatClient: chatClient) } + } + } + .task { + await vm.loadVerification(peerUserId: peerUserId, chatClient: chatClient) + } + } + + private func generateQRCode(from data: Data) -> UIImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + // Base64-encode binary data — raw binary gets corrupted by QR readers (UTF-8 re-encoding) + let b64String = data.base64EncodedString() + filter.setValue(b64String.data(using: .ascii), forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + guard let outputImage = filter.outputImage else { return nil } + let scale = 200.0 / outputImage.extent.width + let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil } + return UIImage(cgImage: cgImage) + } +} diff --git a/ios_client 0.8.5/Kecalek/Views/Verification/VerificationStatusView.swift b/ios_client 0.8.5/Kecalek/Views/Verification/VerificationStatusView.swift new file mode 100644 index 0000000..a54f276 --- /dev/null +++ b/ios_client 0.8.5/Kecalek/Views/Verification/VerificationStatusView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct VerificationStatusView: View { + let status: String // "verified", "trusted", "unverified" + + var body: some View { + HStack(spacing: 6) { + Image(systemName: iconName) + .foregroundStyle(iconColor) + Text(displayText) + .font(.subheadline.bold()) + .foregroundStyle(iconColor) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(iconColor.opacity(0.12)) + .clipShape(Capsule()) + } + + private var iconName: String { + switch status { + case "verified": return "checkmark.shield.fill" + case "trusted": return "shield.fill" + default: return "shield.slash" + } + } + + private var iconColor: Color { + switch status { + case "verified": return .green + case "trusted": return .blue + default: return .secondary + } + } + + private var displayText: String { + switch status { + case "verified": return "Verified" + case "trusted": return "Trusted" + default: return "Unverified" + } + } +} diff --git a/ios_client 0.8.5/SECURITY_REVIEW.md b/ios_client 0.8.5/SECURITY_REVIEW.md new file mode 100644 index 0000000..ca2d455 --- /dev/null +++ b/ios_client 0.8.5/SECURITY_REVIEW.md @@ -0,0 +1,204 @@ +# Security Review: Kecalek iOS v0.8.5 + +**Datum:** 14. března 2026 +**Platforma:** iOS 26+ / Swift 6 +**Architektura:** MVVM + Actor-based concurrency +**Rozsah:** 57 Swift souborů, ~11 500 řádků kódu +**Typ aplikace:** End-to-end encrypted messaging (Signal Protocol) + +--- + +## 1. Shrnutí + +Kecalek je kryptograficky vyspělá messaging aplikace postavená na Signal Protocol (Double Ratchet, X3DH, Sender Keys). Využívá výhradně nativní Apple frameworky bez externích závislostí. Celková bezpečnostní úroveň je vysoká — během review byly identifikovány převážně provozní nedostatky, nikoli fundamentální architekturální chyby. + +--- + +## 2. Kryptografická architektura + +### 2.1 Protokoly a algoritmy + +| Komponenta | Implementace | Hodnocení | +|---|---|---| +| Key Exchange | X3DH (Extended Triple Diffie-Hellman) | Odpovídá Signal specifikaci | +| DM šifrování | Double Ratchet (AES-256-GCM) | Forward secrecy zajištěna | +| Skupinové šifrování | Sender Keys (AES-256-GCM) | Distribuce přes pairwise DM | +| Autentizace | RSA-4096 challenge-response (PSS-SHA256) | Silné | +| Úložiště klíčů | PBKDF2-HMAC-SHA256 (600 000 iterací) + AES-256-GCM | Odpovídající | +| Identity keys | Ed25519 (podepisování) + X25519 (key agreement) | Standard | +| KDF | HKDF-SHA256 | Standard | +| RNG | SecRandomCopyBytes | Kryptograficky bezpečný | +| Message padding | Bucket-based (64B–64KB) | Skrývá délku zpráv | + +### 2.2 Správa klíčů + +- **Signed Pre-Key (SPK):** rotace každých 7 dní s grace periodem pro předchozí SPK +- **One-Time Pre-Keys (OPK):** batch 50 kusů, doplnění při prahu 20 +- **Max skip:** 256 zpráv na chain (ochrana proti DoS přes vynucené přeskakování) +- **TOFU registry:** sledování identity klíčů kontaktů s možností manuální verifikace +- **Safety numbers:** 60místné číslo (SHA-512, 5 200 iterací) + QR kód verifikace + +### 2.3 Pozitivní nálezy + +- Žádné použití zastaralých algoritmů (MD5, SHA1, DES, RC4) +- Žádné vlastní kryptografické primitivy — vše přes CryptoKit a Security framework +- Správná implementace AAD (Associated Authenticated Data) v AES-GCM +- Snapshot/restore mechanismus pro atomické ratchet operace (M9 fix) +- Self-encryption pro multi-device synchronizaci vlastních zpráv + +--- + +## 3. Nalezené zranitelnosti a nápravná opatření + +### 3.1 KRITICKÉ — Opraveno + +#### 3.1.1 Únik kryptografických klíčů přes debug výpisy + +**Popis:** 160 `print()` volání v produkčním kódu vypisovalo citlivý kryptografický materiál do systémových logů — root keys, chain keys, message keys, identity keys, shared secrets, DH výstupy, nonce hodnoty. + +**Riziko:** Na iOS jsou systémové logy čitelné přes USB (Console.app), diagnostické profily a potenciálně dalšími aplikacemi. Útočník s fyzickým přístupem k zařízení nebo se schopností číst logy mohl získat kompletní kryptografický stav relace. + +**Dotčené soubory:** + +- `Core/ChatClient.swift` — 118 výskytů (session data, OPK IDs, decryption debug) +- `Crypto/DoubleRatchet.swift` — 11 výskytů (root keys, DH public keys, message keys, nonce) +- `Crypto/X3DH.swift` — 9 výskytů (identity keys, ephemeral keys, DH outputs, shared secrets) +- `ViewModels/AuthViewModel.swift` — 7 výskytů +- `AppState.swift` — 7 výskytů +- `Core/MessageCache.swift` — 3 výskyty +- `ViewModels/ChatViewModel.swift` — 2 výskyty +- `ViewModels/ConversationListVM.swift` — 3 výskyty + +**Náprava:** Všech 160 print statements zabaleno do `#if DEBUG` / `#endif` bloků. V release buildech nebude žádný kryptografický materiál logován. + +#### 3.1.2 Insecure TLS — bypass ověření certifikátu a volitelné TLS + +**Popis:** Parametr `tlsInsecure` umožňoval kompletní vypnutí TLS certificate verification. Navíc bylo TLS volitelné — uživatel mohl v UI vypnout šifrování transportní vrstvy přes toggle "Use TLS". + +**Riziko:** MitM útok — útočník na síti mohl odposlouchávat a modifikovat veškerou komunikaci, včetně challenge-response autentizace a metadat. + +**Dotčené soubory:** + +- `Network/ConnectionManager.swift` +- `Core/ChatClient.swift` +- `ViewModels/AuthViewModel.swift` +- `Views/Auth/LoginView.swift` +- `Views/Auth/PairingView.swift` +- `Core/KeychainService.swift` + +**Náprava:** TLS je nyní povinné bez výjimek. Parametry `useTLS` a `tlsInsecure` kompletně odstraněny z celého codebase. Toggle "Use TLS" odstraněn z UI. `ConnectionManager.connect()` vždy navazuje TLS spojení. Credentials v Keychainu již neukládají `useTLS` flag. + +--- + +### 3.2 VYSOKÉ — Opraveno + +#### 3.2.1 Heslo v paměti po úspěšném přihlášení + +**Popis:** `AuthViewModel` uchovával heslo jako `String` property i po úspěšném loginu. Swift String je immutable a garbage collector jej může držet v paměti neomezeně dlouho. + +**Riziko:** Memory dump útok — při fyzickém přístupu k zařízení nebo exploitu s přístupem do paměti procesu mohl útočník extrahovat heslo. + +**Dotčený soubor:** `ViewModels/AuthViewModel.swift` + +**Náprava:** Properties `password` a `confirmPassword` jsou vynulovány ihned po úspěšném přihlášení a uložení do Keychainu. + +**Poznámka:** Swift `String` neumožňuje bezpečné přepisování paměti (na rozdíl od `UnsafeMutableBufferPointer`). Kompletní mitigace by vyžadovala vlastní typ pro citlivé řetězce. Aktuální řešení minimalizuje dobu expozice. + +--- + +### 3.3 STŘEDNÍ — Opraveno + +#### 3.3.1 Clipboard bez automatického vymazání + +**Popis:** Funkce kopírování zprávy zapisovala text do systémového clipboardu (`UIPasteboard.general`) bez časového omezení. + +**Riziko:** Jiné aplikace mohou číst obsah clipboardu (iOS 14+ zobrazuje notifikaci, ale nezabraňuje přístupu). Citlivý obsah zpráv mohl zůstat v clipboardu neomezeně. + +**Dotčený soubor:** `Views/Chat/MessageBubbleView.swift` + +**Náprava:** Přidán automatický clear clipboardu po 30 sekundách s kontrolou, že obsah nebyl mezitím uživatelem přepsán. + +#### 3.3.2 Komentované vývojové IP adresy + +**Popis:** `Constants.swift` obsahoval komentované dev server adresy (`192.168.88.65`, `85.71.71.188`), které odhalovaly interní síťovou infrastrukturu. + +**Dotčený soubor:** `Utilities/Constants.swift` + +**Náprava:** Komentované IP adresy odstraněny. + +--- + +### 3.4 STŘEDNÍ — Neřešeno (doporučení) + +#### 3.4.1 Chybějící certificate pinning + +**Popis:** Aplikace se spoléhá výhradně na systémovou validaci TLS certifikátů. Neimplementuje certificate pinning ani SPKI pinning. + +**Riziko:** Při kompromitaci certifikační autority, na enterprise-managed zařízeních s vlastním root CA, nebo při state-level útoku může útočník provést MitM. Dopady jsou omezené díky E2EE (obsah zpráv zůstává chráněn), ale metadata (kdo s kým komunikuje, timing) by byla vystavena. + +**Doporučení:** Implementovat SPKI pinning pro produkční server `chat.ai-tech.news` pomocí Network.framework `sec_protocol_options_set_verify_block` s vlastní validací veřejného klíče serveru. + +#### 3.4.2 Chybějící jailbreak detekce + +**Popis:** Aplikace nedetekuje jailbreaknutá zařízení a nevaruje uživatele. + +**Riziko:** Na jailbreaknutém zařízení jsou oslabeny iOS sandbox protekce — jiné aplikace mohou přistupovat k souborům aplikace, Keychain items mohou být extrahovány, a iOS file protection je částečně neúčinná. + +**Doporučení:** Implementovat detekci (existence `/Applications/Cydia.app`, zápis mimo sandbox, dynamické knihovny) a zobrazit varování uživateli. Neblokovat použití — pouze informovat o riziku. + +--- + +## 4. Pozitivní bezpečnostní nálezy + +### 4.1 Keychain + +Implementace v `KeychainService.swift` je správná: + +- Přístupnost: `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` +- Biometrická ochrana: `.biometryAny` (Face ID / Touch ID) +- Credentials nejsou exportovatelné na jiná zařízení +- Správné error handling pro biometrické selhání + +### 4.2 File protection + +Všechny soubory na disku používají `.completeFileProtection` — jsou šifrované iOS file protection a přístupné pouze když je zařízení odemčené. + +### 4.3 Šifrování lokálních dat + +Všechny persistentní soubory (sessions, sender keys, message cache, conversation cache, avatary, TOFU registry) jsou šifrované AES-256-GCM s klíčem derivovaným přes HKDF z identity private key. + +### 4.4 Žádné externí závislosti + +Aplikace nepoužívá žádné third-party knihovny (CocoaPods, SPM, Carthage). Veškerá kryptografie běží přes nativní Apple frameworky (CryptoKit, Security, CommonCrypto). To eliminuje supply chain rizika. + +### 4.5 Žádné WebView + +Celé UI je nativní SwiftUI/UIKit. Absence WebView eliminuje kategorii XSS a JavaScript injection zranitelností. + +### 4.6 Brute-force ochrana + +Login implementuje exponenciální backoff (2^n sekund, max 300s) při neúspěšných pokusech. Server může vyžadovat PoW challenge při registračních surge. + +### 4.7 Bezpečná registrace + +Registrační flow zahrnuje email verifikaci a volitelný SHA-256 Proof-of-Work challenge jako ochranu proti automatizovaným registracím. + +### 4.8 Actor isolation + +`ChatClient` je implementován jako Swift actor, což garantuje thread-safe přístup ke kryptografickému stavu bez možnosti race conditions. + +--- + +## 5. Shrnutí změn + +| # | Závažnost | Nález | Stav | +|---|---|---|---| +| 3.1.1 | Kritická | Debug výpisy kryptografických klíčů (160×) | **Opraveno** | +| 3.1.2 | Kritická | TLS insecure bypass + volitelné TLS | **Opraveno** | +| 3.2.1 | Vysoká | Heslo zůstává v paměti po loginu | **Opraveno** | +| 3.3.1 | Střední | Clipboard bez auto-clear | **Opraveno** | +| 3.3.2 | Střední | Dev IP adresy v kódu | **Opraveno** | +| 3.4.1 | Střední | Chybějící certificate pinning | Doporučení | +| 3.4.2 | Střední | Chybějící jailbreak detekce | Doporučení | + +**Celkové hodnocení po opravách:** Aplikace splňuje vysoké bezpečnostní standardy pro E2EE messaging. Kryptografická architektura je solidní a odpovídá Signal Protocol specifikaci. Zbývající doporučení se týkají defense-in-depth opatření.