ios_client
This commit is contained in:
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -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 <user> -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 <tls-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_<feature>.py` for focused checks and `<scenario>_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.
|
||||||
441
README.md
441
README.md
@@ -1,333 +1,246 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
# Kecalek_python
|
|
||||||
|
|
||||||
=======
|
|
||||||
# Encrypted Chat
|
# Encrypted Chat
|
||||||
|
|
||||||
End-to-end encrypted chat s forward secrecy (X3DH + Double Ratchet, Signal Protocol).
|
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.
|
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
|
## Soubory
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
| Soubor | Účel |
|
| Soubor | Řádky | Účel |
|
||||||
|--------|------|
|
|--------|-------|------|
|
||||||
| `server.py` | Asyncio TCP server, handler dispatch, rate limiting, notifikace |
|
| `server.py` | ~2 900 | Asyncio TCP server, 45 handlerů, rate limiting, 5 asyncio.Lock guardů, real-time notifikace |
|
||||||
| `db.py` | MySQL CRUD, jedna connection na volání |
|
| `db.py` | ~1 700 | MySQL CRUD, connection pooling (pool_size=10), phantom users, reactions/pins CRUD |
|
||||||
| `schema.sql` | MySQL schéma (users, conversations, messages, ...) |
|
| `schema.sql` | ~190 | MySQL schéma (14 tabulek) |
|
||||||
|
|
||||||
### Klient
|
### Klient
|
||||||
| Soubor | Účel |
|
| Soubor | Řádky | Účel |
|
||||||
|--------|------|
|
|--------|-------|------|
|
||||||
| `gui_client.py` | PyQt6 GUI |
|
| `gui_client.py` | ~6 300 | PyQt6 GUI — dark/light téma, widget-based message bubbles, verifikace kontaktů, privacy overlay |
|
||||||
| `client.py` | CLI klient |
|
| `client.py` | ~900 | CLI klient — 23 menu opcí |
|
||||||
| `chat_core.py` | Logika klienta — session management, šifrování, lokální klíče |
|
| `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)
|
### Sdílené (server + klient)
|
||||||
| Soubor | Účel |
|
| Soubor | Účel |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `crypto_utils.py` | Ed25519, X25519, AES-256-GCM, HKDF, PBKDF2, X3DH, Double Ratchet (state rollback), Sender Keys (state rollback), ECP1 key encryption |
|
| `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` | Newline-delimited JSON protokol, base64 encoding |
|
| `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
|
## Quick Start
|
||||||
|
|
||||||
1. `pip install -r requirements.txt`
|
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`
|
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í
|
## Jak funguje šifrování
|
||||||
|
|
||||||
### Klíče na uživatele
|
### Klíče na uživatele
|
||||||
| Klíč | Typ | Účel |
|
| Klíč | Typ | Účel |
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| RSA-4096 | Asymetrický | Pouze login challenge-response. Šifrovaný PBKDF2 (600k iterací) + AES-256-GCM. |
|
| 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ý PBKDF2 (600k iterací) + 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 pro in-flight X3DH. |
|
| 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) |
|
| 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
|
### DM (1:1 zprávy) — X3DH + Double Ratchet
|
||||||
1. Alice chce napsat Bobovi poprvé → stáhne jeho key bundle (IK, SPK, OPK) ze serveru.
|
1. Alice stáhne Bobovy per-device key bundles (IK, SPK, OPK) → X3DH per device → shared secret per device.
|
||||||
2. X3DH: 4 DH výpočty → shared secret.
|
2. Double Ratchet inicializován ze shared secret — jedna session per (user, device).
|
||||||
3. Double Ratchet inicializován ze shared secret.
|
3. Každá zpráva: symmetric ratchet (HMAC chain) → message key → AES-256-GCM.
|
||||||
4. 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. 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. Per-recipient ciphertext — každý recipient má vlastní šifrovaný blob.
|
6. Self-encrypted kopie s SELF_DEVICE_ID sentinel, čitelná všemi vlastními zařízeními.
|
||||||
7. Při selhání dešifrování: automatický rollback stavu ratchetu (snapshot/restore).
|
|
||||||
|
|
||||||
### Skupiny — Sender Keys
|
### Skupiny — Sender Keys
|
||||||
1. Každý člen má vlastní sender key chain pro skupinu.
|
1. Každý odesílatel má vlastní SenderKeyState per group.
|
||||||
2. Sender key se distribuuje ostatním členům přes pairwise Double Ratchet (jako DM).
|
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.
|
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íčů
|
### Lokální úložiště klíčů
|
||||||
```
|
```
|
||||||
~/.encrypted_chat/{email}/
|
~/.encrypted_chat/{email}/
|
||||||
private.pem # RSA (login) — ECP1 formát s heslem, PEM bez hesla
|
private.pem / public.pem — RSA (login, ECP1 formát)
|
||||||
public.pem # RSA (login)
|
identity_private.bin / _public.bin — Ed25519 (ECP1 formát)
|
||||||
identity_private.bin # Ed25519 — ECP1 formát s heslem, 32B raw bez hesla
|
device_id.txt — UUID tohoto zařízení
|
||||||
identity_public.bin # Ed25519
|
spk_private.bin / spk_id.txt — Aktuální SPK (AES-256-GCM)
|
||||||
device_id.txt # UUID tohoto zařízení
|
prev_spk_private.bin / prev_spk_id.txt — Předchozí SPK, grace period
|
||||||
spk_private.bin # Aktuální signed prekey (šifrovaný AES-256-GCM)
|
opk_private/{opk_id}.bin — One-time prekeys (AES-256-GCM)
|
||||||
spk_id.txt
|
sessions/{uid}_{did}.bin — Double Ratchet stavy (AES-256-GCM)
|
||||||
prev_spk_private.bin # Předchozí SPK, grace period (šifrovaný AES-256-GCM)
|
sender_keys/{conv_id}.bin — Vlastní sender keys
|
||||||
prev_spk_id.txt
|
sender_keys_recv/{conv}_{uid}_{did}.bin — Přijaté sender keys
|
||||||
opk_private/ # One-time prekeys (šifrované AES-256-GCM)
|
known_identity_keys.bin — TOFU registr (AES-256-GCM)
|
||||||
{opk_id}.bin
|
verified_contacts.bin — Explicitní verifikace (AES-256-GCM)
|
||||||
login_lockout.json # Brute-force lockout stav (failed_attempts, locked_until)
|
message_cache/{conv_id}.bin — Šifrovaný message cache
|
||||||
sessions/ # Double Ratchet stavy (šifrované AES-256-GCM)
|
login_lockout.json — Brute-force lockout stav
|
||||||
{user_id}_{device_id}.bin
|
|
||||||
sender_keys/ # Vlastní sender keys pro skupiny
|
|
||||||
{conv_id}.bin
|
|
||||||
sender_keys_recv/ # Přijaté sender keys od ostatních
|
|
||||||
{conv_id}_{sender_id}_{device_id}.bin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bezpečnostní hardening
|
## Bezpečnostní hardening
|
||||||
|
|
||||||
### Šifrování privátních klíčů na disku (ECP1 formát)
|
### Šifrování privátních klíčů (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)
|
||||||
- **PBKDF2-HMAC-SHA256** s 600 000 iteracemi (OWASP 2023 doporučuje 480k+)
|
- **AES-256-GCM**, magic bytes "ECP1" jako AAD
|
||||||
- **AES-256-GCM** pro šifrování, magic bytes "ECP1" jako AAD
|
|
||||||
- **Formát:** `ECP1(4B) + salt(16B) + nonce(12B) + ciphertext+tag`
|
- **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
|
### Lokální šifrování dat
|
||||||
SPK a OPK privátní klíče jsou šifrované AES-256-GCM klíčem `_local_key` (HKDF z Ed25519 identity key):
|
- Session/sender key soubory, OPK, SPK, message cache, verifikační soubory — AES-256-GCM klíčem z HKDF(identity_key)
|
||||||
- Při save: `_encrypt_local(raw, local_key)` → `nonce(12B) + tag(16B) + ciphertext`
|
- `chmod 0o700` na adresáře, `0o600` na soubory
|
||||||
- Při load: `_decrypt_local()` s transparentní migrací — pokud dešifrování selže, načte jako plaintext a uloží šifrovaně
|
|
||||||
- Aplikováno na `spk_private.bin`, `prev_spk_private.bin`, `opk_private/*.bin`
|
|
||||||
|
|
||||||
### Brute-force ochrana (client-side lockout)
|
### Brute-force ochrana
|
||||||
Po chybném zadání hesla se prodlužuje čas do dalšího pokusu:
|
- Exponenciální backoff: `min(2^N, 300)` sekund po N chybných pokusech
|
||||||
- **Vzorec:** `min(2^N, 300)` sekund, kde N = počet neúspěšných pokusů (2s, 4s, 8s, ... až 5 min)
|
- Aplikováno na login + privacy overlay unlock
|
||||||
- **Stav:** `login_lockout.json` v adresáři klíčů (`failed_attempts`, `locked_until`)
|
|
||||||
- **Aplikováno na:** `ChatClient.login()` (síťový login) + GUI privacy overlay unlock (`_on_unlock_attempt`)
|
|
||||||
- **Reset:** Úspěšné přihlášení smaže lockout soubor
|
|
||||||
- **Defense-in-depth:** Smazání souboru resetuje počítadlo, ale PBKDF2-600k stále zpomaluje každý pokus (~0.5s/pokus)
|
|
||||||
|
|
||||||
### SPK rotace (7 dní)
|
### SPK rotace (7 dní)
|
||||||
Signed Pre-Key se rotuje periodicky:
|
- Automatická rotace s grace periodem pro in-flight X3DH
|
||||||
- Po přihlášení `_ensure_prekeys()` zjistí stáří SPK ze serveru (`spk_created_at`)
|
- Omezuje dopad kompromitace SPK
|
||||||
- Pokud je SPK starší než 7 dní → vygeneruje nový, starý uloží jako grace period
|
|
||||||
- **Grace period:** `prev_spk_private.bin` — pokud příchozí X3DH selže s aktuálním SPK, zkusí předchozí
|
|
||||||
- Omezuje dopad kompromitace SPK — útočník může vytvářet nové sessions max 7 dní
|
|
||||||
|
|
||||||
### Odolnost ratchetu (state rollback)
|
### Ratchet state rollback
|
||||||
Double Ratchet i Sender Keys automaticky rollbackují stav při selhání dešifrování:
|
- Snapshot/restore při selhání dešifrování (DoubleRatchet + SenderKeyState)
|
||||||
- Před modifikací chain keys/counters se vytvoří snapshot
|
|
||||||
- Pokud AES-GCM dešifrování selže (corrupted data, wrong key), stav se obnoví
|
|
||||||
- Session zůstane funkční i po zpracování poškozené zprávy
|
|
||||||
|
|
||||||
## Registrace
|
### 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).
|
### Message padding
|
||||||
2. `register_confirm` → potvrzení kódu.
|
- Bucketed padding (64B–64KB) maskuje délku zpráv
|
||||||
3. Automaticky se vygenerují a uploadnou prekeys (1 SPK + 50 OPKs).
|
|
||||||
4. Login.
|
### 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
|
## Multi-Device Support
|
||||||
|
|
||||||
Pravý multi-device (Signal-like) — každé zařízení má nezávislé Double Ratchet sessions.
|
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)
|
||||||
- **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
|
||||||
- **Per-device prekeys** — každé zařízení má vlastní SPK + OPKs, server vrací `device_bundles` pole
|
- **Per-device sessions** — klíčované `"user_id:device_id"`
|
||||||
- **Per-device sessions** — sessions klíčované `"user_id:device_id"`, nezávislé Double Ratchet instance
|
- **Self-encryption** — statický klíč z identity key (čitelné všemi vlastními zařízeními)
|
||||||
- **Self-encryption** — odesílatel šifruje vlastní kopii statickým klíčem z identity key (čitelné všemi vlastními zařízeními)
|
- **Pairing** — přenos RSA + Ed25519, nové zařízení generuje vlastní SPK + OPKs
|
||||||
- **Notifikace** — `device_entries` pole, klient vybere záznam odpovídající svému device_id
|
|
||||||
|
|
||||||
### Device Pairing (zjednodušený)
|
## Features
|
||||||
|
|
||||||
Nové zařízení získá RSA + Ed25519 identity klíče od existujícího zařízení.
|
### Protokol & šifrování
|
||||||
Přenos šifrovaný RSA-OAEP + AES-GCM přes server (server nevidí klíče).
|
- X3DH + Double Ratchet (DM) s forward secrecy
|
||||||
Nové zařízení si po přihlášení automaticky vygeneruje vlastní SPK + OPKs.
|
- 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.
|
### Komunikace
|
||||||
2. Existující zařízení: `Authorize Device` → zadá kód → odešle RSA + identity klíče.
|
- DM + skupinové konverzace
|
||||||
3. Nové zařízení importuje klíče, přihlásí se, vygeneruje vlastní prekeys.
|
- 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
|
### Skupiny
|
||||||
- Existující DB: spustit `migration_multi_device.sql` (nebo `migration_multi_device_resume.sql` pro idempotentní re-run)
|
- Skupinové pozvánky (accept/decline)
|
||||||
- Čistá DB: `schema.sql` již obsahuje všechny multi-device sloupce
|
- 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
|
### GUI (PyQt6)
|
||||||
jednoho session klíče neodhalí historii — není potřeba re-encryption.
|
- 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
|
## Konfigurace
|
||||||
|
|
||||||
### Server + DB
|
### Server + DB
|
||||||
- `SERVER_HOST` (default `127.0.0.1`), `SERVER_PORT` (default `9999`)
|
- `SERVER_HOST` (default `127.0.0.1`), `SERVER_PORT` (default `9999`)
|
||||||
- `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
|
- `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
|
||||||
|
- `DB_POOL_SIZE` (default `10`)
|
||||||
|
|
||||||
### TLS
|
### TLS
|
||||||
- `TLS_ENABLED` — zapne TLS (default `false`)
|
- `TLS_ENABLED` — zapne TLS (default `false`)
|
||||||
- `TLS_REQUIRED` — vyžaduje TLS_ENABLED, jinak server odmítne start
|
- `TLS_REQUIRED` — vyžaduje TLS_ENABLED
|
||||||
- `TLS_CERT_FILE`, `TLS_KEY_FILE` — cesty k certifikátu a privátnímu klíči (PEM)
|
- `TLS_CERT_FILE`, `TLS_KEY_FILE` — cesty k certifikátu (PEM)
|
||||||
- `TLS_AUTOGEN` — auto-generuje self-signed cert (**jen s `ENVIRONMENT=dev`**)
|
- `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`**)
|
- `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
|
||||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`
|
||||||
- Bez SMTP = dev mód (kód se vrací přímo klientovi).
|
- Bez SMTP = dev mód (kód se vrací přímo klientovi)
|
||||||
|
|
||||||
### Obrázky
|
|
||||||
- `UPLOAD_DIR` (default `uploads`), `MAX_IMAGE_BYTES` (default 5 MB, `0` = bez limitu)
|
|
||||||
|
|
||||||
### Limity
|
### Limity
|
||||||
- `MAX_MESSAGE_BYTES` (default `65536`), `MAX_INPUT_CHARS` (GUI, default `2000`)
|
- `MAX_MESSAGE_BYTES` (default `65536`), `MAX_IMAGE_BYTES` (5 MB), `MAX_FILE_BYTES` (50 MB)
|
||||||
- Rate limity: register 3/min, login 10/min, send_message 20/min, pairing_poll 10/min
|
- `MAX_INPUT_CHARS` (GUI, default `2000`)
|
||||||
- Connection: 20 req/s per connection, max 10 per IP, 200 global
|
- `METADATA_RETENTION_DAYS` (default `90`)
|
||||||
- Pairing TTL: 120s, max 5 failed poll pokusů
|
- Rate limity: register 3/min, login 10/min, send_message 20/min
|
||||||
|
- Connection: 20 req/s, max 10/IP, 200 global
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
- `LOG_LEVEL` (default `INFO`)
|
- `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
|
## Bezpečnostní audit
|
||||||
|
|
||||||
Dva bezpečnostní audity provedeny (kód review). Nalezeno 6 CRITICAL, 12 HIGH, 12 MEDIUM, 8 LOW nálezů.
|
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 |
|
| CRITICAL | 6 | **6** | 0 |
|
||||||
| HIGH | 12 | **11** | 1 (H9 — by-design) |
|
| HIGH | 12 | **11** | 1 (H9 — by-design) |
|
||||||
| MEDIUM | 12 | **10** | 2 (M1 částečně, M6, M7) |
|
| MEDIUM | 12 | **11** | 1 (M7) |
|
||||||
| LOW | 8 | 0 | 8 |
|
| LOW | 8 | **1** | 7 |
|
||||||
|
|
||||||
Detaily viz `CLAUDE.md`.
|
Detaily viz `SECURITY_AUDIT.md` a `CLAUDE.md`.
|
||||||
>>>>>>> d506e65 (initial commit)
|
|
||||||
|
## 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ů
|
||||||
|
|||||||
147
TODO.md
147
TODO.md
@@ -1,22 +1,131 @@
|
|||||||
# TODO
|
# 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`).
|
### HIGH
|
||||||
2. Move cap enforcement into one DB transaction:
|
- [ ] **H9: Self-encryption key** — statický/deterministický klíč z identity key (by-design pro cross-device čtení, architektonické omezení — žádná forward secrecy pro self-copies)
|
||||||
- lock quota row with `SELECT ... FOR UPDATE`
|
|
||||||
- check `used < limit`
|
### MEDIUM
|
||||||
- create phantom user
|
- [ ] **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`.
|
||||||
- increment `used`
|
|
||||||
- commit (or rollback on failure).
|
### LOW (nízké riziko)
|
||||||
3. Handle same-email races using `UNIQUE(email)`:
|
- [ ] L1: Hex string keys v skipped messages dict — timing side-channel (post-auth)
|
||||||
- on duplicate key, do not increment quota
|
- [ ] L2: RatchetHeader redundantní type konverze
|
||||||
- return existing user (or unified error response).
|
- [ ] L3: `notif_label.setText()` vs `setHtml()` křehkost
|
||||||
4. Add periodic reconciliation job:
|
- [ ] L4: SQL column interpolation v `update_user_profile` (whitelist chrání)
|
||||||
- recalculate phantom count from `users`
|
- [ ] L5: TLS cipher suite hardening (Python defaults rozumné, ne explicitní)
|
||||||
- repair `system_quotas.used` if drift is detected.
|
- [ ] L6: Temporary pairing key cleanup z paměti
|
||||||
5. Move phantom creation rate-limits to shared backend (Redis or DB atomic counters), so all server processes enforce the same limits.
|
- [ ] L7: `_user_cache` indefinite growth
|
||||||
6. Add concurrency tests:
|
|
||||||
- multi-process create storm near cap boundary (499/500)
|
## Funkční TODO
|
||||||
- duplicate-email storm
|
|
||||||
- assert `used <= limit` always holds.
|
### 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í)
|
||||||
|
|||||||
27
chat_core.py
27
chat_core.py
@@ -1044,30 +1044,34 @@ class ChatClient:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def register(self, username: str, password: str, email: str) -> tuple[bool, str]:
|
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.username = username
|
||||||
self.email = email
|
self.email = email
|
||||||
pwd_bytes = bytearray(password.encode("utf-8")) if password else None
|
pwd_bytes = bytearray(password.encode("utf-8")) if password else None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# RSA keys for login
|
pwd = bytes(pwd_bytes) if pwd_bytes else None
|
||||||
priv, pub, err = load_keys(email, password=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:
|
if priv is None:
|
||||||
priv, pub = generate_rsa_keypair()
|
priv, pub = generate_rsa_keypair()
|
||||||
save_keys(email, priv, pub, password=bytes(pwd_bytes) if pwd_bytes else None)
|
|
||||||
self.private_key = priv
|
self.private_key = priv
|
||||||
self.public_key = pub
|
self.public_key = pub
|
||||||
|
|
||||||
# Ed25519 identity keys
|
try:
|
||||||
ed_priv, ed_pub = _load_identity_keys(email, password=bytes(pwd_bytes) if pwd_bytes else None)
|
ed_priv, ed_pub = _load_identity_keys(email, password=pwd)
|
||||||
|
except Exception:
|
||||||
|
ed_priv, ed_pub = None, None
|
||||||
if ed_priv is None:
|
if ed_priv is None:
|
||||||
ed_priv, ed_pub = generate_identity_keypair()
|
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_private = ed_priv
|
||||||
self.identity_public = ed_pub
|
self.identity_public = ed_pub
|
||||||
self._cache_key = derive_self_encryption_key(ed_priv)
|
self._cache_key = derive_self_encryption_key(ed_priv)
|
||||||
self._local_key = derive_local_storage_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:
|
finally:
|
||||||
if pwd_bytes:
|
if pwd_bytes:
|
||||||
pwd_bytes[:] = b'\x00' * len(pwd_bytes)
|
pwd_bytes[:] = b'\x00' * len(pwd_bytes)
|
||||||
@@ -1100,6 +1104,7 @@ class ChatClient:
|
|||||||
**extra_fields,
|
**extra_fields,
|
||||||
)
|
)
|
||||||
if start["status"] != "ok":
|
if start["status"] != "ok":
|
||||||
|
self._reg_password = None
|
||||||
return False, start["data"]["message"]
|
return False, start["data"]["message"]
|
||||||
code = start["data"].get("code")
|
code = start["data"].get("code")
|
||||||
if code:
|
if code:
|
||||||
@@ -1109,6 +1114,12 @@ class ChatClient:
|
|||||||
async def confirm_registration(self, email: str, username: str, code: str) -> tuple[bool, str]:
|
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)
|
confirm = await self.send_and_recv("register_confirm", email=email, code=code)
|
||||||
if confirm["status"] == "ok":
|
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
|
# Upload prekeys immediately after registration
|
||||||
await self._generate_and_upload_prekeys()
|
await self._generate_and_upload_prekeys()
|
||||||
return True, f"Registered as '{username}' (ID: {confirm['data']['user_id']})"
|
return True, f"Registered as '{username}' (ID: {confirm['data']['user_id']})"
|
||||||
|
|||||||
346
ios_client 0.8.5/ARCHITECTURE.md
Normal file
346
ios_client 0.8.5/ARCHITECTURE.md
Normal file
@@ -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<ChatNotification>` (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.
|
||||||
351
ios_client 0.8.5/Kecalek.xcodeproj/project.pbxproj
Normal file
351
ios_client 0.8.5/Kecalek.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
DC2D11D42F3CE6FD009F93FA /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DC2D11D32F3CE6FD009F93FA /* Kecalek.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
7
ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios_client 0.8.5/Kecalek.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BuildLocationStyle</key>
|
||||||
|
<string>UseAppPreferences</string>
|
||||||
|
<key>CompilationCachingSetting</key>
|
||||||
|
<string>Default</string>
|
||||||
|
<key>CustomBuildLocationType</key>
|
||||||
|
<string>RelativeToDerivedData</string>
|
||||||
|
<key>DerivedDataLocationStyle</key>
|
||||||
|
<string>Default</string>
|
||||||
|
<key>ShowSharedSchemesAutomaticallyEnabled</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>Kecalek.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
ios_client 0.8.5/Kecalek/.DS_Store
vendored
Normal file
BIN
ios_client 0.8.5/Kecalek/.DS_Store
vendored
Normal file
Binary file not shown.
161
ios_client 0.8.5/Kecalek/AppState.swift
Normal file
161
ios_client 0.8.5/Kecalek/AppState.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
private var notificationTask: Task<Void, Never>?
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 702 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json
Normal file
6
ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
3666
ios_client 0.8.5/Kecalek/Core/ChatClient.swift
Normal file
3666
ios_client 0.8.5/Kecalek/Core/ChatClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
485
ios_client 0.8.5/Kecalek/Core/KeyStorage.swift
Normal file
485
ios_client 0.8.5/Kecalek/Core/KeyStorage.swift
Normal file
@@ -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<String>, 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<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
132
ios_client 0.8.5/Kecalek/Core/KeychainService.swift
Normal file
132
ios_client 0.8.5/Kecalek/Core/KeychainService.swift
Normal file
@@ -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<CFError>?
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
162
ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift
Normal file
162
ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift
Normal file
@@ -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..<iterations {
|
||||||
|
var hasher = SHA512()
|
||||||
|
hasher.update(data: data)
|
||||||
|
hasher.update(data: identityKey)
|
||||||
|
let digest = hasher.finalize()
|
||||||
|
data = Data(digest)
|
||||||
|
}
|
||||||
|
return Data(data.prefix(32))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format 32-byte fingerprint as 6 groups of 5 zero-padded digits (30 digits).
|
||||||
|
///
|
||||||
|
/// Each group: int(bytes[i*5:(i+1)*5], big-endian) % 100000.
|
||||||
|
/// Output: two lines of 3 groups each, space-separated.
|
||||||
|
static func formatFingerprint(_ fpBytes: Data) -> 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift
Normal file
95
ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CryptoError: Error, LocalizedError {
|
||||||
|
case invalidBase64
|
||||||
|
case invalidHex
|
||||||
|
case invalidKeyData(String)
|
||||||
|
case invalidSignature
|
||||||
|
case signatureVerificationFailed
|
||||||
|
case encryptionFailed(String)
|
||||||
|
case decryptionFailed(String)
|
||||||
|
case invalidECP1Format
|
||||||
|
case pbkdf2Failed
|
||||||
|
case rsaKeyGenerationFailed
|
||||||
|
case rsaOperationFailed(String)
|
||||||
|
case x3dhFailed(String)
|
||||||
|
case ratchetError(String)
|
||||||
|
case senderKeyError(String)
|
||||||
|
case maxSkipExceeded
|
||||||
|
case duplicateMessage
|
||||||
|
case invalidHeader(String)
|
||||||
|
case stateImportFailed(String)
|
||||||
|
case keyConversionFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBase64: return "Invalid base64 encoding"
|
||||||
|
case .invalidHex: return "Invalid hex encoding"
|
||||||
|
case .invalidKeyData(let msg): return "Invalid key data: \(msg)"
|
||||||
|
case .invalidSignature: return "Invalid signature format"
|
||||||
|
case .signatureVerificationFailed: return "Signature verification failed"
|
||||||
|
case .encryptionFailed(let msg): return "Encryption failed: \(msg)"
|
||||||
|
case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
|
||||||
|
case .invalidECP1Format: return "Invalid ECP1 key format"
|
||||||
|
case .pbkdf2Failed: return "PBKDF2 key derivation failed"
|
||||||
|
case .rsaKeyGenerationFailed: return "RSA key generation failed"
|
||||||
|
case .rsaOperationFailed(let msg): return "RSA operation failed: \(msg)"
|
||||||
|
case .x3dhFailed(let msg): return "X3DH failed: \(msg)"
|
||||||
|
case .ratchetError(let msg): return "Ratchet error: \(msg)"
|
||||||
|
case .senderKeyError(let msg): return "Sender key error: \(msg)"
|
||||||
|
case .maxSkipExceeded: return "Maximum message skip exceeded"
|
||||||
|
case .duplicateMessage: return "Duplicate message detected"
|
||||||
|
case .invalidHeader(let msg): return "Invalid header: \(msg)"
|
||||||
|
case .stateImportFailed(let msg): return "State import failed: \(msg)"
|
||||||
|
case .keyConversionFailed(let msg): return "Key conversion failed: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NetworkError: Error, LocalizedError {
|
||||||
|
case notConnected
|
||||||
|
case connectionFailed(String)
|
||||||
|
case timeout
|
||||||
|
case serverError(String)
|
||||||
|
case protocolError(String)
|
||||||
|
case messageTooLarge
|
||||||
|
case invalidResponse(String)
|
||||||
|
case authenticationFailed(String)
|
||||||
|
case alreadyConnected
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notConnected: return "Not connected to server"
|
||||||
|
case .connectionFailed(let msg): return "Connection failed: \(msg)"
|
||||||
|
case .timeout: return "Request timed out"
|
||||||
|
case .serverError(let msg): return "Server error: \(msg)"
|
||||||
|
case .protocolError(let msg): return "Protocol error: \(msg)"
|
||||||
|
case .messageTooLarge: return "Message exceeds maximum size"
|
||||||
|
case .invalidResponse(let msg): return "Invalid response: \(msg)"
|
||||||
|
case .authenticationFailed(let msg): return "Authentication failed: \(msg)"
|
||||||
|
case .alreadyConnected: return "Already connected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatError: Error, LocalizedError {
|
||||||
|
case notLoggedIn
|
||||||
|
case conversationNotFound
|
||||||
|
case membershipRequired
|
||||||
|
case permissionDenied(String)
|
||||||
|
case operationFailed(String)
|
||||||
|
case fileError(String)
|
||||||
|
case invalidData(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notLoggedIn: return "Not logged in"
|
||||||
|
case .conversationNotFound: return "Conversation not found"
|
||||||
|
case .membershipRequired: return "Must be a member of this conversation"
|
||||||
|
case .permissionDenied(let msg): return "Permission denied: \(msg)"
|
||||||
|
case .operationFailed(let msg): return "Operation failed: \(msg)"
|
||||||
|
case .fileError(let msg): return "File error: \(msg)"
|
||||||
|
case .invalidData(let msg): return "Invalid data: \(msg)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift
Normal file
196
ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Core cryptographic utilities: AES-GCM, HKDF, KDF helpers
|
||||||
|
enum CryptoUtils {
|
||||||
|
|
||||||
|
// MARK: - AES-256-GCM
|
||||||
|
|
||||||
|
/// Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag) — all as Data.
|
||||||
|
/// If key is nil, generates a random 256-bit key.
|
||||||
|
/// Matches Python: aes_encrypt(plaintext, key=None)
|
||||||
|
static func aesEncrypt(_ plaintext: Data, key: Data? = nil) throws -> (key: Data, nonce: Data, ciphertext: Data, tag: Data) {
|
||||||
|
let keyData = key ?? Data.randomBytes(32)
|
||||||
|
let symmetricKey = SymmetricKey(data: keyData)
|
||||||
|
let nonceData = Data.randomBytes(12)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonceData)
|
||||||
|
|
||||||
|
let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: gcmNonce)
|
||||||
|
|
||||||
|
return (
|
||||||
|
key: keyData,
|
||||||
|
nonce: nonceData,
|
||||||
|
ciphertext: Data(sealedBox.ciphertext),
|
||||||
|
tag: Data(sealedBox.tag)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt with AES-256-GCM.
|
||||||
|
/// Matches Python: aes_decrypt(key, nonce, ciphertext, tag)
|
||||||
|
static func aesDecrypt(key: Data, nonce: Data, ciphertext: Data, tag: Data) throws -> Data {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||||
|
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(
|
||||||
|
nonce: gcmNonce,
|
||||||
|
ciphertext: ciphertext,
|
||||||
|
tag: tag
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||||
|
} catch {
|
||||||
|
throw CryptoError.decryptionFailed("AES-GCM decryption failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt with AES-256-GCM using AAD. Returns ciphertext with tag appended.
|
||||||
|
/// Used by Double Ratchet and Sender Keys.
|
||||||
|
static func aesGcmEncrypt(_ plaintext: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||||
|
|
||||||
|
let sealedBox = try AES.GCM.seal(
|
||||||
|
plaintext,
|
||||||
|
using: symmetricKey,
|
||||||
|
nonce: gcmNonce,
|
||||||
|
authenticating: aad
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return ciphertext + tag concatenated (matches Python AESGCM.encrypt)
|
||||||
|
return Data(sealedBox.ciphertext) + Data(sealedBox.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt AES-256-GCM with AAD. Input ciphertext has tag appended (last 16 bytes).
|
||||||
|
static func aesGcmDecrypt(_ ctWithTag: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||||
|
guard ctWithTag.count >= 16 else {
|
||||||
|
throw CryptoError.decryptionFailed("Ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||||
|
let tag = ctWithTag.suffix(16)
|
||||||
|
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||||
|
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(
|
||||||
|
nonce: gcmNonce,
|
||||||
|
ciphertext: ct,
|
||||||
|
tag: tag
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||||
|
} catch {
|
||||||
|
throw CryptoError.decryptionFailed("AES-GCM decryption with AAD failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HKDF
|
||||||
|
|
||||||
|
/// HKDF-SHA256 key derivation.
|
||||||
|
/// Matches Python: hkdf_derive(input_key, salt, info, length=32)
|
||||||
|
static func hkdfDerive(inputKey: Data, salt: Data, info: Data, length: Int = 32) -> Data {
|
||||||
|
let symmetricKey = SymmetricKey(data: inputKey)
|
||||||
|
let derived = HKDF<SHA256>.deriveKey(
|
||||||
|
inputKeyMaterial: symmetricKey,
|
||||||
|
salt: salt,
|
||||||
|
info: info,
|
||||||
|
outputByteCount: length
|
||||||
|
)
|
||||||
|
return derived.withUnsafeBytes { Data($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KDF for Double Ratchet
|
||||||
|
|
||||||
|
/// Root key KDF. Returns (newRootKey, chainKey).
|
||||||
|
/// HKDF with rootKey as salt and DH output as input. Derives 64 bytes, split in half.
|
||||||
|
/// Matches Python: kdf_rk(root_key, dh_output)
|
||||||
|
static func kdfRK(rootKey: Data, dhOutput: Data) -> (newRootKey: Data, chainKey: Data) {
|
||||||
|
let derived = hkdfDerive(
|
||||||
|
inputKey: dhOutput,
|
||||||
|
salt: rootKey,
|
||||||
|
info: Data(Constants.rootKeyInfo.utf8),
|
||||||
|
length: 64
|
||||||
|
)
|
||||||
|
return (derived.prefix(32), Data(derived.suffix(32)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chain key KDF. Returns (newChainKey, messageKey).
|
||||||
|
/// HMAC-SHA256: messageKey = HMAC(chainKey, 0x01), newChainKey = HMAC(chainKey, 0x02)
|
||||||
|
/// Matches Python: kdf_ck(chain_key)
|
||||||
|
static func kdfCK(chainKey: Data) -> (newChainKey: Data, messageKey: Data) {
|
||||||
|
let symmetricKey = SymmetricKey(data: chainKey)
|
||||||
|
let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: symmetricKey))
|
||||||
|
let newChainKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: symmetricKey))
|
||||||
|
return (newChainKey, messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Self-Encryption Key
|
||||||
|
|
||||||
|
/// Derive static AES-256 key from identity key for self-encrypted message copies.
|
||||||
|
/// Matches Python: derive_self_encryption_key(identity_private)
|
||||||
|
static func deriveSelfEncryptionKey(identityPrivateRaw: Data) -> Data {
|
||||||
|
hkdfDerive(
|
||||||
|
inputKey: identityPrivateRaw,
|
||||||
|
salt: Data(Constants.selfEncryptionSalt.utf8),
|
||||||
|
info: Data(Constants.selfEncryptionInfo.utf8),
|
||||||
|
length: 32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local Storage Key
|
||||||
|
|
||||||
|
/// Derive AES-256 key for encrypting local session/sender key files.
|
||||||
|
/// Matches Python: derive_local_storage_key(identity_private)
|
||||||
|
static func deriveLocalStorageKey(identityPrivateRaw: Data) -> Data {
|
||||||
|
hkdfDerive(
|
||||||
|
inputKey: identityPrivateRaw,
|
||||||
|
salt: Data(Constants.localStorageSalt.utf8),
|
||||||
|
info: Data(Constants.localStorageInfo.utf8),
|
||||||
|
length: 32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local File Encryption
|
||||||
|
|
||||||
|
/// Encrypt data for local storage. Format: nonce(12) + tag(16) + ciphertext
|
||||||
|
/// Matches Python: _encrypt_local(data, key)
|
||||||
|
static func encryptLocal(_ data: Data, key: Data) throws -> Data {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
let sealedBox = try AES.GCM.seal(data, using: symmetricKey)
|
||||||
|
|
||||||
|
var result = Data()
|
||||||
|
result.append(Data(sealedBox.nonce)) // 12 bytes
|
||||||
|
result.append(Data(sealedBox.tag)) // 16 bytes
|
||||||
|
result.append(Data(sealedBox.ciphertext)) // N bytes
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt locally stored data. Format: nonce(12) + tag(16) + ciphertext
|
||||||
|
/// Matches Python: _decrypt_local(raw, key)
|
||||||
|
static func decryptLocal(_ raw: Data, key: Data) throws -> Data {
|
||||||
|
guard raw.count >= 28 else { // 12 + 16 minimum
|
||||||
|
throw CryptoError.decryptionFailed("Local encrypted data too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce = raw[0..<12]
|
||||||
|
let tag = raw[12..<28]
|
||||||
|
let ct = raw[28...]
|
||||||
|
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||||
|
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(
|
||||||
|
nonce: gcmNonce,
|
||||||
|
ciphertext: ct,
|
||||||
|
tag: tag
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||||
|
} catch {
|
||||||
|
throw CryptoError.decryptionFailed("Local storage decryption failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
393
ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift
Normal file
393
ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
73
ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift
Normal file
73
ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Ed25519 signing operations — Identity Key management
|
||||||
|
enum Ed25519Crypto {
|
||||||
|
|
||||||
|
// MARK: - Key Generation
|
||||||
|
|
||||||
|
/// Generate Ed25519 keypair
|
||||||
|
static func generateKeypair() -> (privateKey: Curve25519.Signing.PrivateKey, publicKey: Curve25519.Signing.PublicKey) {
|
||||||
|
let privateKey = Curve25519.Signing.PrivateKey()
|
||||||
|
return (privateKey, privateKey.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Serialization
|
||||||
|
|
||||||
|
/// Serialize Ed25519 private key. With password: raw 32B → ECP1. Without: raw 32B.
|
||||||
|
/// Matches Python: serialize_ed25519_private(key, password=None)
|
||||||
|
static func serializePrivate(_ key: Curve25519.Signing.PrivateKey, password: Data? = nil) throws -> Data {
|
||||||
|
let raw = key.rawData // 32 bytes
|
||||||
|
if let password = password {
|
||||||
|
return try KeyEncryption.encrypt(raw, password: password)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize Ed25519 public key to 32 raw bytes.
|
||||||
|
/// Matches Python: serialize_ed25519_public(key)
|
||||||
|
static func serializePublic(_ key: Curve25519.Signing.PublicKey) -> Data {
|
||||||
|
key.rawData // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
/// Load Ed25519 private key. Auto-detects ECP1 / raw 32B.
|
||||||
|
/// Matches Python: load_ed25519_private(data, password=None)
|
||||||
|
static func loadPrivate(_ data: Data, password: Data? = nil) throws -> Curve25519.Signing.PrivateKey {
|
||||||
|
if KeyEncryption.isECP1Format(data) {
|
||||||
|
guard let pwd = password else {
|
||||||
|
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||||
|
}
|
||||||
|
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||||
|
return try Curve25519.Signing.PrivateKey(rawRepresentation: raw)
|
||||||
|
}
|
||||||
|
if data.count == 32 {
|
||||||
|
return try Curve25519.Signing.PrivateKey(rawRepresentation: data)
|
||||||
|
}
|
||||||
|
throw CryptoError.invalidKeyData("Cannot parse Ed25519 private key (\(data.count) bytes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load Ed25519 public key from 32 raw bytes.
|
||||||
|
/// Matches Python: load_ed25519_public(data)
|
||||||
|
static func loadPublic(_ data: Data) throws -> Curve25519.Signing.PublicKey {
|
||||||
|
guard data.count == 32 else {
|
||||||
|
throw CryptoError.invalidKeyData("Ed25519 public key must be 32 bytes, got \(data.count)")
|
||||||
|
}
|
||||||
|
return try Curve25519.Signing.PublicKey(rawRepresentation: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sign / Verify
|
||||||
|
|
||||||
|
/// Sign data with Ed25519. Returns 64-byte signature.
|
||||||
|
/// Matches Python: ed25519_sign(private_key, data)
|
||||||
|
static func sign(_ privateKey: Curve25519.Signing.PrivateKey, data: Data) throws -> Data {
|
||||||
|
Data(try privateKey.signature(for: data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify Ed25519 signature.
|
||||||
|
/// Matches Python: ed25519_verify(public_key, signature, data)
|
||||||
|
static func verify(_ publicKey: Curve25519.Signing.PublicKey, signature: Data, data: Data) -> Bool {
|
||||||
|
publicKey.isValidSignature(signature, for: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
231
ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift
Normal file
231
ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Pure Swift GF(2^255-19) arithmetic for Ed25519 → X25519 public key conversion.
|
||||||
|
///
|
||||||
|
/// The conversion formula is: u = (1 + y) / (1 - y) mod p
|
||||||
|
/// where p = 2^255 - 19, and y is the Ed25519 public key's y-coordinate.
|
||||||
|
///
|
||||||
|
/// Uses 4-limb UInt64 representation (little-endian).
|
||||||
|
enum FieldArithmetic {
|
||||||
|
|
||||||
|
// p = 2^255 - 19
|
||||||
|
static let p: [UInt64] = [
|
||||||
|
0xFFFF_FFFF_FFFF_FFED, // limb 0 (least significant)
|
||||||
|
0xFFFF_FFFF_FFFF_FFFF, // limb 1
|
||||||
|
0xFFFF_FFFF_FFFF_FFFF, // limb 2
|
||||||
|
0x7FFF_FFFF_FFFF_FFFF, // limb 3 (most significant, 2^63 - 1 accounting for -19)
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Load a 256-bit little-endian byte array into 4 UInt64 limbs
|
||||||
|
static func load(_ bytes: Data) -> [UInt64] {
|
||||||
|
precondition(bytes.count == 32)
|
||||||
|
var limbs = [UInt64](repeating: 0, count: 4)
|
||||||
|
for i in 0..<4 {
|
||||||
|
var val: UInt64 = 0
|
||||||
|
for j in 0..<8 {
|
||||||
|
val |= UInt64(bytes[i * 8 + j]) << (j * 8)
|
||||||
|
}
|
||||||
|
limbs[i] = val
|
||||||
|
}
|
||||||
|
return limbs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store 4 UInt64 limbs as 32 little-endian bytes
|
||||||
|
static func store(_ limbs: [UInt64]) -> Data {
|
||||||
|
var bytes = Data(count: 32)
|
||||||
|
for i in 0..<4 {
|
||||||
|
for j in 0..<8 {
|
||||||
|
bytes[i * 8 + j] = UInt8((limbs[i] >> (j * 8)) & 0xFF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// a + b mod p
|
||||||
|
static func add(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||||
|
var result = [UInt64](repeating: 0, count: 4)
|
||||||
|
var carry: UInt64 = 0
|
||||||
|
for i in 0..<4 {
|
||||||
|
let (sum1, c1) = a[i].addingReportingOverflow(b[i])
|
||||||
|
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||||
|
result[i] = sum2
|
||||||
|
carry = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||||
|
}
|
||||||
|
// Reduce mod p
|
||||||
|
return reduceOnce(result, carry: carry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// a - b mod p
|
||||||
|
static func sub(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||||
|
var result = [UInt64](repeating: 0, count: 4)
|
||||||
|
var borrow: UInt64 = 0
|
||||||
|
for i in 0..<4 {
|
||||||
|
let (diff1, b1) = a[i].subtractingReportingOverflow(b[i])
|
||||||
|
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||||
|
result[i] = diff2
|
||||||
|
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||||
|
}
|
||||||
|
if borrow > 0 {
|
||||||
|
// Add p back
|
||||||
|
var c: UInt64 = 0
|
||||||
|
for i in 0..<4 {
|
||||||
|
let (s1, c1) = result[i].addingReportingOverflow(p[i])
|
||||||
|
let (s2, c2) = s1.addingReportingOverflow(c)
|
||||||
|
result[i] = s2
|
||||||
|
c = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiply two 256-bit numbers mod p using schoolbook multiplication
|
||||||
|
static func mul(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||||
|
// Full 512-bit product in 8 limbs
|
||||||
|
var product = [UInt64](repeating: 0, count: 8)
|
||||||
|
|
||||||
|
for i in 0..<4 {
|
||||||
|
var carry: UInt64 = 0
|
||||||
|
for j in 0..<4 {
|
||||||
|
let (hi, lo) = a[i].multipliedFullWidth(by: b[j])
|
||||||
|
let (sum1, c1) = product[i + j].addingReportingOverflow(lo)
|
||||||
|
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||||
|
product[i + j] = sum2
|
||||||
|
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||||
|
}
|
||||||
|
product[i + 4] = carry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce mod p using Barrett-like reduction
|
||||||
|
// Since p = 2^255 - 19, for a 512-bit number we can use:
|
||||||
|
// x mod p = (x_low + x_high * 2^256) mod p
|
||||||
|
// Since 2^255 ≡ 19 (mod p), 2^256 ≡ 38 (mod p)
|
||||||
|
return reduceFull(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce 512-bit product mod p using 2^256 ≡ 38 (mod p)
|
||||||
|
private static func reduceFull(_ product: [UInt64]) -> [UInt64] {
|
||||||
|
// Split: low = product[0..3], high = product[4..7]
|
||||||
|
// result = low + high * 38
|
||||||
|
var result = [UInt64](repeating: 0, count: 5)
|
||||||
|
|
||||||
|
// Start with low part
|
||||||
|
for i in 0..<4 {
|
||||||
|
result[i] = product[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add high * 38
|
||||||
|
var carry: UInt64 = 0
|
||||||
|
for i in 0..<4 {
|
||||||
|
let (hi, lo) = product[i + 4].multipliedFullWidth(by: 38)
|
||||||
|
let (sum1, c1) = result[i].addingReportingOverflow(lo)
|
||||||
|
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||||
|
result[i] = sum2
|
||||||
|
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||||
|
}
|
||||||
|
result[4] = carry
|
||||||
|
|
||||||
|
// The result might still be >= p, so reduce once more
|
||||||
|
// result[4] * 2^256 ≡ result[4] * 38 (mod p)
|
||||||
|
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..<bits {
|
||||||
|
if limb & 1 == 1 {
|
||||||
|
result = mul(result, b)
|
||||||
|
}
|
||||||
|
b = mul(b, b)
|
||||||
|
limb >>= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ed25519 → X25519 Public Key Conversion
|
||||||
|
|
||||||
|
/// Convert Ed25519 public key (32 bytes) to X25519 public key (32 bytes).
|
||||||
|
/// Formula: u = (1 + y) * inverse(1 - y) mod p
|
||||||
|
static func ed25519PublicToX25519(_ ed25519Pub: Data) -> Data {
|
||||||
|
precondition(ed25519Pub.count == 32)
|
||||||
|
|
||||||
|
// Ed25519 public key is the y-coordinate with sign bit in the top bit of byte 31
|
||||||
|
var keyBytes = ed25519Pub
|
||||||
|
// Clear the sign bit
|
||||||
|
keyBytes[31] &= 0x7F
|
||||||
|
|
||||||
|
let y = load(keyBytes)
|
||||||
|
let one: [UInt64] = [1, 0, 0, 0]
|
||||||
|
|
||||||
|
let onePlusY = add(one, y)
|
||||||
|
let oneMinusY = sub(one, y)
|
||||||
|
let inv = inverse(oneMinusY)
|
||||||
|
let u = mul(onePlusY, inv)
|
||||||
|
|
||||||
|
return store(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift
Normal file
106
ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import CommonCrypto
|
||||||
|
|
||||||
|
/// ECP1 key encryption format: PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM
|
||||||
|
/// Wire format: magic(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
|
||||||
|
enum KeyEncryption {
|
||||||
|
|
||||||
|
/// Encrypt raw key bytes with password using ECP1 format
|
||||||
|
static func encrypt(_ rawBytes: Data, password: Data) throws -> Data {
|
||||||
|
let salt = Data.randomBytes(16)
|
||||||
|
let derivedKey = try pbkdf2(password: password, salt: salt)
|
||||||
|
|
||||||
|
let nonce = Data.randomBytes(12)
|
||||||
|
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||||
|
|
||||||
|
// AAD = ECP1 magic bytes (matching Python)
|
||||||
|
let sealedBox = try AES.GCM.seal(
|
||||||
|
rawBytes,
|
||||||
|
using: symmetricKey,
|
||||||
|
nonce: gcmNonce,
|
||||||
|
authenticating: Constants.ecp1Magic
|
||||||
|
)
|
||||||
|
|
||||||
|
// ciphertext + tag concatenated (matches Python's AESGCM.encrypt output)
|
||||||
|
var result = Data()
|
||||||
|
result.append(Constants.ecp1Magic) // 4 bytes
|
||||||
|
result.append(salt) // 16 bytes
|
||||||
|
result.append(nonce) // 12 bytes
|
||||||
|
result.append(sealedBox.ciphertext) // N bytes
|
||||||
|
result.append(sealedBox.tag) // 16 bytes
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt ECP1-encrypted key bytes with password
|
||||||
|
static func decrypt(_ data: Data, password: Data) throws -> Data {
|
||||||
|
guard data.count >= 48 else { // 4 + 16 + 12 + 16 minimum
|
||||||
|
throw CryptoError.invalidECP1Format
|
||||||
|
}
|
||||||
|
guard data.prefix(4) == Constants.ecp1Magic else {
|
||||||
|
throw CryptoError.invalidECP1Format
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = data[4..<20]
|
||||||
|
let nonce = data[20..<32]
|
||||||
|
let ctWithTag = data[32...]
|
||||||
|
|
||||||
|
guard ctWithTag.count >= 16 else {
|
||||||
|
throw CryptoError.invalidECP1Format
|
||||||
|
}
|
||||||
|
|
||||||
|
let derivedKey = try pbkdf2(password: password, salt: Data(salt))
|
||||||
|
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||||
|
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||||
|
|
||||||
|
// Split ciphertext and tag
|
||||||
|
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||||
|
let tag = ctWithTag.suffix(16)
|
||||||
|
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(
|
||||||
|
nonce: gcmNonce,
|
||||||
|
ciphertext: ct,
|
||||||
|
tag: tag
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: Constants.ecp1Magic)
|
||||||
|
} catch {
|
||||||
|
throw CryptoError.decryptionFailed("ECP1 decryption failed - wrong password?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if data starts with ECP1 magic
|
||||||
|
static func isECP1Format(_ data: Data) -> Bool {
|
||||||
|
data.count >= 4 && data.prefix(4) == Constants.ecp1Magic
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PBKDF2
|
||||||
|
|
||||||
|
/// Derive 32-byte key using PBKDF2-HMAC-SHA256 with 600k iterations
|
||||||
|
static func pbkdf2(password: Data, salt: Data) throws -> Data {
|
||||||
|
var derivedKey = Data(count: 32)
|
||||||
|
let status = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
|
||||||
|
password.withUnsafeBytes { passwordPtr in
|
||||||
|
salt.withUnsafeBytes { saltPtr in
|
||||||
|
CCKeyDerivationPBKDF(
|
||||||
|
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||||
|
passwordPtr.baseAddress?.assumingMemoryBound(to: Int8.self),
|
||||||
|
password.count,
|
||||||
|
saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||||
|
salt.count,
|
||||||
|
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
||||||
|
Constants.pbkdf2Iterations,
|
||||||
|
derivedKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||||
|
32
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard status == kCCSuccess else {
|
||||||
|
throw CryptoError.pbkdf2Failed
|
||||||
|
}
|
||||||
|
return derivedKey
|
||||||
|
}
|
||||||
|
}
|
||||||
59
ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift
Normal file
59
ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift
Normal file
@@ -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)]
|
||||||
|
}
|
||||||
|
}
|
||||||
356
ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift
Normal file
356
ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift
Normal file
@@ -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<CFError>?
|
||||||
|
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||||
|
throw CryptoError.rsaKeyGenerationFailed
|
||||||
|
}
|
||||||
|
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||||
|
throw CryptoError.rsaKeyGenerationFailed
|
||||||
|
}
|
||||||
|
return (privateKey, publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Serialization
|
||||||
|
|
||||||
|
/// Serialize RSA private key. With password: DER → ECP1. Without: PEM PKCS#8.
|
||||||
|
static func serializePrivateKey(_ key: SecKey, password: Data? = nil) throws -> Data {
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||||
|
throw CryptoError.rsaOperationFailed("Failed to export private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecKey exports in PKCS#1 format on iOS — wrap in PKCS#8 for Python compat
|
||||||
|
let pkcs8 = wrapRSAPrivateKeyPKCS8(derData)
|
||||||
|
|
||||||
|
if let password = password {
|
||||||
|
return try KeyEncryption.encrypt(pkcs8, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PEM encode for Python compatibility
|
||||||
|
return pemEncode(pkcs8, label: "PRIVATE KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize RSA public key as PEM SubjectPublicKeyInfo (Python-compatible)
|
||||||
|
static func serializePublicKey(_ key: SecKey) throws -> Data {
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||||
|
throw CryptoError.rsaOperationFailed("Failed to export public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecKey exports PKCS#1 on iOS — wrap in SubjectPublicKeyInfo
|
||||||
|
let spki = wrapRSAPublicKeySPKI(derData)
|
||||||
|
return pemEncode(spki, label: "PUBLIC KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load RSA private key. Auto-detects ECP1 vs PEM format.
|
||||||
|
static func loadPrivateKey(_ data: Data, password: Data? = nil) throws -> SecKey {
|
||||||
|
let derData: Data
|
||||||
|
|
||||||
|
if KeyEncryption.isECP1Format(data) {
|
||||||
|
guard let pwd = password else {
|
||||||
|
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||||
|
}
|
||||||
|
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||||
|
derData = unwrapPKCS8ToRSAPrivateKey(raw)
|
||||||
|
} else {
|
||||||
|
// PEM format
|
||||||
|
let pem = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
derData = try pemDecode(pem, label: "PRIVATE KEY")
|
||||||
|
.flatMap { unwrapPKCS8ToRSAPrivateKey($0) }
|
||||||
|
?? pemDecode(pem, label: "RSA PRIVATE KEY")
|
||||||
|
?? { throw CryptoError.invalidKeyData("Cannot parse RSA private key PEM") }()
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||||
|
]
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||||
|
throw CryptoError.invalidKeyData("Failed to create RSA private key from DER")
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load RSA public key from PEM
|
||||||
|
static func loadPublicKey(_ pemData: Data) throws -> SecKey {
|
||||||
|
let pem = String(data: pemData, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
|
// Try SubjectPublicKeyInfo (PUBLIC KEY), unwrap to PKCS#1
|
||||||
|
let derData: Data
|
||||||
|
if let spki = pemDecode(pem, label: "PUBLIC KEY") {
|
||||||
|
derData = unwrapSPKIToRSAPublicKey(spki)
|
||||||
|
} else if let pkcs1 = pemDecode(pem, label: "RSA PUBLIC KEY") {
|
||||||
|
derData = pkcs1
|
||||||
|
} else {
|
||||||
|
throw CryptoError.invalidKeyData("Cannot parse RSA public key PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||||
|
]
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||||
|
throw CryptoError.invalidKeyData("Failed to create RSA public key from DER")
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sign / Verify
|
||||||
|
|
||||||
|
/// Sign data with RSA-PSS SHA-256.
|
||||||
|
/// Note: iOS uses salt_length = hash_length (32). Server must use PSS.AUTO to verify.
|
||||||
|
static func sign(_ privateKey: SecKey, data: Data) throws -> Data {
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let signature = SecKeyCreateSignature(
|
||||||
|
privateKey,
|
||||||
|
.rsaSignatureMessagePSSSHA256,
|
||||||
|
data as CFData,
|
||||||
|
&error
|
||||||
|
) as Data? else {
|
||||||
|
throw CryptoError.rsaOperationFailed("RSA signing failed")
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify RSA-PSS SHA-256 signature
|
||||||
|
static func verify(_ publicKey: SecKey, signature: Data, data: Data) -> Bool {
|
||||||
|
SecKeyVerifySignature(
|
||||||
|
publicKey,
|
||||||
|
.rsaSignatureMessagePSSSHA256,
|
||||||
|
data as CFData,
|
||||||
|
signature as CFData,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 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<CFError>?
|
||||||
|
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<CFError>?
|
||||||
|
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<CFError>?
|
||||||
|
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||||
|
throw CryptoError.rsaKeyGenerationFailed
|
||||||
|
}
|
||||||
|
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||||
|
throw CryptoError.rsaKeyGenerationFailed
|
||||||
|
}
|
||||||
|
return (privateKey, publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PEM Helpers
|
||||||
|
|
||||||
|
private static func pemEncode(_ der: Data, label: String) -> Data {
|
||||||
|
let base64 = der.base64EncodedString(options: .lineLength64Characters)
|
||||||
|
let pem = "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n"
|
||||||
|
return Data(pem.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func pemDecode(_ pem: String, label: String) -> Data? {
|
||||||
|
let beginMarker = "-----BEGIN \(label)-----"
|
||||||
|
let endMarker = "-----END \(label)-----"
|
||||||
|
|
||||||
|
guard let beginRange = pem.range(of: beginMarker),
|
||||||
|
let endRange = pem.range(of: endMarker) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let base64String = pem[beginRange.upperBound..<endRange.lowerBound]
|
||||||
|
.replacingOccurrences(of: "\n", with: "")
|
||||||
|
.replacingOccurrences(of: "\r", with: "")
|
||||||
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
|
||||||
|
return Data(base64Encoded: base64String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ASN.1 PKCS#8 / SPKI Wrappers
|
||||||
|
|
||||||
|
// SecKey on iOS exports RSA keys in PKCS#1 format, but Python expects PKCS#8 / SPKI.
|
||||||
|
// These functions add/remove the ASN.1 wrapping.
|
||||||
|
|
||||||
|
// RSA OID: 1.2.840.113549.1.1.1
|
||||||
|
private static let rsaOID: [UInt8] = [0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
|
||||||
|
private static let nullParam: [UInt8] = [0x05, 0x00]
|
||||||
|
|
||||||
|
/// Wrap PKCS#1 RSA private key in PKCS#8 PrivateKeyInfo envelope
|
||||||
|
private static func wrapRSAPrivateKeyPKCS8(_ pkcs1: Data) -> Data {
|
||||||
|
// PrivateKeyInfo ::= SEQUENCE {
|
||||||
|
// version INTEGER (0),
|
||||||
|
// algorithm AlgorithmIdentifier,
|
||||||
|
// privateKey OCTET STRING (containing PKCS#1 key)
|
||||||
|
// }
|
||||||
|
let version = Data([0x02, 0x01, 0x00]) // INTEGER 0
|
||||||
|
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||||
|
let privateKeyOctet = asn1OctetString(pkcs1)
|
||||||
|
return asn1Sequence(version + algorithmSeq + privateKeyOctet)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap PKCS#8 to get PKCS#1 RSA private key
|
||||||
|
private static func unwrapPKCS8ToRSAPrivateKey(_ pkcs8: Data) -> Data {
|
||||||
|
// Parse SEQUENCE, skip version + algorithm, extract OCTET STRING
|
||||||
|
guard pkcs8.count > 2 else { return pkcs8 }
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
// Outer SEQUENCE
|
||||||
|
guard pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||||
|
offset += 1
|
||||||
|
offset = skipASN1Length(pkcs8, offset: offset)
|
||||||
|
|
||||||
|
// Version INTEGER
|
||||||
|
guard offset < pkcs8.count, pkcs8[offset] == 0x02 else { return pkcs8 }
|
||||||
|
offset += 1
|
||||||
|
let versionLen = readASN1Length(pkcs8, offset: &offset)
|
||||||
|
offset += versionLen
|
||||||
|
|
||||||
|
// Algorithm SEQUENCE
|
||||||
|
guard offset < pkcs8.count, pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||||
|
offset += 1
|
||||||
|
let algoLen = readASN1Length(pkcs8, offset: &offset)
|
||||||
|
offset += algoLen
|
||||||
|
|
||||||
|
// Private key OCTET STRING
|
||||||
|
guard offset < pkcs8.count, pkcs8[offset] == 0x04 else { return pkcs8 }
|
||||||
|
offset += 1
|
||||||
|
let keyLen = readASN1Length(pkcs8, offset: &offset)
|
||||||
|
guard offset + keyLen <= pkcs8.count else { return pkcs8 }
|
||||||
|
return Data(pkcs8[offset..<(offset + keyLen)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap PKCS#1 RSA public key in SubjectPublicKeyInfo
|
||||||
|
private static func wrapRSAPublicKeySPKI(_ pkcs1: Data) -> Data {
|
||||||
|
// SubjectPublicKeyInfo ::= SEQUENCE {
|
||||||
|
// algorithm AlgorithmIdentifier,
|
||||||
|
// subjectPublicKey BIT STRING (containing PKCS#1 key)
|
||||||
|
// }
|
||||||
|
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||||
|
let bitString = asn1BitString(pkcs1)
|
||||||
|
return asn1Sequence(algorithmSeq + bitString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap SubjectPublicKeyInfo to get PKCS#1 RSA public key
|
||||||
|
private static func unwrapSPKIToRSAPublicKey(_ spki: Data) -> Data {
|
||||||
|
guard spki.count > 2 else { return spki }
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
// Outer SEQUENCE
|
||||||
|
guard spki[offset] == 0x30 else { return spki }
|
||||||
|
offset += 1
|
||||||
|
offset = skipASN1Length(spki, offset: offset)
|
||||||
|
|
||||||
|
// Algorithm SEQUENCE
|
||||||
|
guard offset < spki.count, spki[offset] == 0x30 else { return spki }
|
||||||
|
offset += 1
|
||||||
|
let algoLen = readASN1Length(spki, offset: &offset)
|
||||||
|
offset += algoLen
|
||||||
|
|
||||||
|
// BIT STRING
|
||||||
|
guard offset < spki.count, spki[offset] == 0x03 else { return spki }
|
||||||
|
offset += 1
|
||||||
|
let bitLen = readASN1Length(spki, offset: &offset)
|
||||||
|
// Skip the unused bits byte
|
||||||
|
guard offset < spki.count, spki[offset] == 0x00 else { return spki }
|
||||||
|
offset += 1
|
||||||
|
let keyLen = bitLen - 1
|
||||||
|
guard offset + keyLen <= spki.count else { return spki }
|
||||||
|
return Data(spki[offset..<(offset + keyLen)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ASN.1 Primitives
|
||||||
|
|
||||||
|
private static func asn1Length(_ length: Int) -> Data {
|
||||||
|
if length < 0x80 {
|
||||||
|
return Data([UInt8(length)])
|
||||||
|
} else if length <= 0xFF {
|
||||||
|
return Data([0x81, UInt8(length)])
|
||||||
|
} else if length <= 0xFFFF {
|
||||||
|
return Data([0x82, UInt8(length >> 8), UInt8(length & 0xFF)])
|
||||||
|
} else {
|
||||||
|
return Data([0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func asn1Sequence(_ content: Data) -> Data {
|
||||||
|
Data([0x30]) + asn1Length(content.count) + content
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func asn1OctetString(_ content: Data) -> Data {
|
||||||
|
Data([0x04]) + asn1Length(content.count) + content
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func asn1BitString(_ content: Data) -> Data {
|
||||||
|
// BIT STRING: tag + length + unused_bits(0) + content
|
||||||
|
Data([0x03]) + asn1Length(content.count + 1) + Data([0x00]) + content
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readASN1Length(_ data: Data, offset: inout Int) -> Int {
|
||||||
|
guard offset < data.count else { return 0 }
|
||||||
|
let first = data[offset]
|
||||||
|
offset += 1
|
||||||
|
if first < 0x80 {
|
||||||
|
return Int(first)
|
||||||
|
}
|
||||||
|
let numBytes = Int(first & 0x7F)
|
||||||
|
var length = 0
|
||||||
|
for _ in 0..<numBytes {
|
||||||
|
guard offset < data.count else { return length }
|
||||||
|
length = (length << 8) | Int(data[offset])
|
||||||
|
offset += 1
|
||||||
|
}
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func skipASN1Length(_ data: Data, offset: Int) -> Int {
|
||||||
|
var off = offset
|
||||||
|
_ = readASN1Length(data, offset: &off)
|
||||||
|
return off
|
||||||
|
}
|
||||||
|
}
|
||||||
175
ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift
Normal file
175
ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Sender key chain for group messaging.
|
||||||
|
/// Each sender in a group has their own chain. Others receive the initial key via pairwise ratchet.
|
||||||
|
/// Matches Python: SenderKeyState class in crypto_utils.py
|
||||||
|
class SenderKeyState {
|
||||||
|
|
||||||
|
let senderKey: Data
|
||||||
|
let chainId: Data
|
||||||
|
private(set) var chainKey: Data
|
||||||
|
private(set) var n: Int
|
||||||
|
private var knownKeys: [Int: Data]
|
||||||
|
|
||||||
|
/// Initialize with optional sender key (generates random 32B if nil).
|
||||||
|
/// Matches Python: SenderKeyState.__init__(sender_key=None)
|
||||||
|
init(senderKey: Data? = nil) {
|
||||||
|
let key = senderKey ?? Data.randomBytes(32)
|
||||||
|
self.senderKey = key
|
||||||
|
self.chainId = Data(SHA256.hash(data: key))
|
||||||
|
self.chainKey = CryptoUtils.hkdfDerive(
|
||||||
|
inputKey: key,
|
||||||
|
salt: Data(repeating: 0x00, count: 32),
|
||||||
|
info: Data(Constants.senderKeyChainInfo.utf8),
|
||||||
|
length: 32
|
||||||
|
)
|
||||||
|
self.n = 0
|
||||||
|
self.knownKeys = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Private init for import
|
||||||
|
private init(senderKey: Data, chainId: Data, chainKey: Data, n: Int, knownKeys: [Int: Data]) {
|
||||||
|
self.senderKey = senderKey
|
||||||
|
self.chainId = chainId
|
||||||
|
self.chainKey = chainKey
|
||||||
|
self.n = n
|
||||||
|
self.knownKeys = knownKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encrypt
|
||||||
|
|
||||||
|
/// Encrypt with current chain key.
|
||||||
|
/// Returns (chainId hex, n, ciphertext with tag, nonce).
|
||||||
|
/// Matches Python: SenderKeyState.encrypt(plaintext)
|
||||||
|
func encrypt(_ plaintext: Data) throws -> (chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) {
|
||||||
|
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||||
|
chainKey = newCK
|
||||||
|
|
||||||
|
let nonce = Data.randomBytes(12)
|
||||||
|
// AAD = chainId + bigEndian(UInt32(n))
|
||||||
|
let aad = chainId + UInt32(n).bigEndianData
|
||||||
|
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||||
|
|
||||||
|
let result = (chainIdHex: chainId.hexString, n: n, ciphertext: ctWithTag, nonce: nonce)
|
||||||
|
n += 1
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Decrypt
|
||||||
|
|
||||||
|
/// Decrypt a group message. Fast-forwards the chain if needed.
|
||||||
|
/// State is snapshotted before modification and restored on failure.
|
||||||
|
/// Matches Python: SenderKeyState.decrypt(chain_id_hex, n, ciphertext, nonce)
|
||||||
|
func decrypt(chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) throws -> Data {
|
||||||
|
guard let expectedChainId = Data(hexString: chainIdHex) else {
|
||||||
|
throw CryptoError.senderKeyError("Invalid chain ID hex")
|
||||||
|
}
|
||||||
|
guard expectedChainId == chainId else {
|
||||||
|
throw CryptoError.senderKeyError("Chain ID mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n - self.n > Constants.maxSenderKeySkip {
|
||||||
|
throw CryptoError.senderKeyError("Sender key skip too large (\(n - self.n) > \(Constants.maxSenderKeySkip))")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot before fast-forward
|
||||||
|
let snapChainKey = chainKey
|
||||||
|
let snapN = self.n
|
||||||
|
let snapKnown = knownKeys
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fast-forward the chain to reach message n
|
||||||
|
while self.n <= n {
|
||||||
|
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||||
|
chainKey = newCK
|
||||||
|
knownKeys[self.n] = mk
|
||||||
|
self.n += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let mk = knownKeys.removeValue(forKey: n) else {
|
||||||
|
throw CryptoError.senderKeyError("Message key for n=\(n) not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
let aad = chainId + UInt32(n).bigEndianData
|
||||||
|
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||||
|
} catch {
|
||||||
|
// Restore state on failure
|
||||||
|
chainKey = snapChainKey
|
||||||
|
self.n = snapN
|
||||||
|
knownKeys = snapKnown
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Export/Import
|
||||||
|
|
||||||
|
/// Export sender key for distribution to group members.
|
||||||
|
/// Matches Python: SenderKeyState.export_key()
|
||||||
|
func exportKey() -> Data {
|
||||||
|
let dict: [String: Any] = ["sender_key": senderKey.hexString]
|
||||||
|
return try! JSONSerialization.data(withJSONObject: dict)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a receiving SenderKeyState from an exported key.
|
||||||
|
/// Matches Python: SenderKeyState.from_key(exported_key)
|
||||||
|
static func fromKey(_ exportedKey: Data) throws -> SenderKeyState {
|
||||||
|
guard let dict = try JSONSerialization.jsonObject(with: exportedKey) as? [String: Any],
|
||||||
|
let senderKeyHex = dict["sender_key"] as? String,
|
||||||
|
let senderKey = Data(hexString: senderKeyHex) else {
|
||||||
|
throw CryptoError.stateImportFailed("Invalid sender key export")
|
||||||
|
}
|
||||||
|
return SenderKeyState(senderKey: senderKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full State Export/Import
|
||||||
|
|
||||||
|
/// Serialize full state for persistent storage.
|
||||||
|
/// Matches Python: SenderKeyState.export_state()
|
||||||
|
func exportState() -> Data {
|
||||||
|
var knownKeysDict: [String: String] = [:]
|
||||||
|
for (k, v) in knownKeys {
|
||||||
|
knownKeysDict[String(k)] = v.hexString
|
||||||
|
}
|
||||||
|
let state: [String: Any] = [
|
||||||
|
"sender_key": senderKey.hexString,
|
||||||
|
"chain_id": chainId.hexString,
|
||||||
|
"chain_key": chainKey.hexString,
|
||||||
|
"n": n,
|
||||||
|
"known_keys": knownKeysDict,
|
||||||
|
]
|
||||||
|
return try! JSONSerialization.data(withJSONObject: state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize full state.
|
||||||
|
/// Matches Python: SenderKeyState.import_state(data)
|
||||||
|
static func importState(_ data: Data) throws -> SenderKeyState {
|
||||||
|
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let senderKeyHex = state["sender_key"] as? String,
|
||||||
|
let senderKey = Data(hexString: senderKeyHex),
|
||||||
|
let chainIdHex = state["chain_id"] as? String,
|
||||||
|
let chainId = Data(hexString: chainIdHex),
|
||||||
|
let chainKeyHex = state["chain_key"] as? String,
|
||||||
|
let chainKey = Data(hexString: chainKeyHex),
|
||||||
|
let n = state["n"] as? Int else {
|
||||||
|
throw CryptoError.stateImportFailed("Invalid sender key state")
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownKeys: [Int: Data] = [:]
|
||||||
|
if let knownKeysDict = state["known_keys"] as? [String: String] {
|
||||||
|
for (k, v) in knownKeysDict {
|
||||||
|
if let idx = Int(k), let data = Data(hexString: v) {
|
||||||
|
knownKeys[idx] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SenderKeyState(
|
||||||
|
senderKey: senderKey,
|
||||||
|
chainId: chainId,
|
||||||
|
chainKey: chainKey,
|
||||||
|
n: n,
|
||||||
|
knownKeys: knownKeys
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift
Normal file
77
ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// X25519 Diffie-Hellman key agreement
|
||||||
|
enum X25519Crypto {
|
||||||
|
|
||||||
|
// MARK: - Key Generation
|
||||||
|
|
||||||
|
/// Generate X25519 keypair
|
||||||
|
static func generateKeypair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
|
let privateKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
return (privateKey, privateKey.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Serialization
|
||||||
|
|
||||||
|
/// Serialize X25519 private key to 32 raw bytes
|
||||||
|
static func serializePrivate(_ key: Curve25519.KeyAgreement.PrivateKey) -> Data {
|
||||||
|
key.rawData // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize X25519 public key to 32 raw bytes
|
||||||
|
static func serializePublic(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
|
||||||
|
key.rawData // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load X25519 private key from 32 raw bytes
|
||||||
|
static func loadPrivate(_ data: Data) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||||
|
guard data.count == 32 else {
|
||||||
|
throw CryptoError.invalidKeyData("X25519 private key must be 32 bytes")
|
||||||
|
}
|
||||||
|
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load X25519 public key from 32 raw bytes
|
||||||
|
static func loadPublic(_ data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
|
guard data.count == 32 else {
|
||||||
|
throw CryptoError.invalidKeyData("X25519 public key must be 32 bytes")
|
||||||
|
}
|
||||||
|
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Diffie-Hellman
|
||||||
|
|
||||||
|
/// Perform X25519 DH key agreement. Returns 32-byte shared secret.
|
||||||
|
/// Matches Python: x25519_dh(private_key, public_key)
|
||||||
|
static func dh(_ privateKey: Curve25519.KeyAgreement.PrivateKey, _ publicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
|
||||||
|
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
|
||||||
|
// Extract raw bytes from SharedSecret
|
||||||
|
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ed25519 → X25519 Key Conversion
|
||||||
|
|
||||||
|
/// Convert Ed25519 private key to X25519 private key.
|
||||||
|
/// SHA-512(seed) → take first 32 bytes → clamp per RFC 7748
|
||||||
|
/// Matches Python: ed25519_private_to_x25519(ed_private)
|
||||||
|
static func fromEd25519Private(_ edPrivate: Curve25519.Signing.PrivateKey) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||||
|
let raw = edPrivate.rawData // 32 bytes seed
|
||||||
|
// SHA-512 of the seed
|
||||||
|
let hash = SHA512.hash(data: raw)
|
||||||
|
var clamped = Data(hash.prefix(32))
|
||||||
|
// Clamp per RFC 7748
|
||||||
|
clamped[0] &= 248
|
||||||
|
clamped[31] &= 127
|
||||||
|
clamped[31] |= 64
|
||||||
|
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Ed25519 public key to X25519 public key.
|
||||||
|
/// Uses Montgomery birational map: u = (1+y)/(1-y) mod p
|
||||||
|
/// Matches Python: ed25519_public_to_x25519(ed_public)
|
||||||
|
static func fromEd25519Public(_ edPublic: Curve25519.Signing.PublicKey) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
|
let x25519Bytes = FieldArithmetic.ed25519PublicToX25519(edPublic.rawData)
|
||||||
|
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: x25519Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
139
ios_client 0.8.5/Kecalek/Crypto/X3DH.swift
Normal file
139
ios_client 0.8.5/Kecalek/Crypto/X3DH.swift
Normal file
@@ -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..<count).map { _ in
|
||||||
|
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||||
|
return (priv, pub, UUID().uuidString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - X3DH Initiate (Alice)
|
||||||
|
|
||||||
|
/// Initiator side of X3DH.
|
||||||
|
/// Returns (sharedSecret, ephemeralPrivate, ephemeralPublic).
|
||||||
|
/// Matches Python: x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?)
|
||||||
|
static func initiate(
|
||||||
|
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||||
|
ikPublicRemoteEd: Curve25519.Signing.PublicKey,
|
||||||
|
spkRemote: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
spkSignature: Data,
|
||||||
|
opkRemote: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
|
) throws -> (sharedSecret: Data,
|
||||||
|
ephemeralPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||||
|
ephemeralPublic: Curve25519.KeyAgreement.PublicKey) {
|
||||||
|
// Verify SPK signature
|
||||||
|
let spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
|
||||||
|
guard Ed25519Crypto.verify(ikPublicRemoteEd, signature: spkSignature, data: spkRemoteBytes) else {
|
||||||
|
throw CryptoError.x3dhFailed("Invalid SPK signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert identity keys to X25519
|
||||||
|
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||||
|
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikPublicRemoteEd)
|
||||||
|
|
||||||
|
// Generate ephemeral keypair
|
||||||
|
let (ekPriv, ekPub) = X25519Crypto.generateKeypair()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
ios_client 0.8.5/Kecalek/KecalekApp.swift
Normal file
47
ios_client 0.8.5/Kecalek/KecalekApp.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ios_client 0.8.5/Kecalek/Models/Conversation.swift
Normal file
54
ios_client 0.8.5/Kecalek/Models/Conversation.swift
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
69
ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift
Normal file
69
ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
ios_client 0.8.5/Kecalek/Models/Invitation.swift
Normal file
9
ios_client 0.8.5/Kecalek/Models/Invitation.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Invitation: Identifiable {
|
||||||
|
let id: String // invitation id (from server) or conversationId
|
||||||
|
let conversationId: String
|
||||||
|
let conversationName: String
|
||||||
|
let invitedBy: String
|
||||||
|
let invitedByUsername: String
|
||||||
|
}
|
||||||
210
ios_client 0.8.5/Kecalek/Models/Message.swift
Normal file
210
ios_client 0.8.5/Kecalek/Models/Message.swift
Normal file
@@ -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<String>
|
||||||
|
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<String>
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ios_client 0.8.5/Kecalek/Models/User.swift
Normal file
19
ios_client 0.8.5/Kecalek/Models/User.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct User: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
var username: String
|
||||||
|
var email: String
|
||||||
|
var identityKey: Data? // Ed25519 public key (32 bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserProfile: Equatable {
|
||||||
|
var userId: String
|
||||||
|
var username: String?
|
||||||
|
var email: String?
|
||||||
|
var phone: String?
|
||||||
|
var phoneVisible: Bool
|
||||||
|
var location: String?
|
||||||
|
var locationVisible: Bool
|
||||||
|
var avatarFile: String?
|
||||||
|
}
|
||||||
191
ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift
Normal file
191
ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift
Normal file
88
ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
ios_client 0.8.5/Kecalek/Utilities/Constants.swift
Normal file
38
ios_client 0.8.5/Kecalek/Utilities/Constants.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
168
ios_client 0.8.5/Kecalek/Utilities/Extensions.swift
Normal file
168
ios_client 0.8.5/Kecalek/Utilities/Extensions.swift
Normal file
@@ -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..<nextIndex], radix: 16) else { return nil }
|
||||||
|
data.append(byte)
|
||||||
|
index = nextIndex
|
||||||
|
}
|
||||||
|
self = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate random bytes
|
||||||
|
static func randomBytes(_ count: Int) -> Data {
|
||||||
|
var data = Data(count: count)
|
||||||
|
data.withUnsafeMutableBytes { ptr in
|
||||||
|
_ = SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data ↔ Base64 (Protocol Wire Format)
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
/// Encode to standard base64 string (matching Python's base64.b64encode)
|
||||||
|
func base64EncodedString() -> String {
|
||||||
|
self.base64EncodedString(options: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode from base64 string
|
||||||
|
static func fromBase64(_ string: String) throws -> Data {
|
||||||
|
// Try standard base64 first, then URL-safe
|
||||||
|
if let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
throw CryptoError.invalidBase64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UInt32 Big-Endian
|
||||||
|
|
||||||
|
extension UInt32 {
|
||||||
|
var bigEndianData: Data {
|
||||||
|
var value = self.bigEndian
|
||||||
|
return Data(bytes: &value, count: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CryptoKit Key → Data
|
||||||
|
|
||||||
|
extension Curve25519.KeyAgreement.PublicKey {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
192
ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift
Normal file
192
ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
356
ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift
Normal file
356
ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
246
ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
Normal file
246
ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class ConversationListVM {
|
||||||
|
var conversations: [Conversation] = []
|
||||||
|
var invitations: [Invitation] = []
|
||||||
|
var onlineUsers: Set<String> = []
|
||||||
|
var unreadCounts: [String: Int] = [:]
|
||||||
|
var favorites: Set<String> = []
|
||||||
|
var avatarCache: [String: Data] = [:] // convId -> avatar image data
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
|
private var notificationTask: Task<Void, Never>?
|
||||||
|
private var avatarTask: Task<Void, Never>?
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
98
ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift
Normal file
98
ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
60
ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift
Normal file
60
ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Registration is handled within LoginView via mode toggle.
|
||||||
|
// This file exists for potential future separation.
|
||||||
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift
Normal file
77
ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift
Normal file
113
ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift
Normal file
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal file
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal file
@@ -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..<text.endIndex], to: &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let nsRange = NSRange(text.startIndex..., in: text)
|
||||||
|
let matches = detector.matches(in: text, range: nsRange)
|
||||||
|
|
||||||
|
if matches.isEmpty {
|
||||||
|
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastEnd = text.startIndex
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
guard let matchRange = Range(match.range, in: text),
|
||||||
|
let url = match.url else { continue }
|
||||||
|
|
||||||
|
// Plain text before link (with mention highlighting)
|
||||||
|
if lastEnd < matchRange.lowerBound {
|
||||||
|
appendPlainWithMentions(text[lastEnd..<matchRange.lowerBound], to: &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link
|
||||||
|
let isSecure = url.scheme?.lowercased() == "https"
|
||||||
|
var link = AttributedString(text[matchRange])
|
||||||
|
link.link = url
|
||||||
|
link.underlineStyle = .single
|
||||||
|
if isMine {
|
||||||
|
link.foregroundColor = isSecure ? .cyan : .red
|
||||||
|
} else {
|
||||||
|
link.foregroundColor = isSecure ? .blue : .red
|
||||||
|
}
|
||||||
|
result.append(link)
|
||||||
|
|
||||||
|
lastEnd = matchRange.upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining text
|
||||||
|
if lastEnd < text.endIndex {
|
||||||
|
appendPlainWithMentions(text[lastEnd..<text.endIndex], to: &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let mentionRegex = try! NSRegularExpression(pattern: "@(\\w+)", options: [])
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func appendPlainWithMentions(_ substring: Substring, to result: inout AttributedString) -> 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..<matchRange.lowerBound])
|
||||||
|
plain.foregroundColor = isMine ? .white : .primary
|
||||||
|
result.append(plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mention = AttributedString(str[matchRange])
|
||||||
|
mention.foregroundColor = mentionColor
|
||||||
|
mention.font = .body.bold()
|
||||||
|
result.append(mention)
|
||||||
|
|
||||||
|
lastEnd = matchRange.upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastEnd < str.endIndex {
|
||||||
|
var plain = AttributedString(str[lastEnd..<str.endIndex])
|
||||||
|
plain.foregroundColor = isMine ? .white : .primary
|
||||||
|
result.append(plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow Layout
|
||||||
|
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 4
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> 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) {}
|
||||||
|
}
|
||||||
215
ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
Normal file
215
ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift
Normal file
62
ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift
Normal file
46
ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SearchOverlayView: View {
|
||||||
|
@Binding var query: String
|
||||||
|
let matchCount: Int
|
||||||
|
let currentIndex: Int
|
||||||
|
let onSearch: (String) -> Void
|
||||||
|
let onNext: () -> Void
|
||||||
|
let onPrev: () -> Void
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextField("Search messages", text: $query)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: query) { _, newValue in
|
||||||
|
onSearch(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchCount > 0 {
|
||||||
|
Text("\(currentIndex + 1)/\(matchCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize()
|
||||||
|
|
||||||
|
Button(action: onPrev) {
|
||||||
|
Image(systemName: "chevron.up")
|
||||||
|
}
|
||||||
|
Button(action: onNext) {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onClose) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CircularAvatarView: View {
|
||||||
|
let name: String
|
||||||
|
var imageData: Data?
|
||||||
|
var size: CGFloat = 32
|
||||||
|
var isGroup: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(Circle())
|
||||||
|
} else {
|
||||||
|
// Default: colored circle with initial letter
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(avatarColor)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
|
Text(initial)
|
||||||
|
.font(.system(size: size * 0.4, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var initial: String {
|
||||||
|
String(name.prefix(1)).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic color from name hash (matching Python gui_client behavior)
|
||||||
|
private var avatarColor: Color {
|
||||||
|
let colors: [Color] = [
|
||||||
|
.red, .orange, .yellow, .green, .mint,
|
||||||
|
.teal, .cyan, .blue, .indigo, .purple, .pink
|
||||||
|
]
|
||||||
|
var hash = 0
|
||||||
|
for char in name.unicodeScalars {
|
||||||
|
hash = hash &* 31 &+ Int(char.value)
|
||||||
|
}
|
||||||
|
return colors[abs(hash) % colors.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OnlineDotOverlay: View {
|
||||||
|
var size: CGFloat = 12
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(.green)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(.white, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NewConversationSheet: View {
|
||||||
|
var appState: AppState
|
||||||
|
var onCreated: (String) async -> Void
|
||||||
|
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var groupName = ""
|
||||||
|
@State private var isGroup = false
|
||||||
|
@State private var memberEmails: [String] = [""]
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Create Group", isOn: $isGroup)
|
||||||
|
|
||||||
|
if isGroup {
|
||||||
|
TextField("Group Name", text: $groupName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(isGroup ? "Members" : "Recipient") {
|
||||||
|
if isGroup {
|
||||||
|
ForEach(memberEmails.indices, id: \.self) { index in
|
||||||
|
TextField("Email", text: $memberEmails[index])
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
}
|
||||||
|
Button("Add Member") {
|
||||||
|
memberEmails.append("")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextField("Email", text: $email)
|
||||||
|
.textContentType(.emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Conversation")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Create") {
|
||||||
|
Task { await create() }
|
||||||
|
}
|
||||||
|
.disabled(isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let emails: [String]
|
||||||
|
if isGroup {
|
||||||
|
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
|
||||||
|
guard !emails.isEmpty else {
|
||||||
|
errorMessage = "Add at least one member"
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guard !email.trimmed.isEmpty else {
|
||||||
|
errorMessage = "Enter an email address"
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emails = [email.trimmed]
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
|
||||||
|
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
|
||||||
|
if let convId = convId {
|
||||||
|
await onCreated(convId)
|
||||||
|
} else {
|
||||||
|
errorMessage = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Group creation is handled within NewConversationSheet via the isGroup toggle.
|
||||||
|
// This file exists for potential future separation.
|
||||||
301
ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift
Normal file
301
ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift
Normal file
41
ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InvitationBanner: View {
|
||||||
|
let invitation: Invitation
|
||||||
|
let onAccept: () -> Void
|
||||||
|
let onDecline: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "envelope.badge")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(invitation.conversationName)
|
||||||
|
.font(.body.bold())
|
||||||
|
Text("Invited by \(invitation.invitedByUsername)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Accept") {
|
||||||
|
onAccept()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button("Decline") {
|
||||||
|
onDecline()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Profile editing is handled within ProfileView when isOwnProfile = true.
|
||||||
|
// This file exists for potential future separation.
|
||||||
277
ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift
Normal file
277
ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
ios_client 0.8.5/SECURITY_REVIEW.md
Normal file
204
ios_client 0.8.5/SECURITY_REVIEW.md
Normal file
@@ -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í.
|
||||||
Reference in New Issue
Block a user