Initial commit — encrypted chat server + Python clients (v0.8.5)

E2E encrypted chat (X3DH + Double Ratchet, Signal Protocol).
Server: asyncio TCP + TLS, MySQL. Clients: PyQt6 GUI + CLI.
Secrets (.env, TLS keys, Cloudflare token), runtime data and
mobile clients (separate repos) are gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Filip
2026-06-11 18:22:39 -04:00
commit 2e7b72307d
24 changed files with 21821 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Secrets & runtime config
.env
certs/*.pem
certs/cloudflare.ini
# Python
.venv/
__pycache__/
*.pyc
# Runtime data
uploads/
# Local tooling
.claude/settings.local.json
# Mobile clients (separate repos)
ios/
Android/

42
AGENTS.md Normal file
View 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.

1136
CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

44
Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# Encrypted Chat Server — Docker image
# Builds only the server-side components (server.py, db.py, crypto_utils.py, protocol.py)
# GUI/iOS client files are not included.
FROM python:3.12-slim
# Install system deps needed by pyzbar (libzbar) and Pillow
RUN apt-get update && apt-get install -y --no-install-recommends \
libzbar0 \
libjpeg62-turbo \
libpng16-16 \
default-libmysqlclient-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python deps — separate layer so code changes don't bust the cache
COPY requirements.txt .
# Install server-only deps (skip PyQt6, pyzbar, qrcode — not needed server-side)
RUN pip install --no-cache-dir \
cryptography \
"mysql-connector-python>=8.3.0" \
"python-dotenv>=1.0.0" \
"Pillow>=10.0.0"
# Copy server source files
COPY server.py db.py crypto_utils.py protocol.py schema.sql ./
# Optional: copy .env if it exists (overridden at runtime via env vars or mounted file)
# COPY .env .
# Create uploads directory
RUN mkdir -p /app/uploads && chmod 700 /app/uploads
# Expose the default server port
EXPOSE 5000
# Health check: attempt TCP connection to the server port
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import socket,sys; s=socket.socket(); s.settimeout(3); s.connect(('localhost', int(__import__('os').getenv('SERVER_PORT','5000')))); s.close()" || exit 1
ENV PYTHONUNBUFFERED=1
CMD ["python", "server.py"]

View File

@@ -0,0 +1,289 @@
# KEC-18 Operational Cost Analysis
Date: 2026-03-27
## Executive Summary
- The absolute cheapest workable production setup for roughly 100 concurrent users is one Contabo `Cloud VPS 10` at `EUR4.50/mo`, plus a domain, free Let's Encrypt TLS, free-tier SMTP, and low-cost object backup. That lands around `EUR5.8-6.5/mo` (`USD6.7-7.5/mo`) if email volume stays inside a free tier.
- The more realistic "do not hate yourself later" floor is about `EUR11-16/mo` (`USD12.7-18.5/mo`) once you include a paid SMTP plan or more backup headroom.
- For 500 to 1,000 total users, Contabo remains extremely cheap. The main constraint is not raw VPS price; it is operational headroom, backup discipline, and the app's current default connection cap.
- For 5,000 total users, I would stop using a single-node layout. Split app and MySQL onto separate VPSes and keep uploads/backups external or on a storage-optimized node.
## What I Sized From The Codebase
Relevant defaults in the current code:
- `protocol.py` defaults to `MAX_MESSAGE_BYTES=1 MiB`, `MAX_IMAGE_BYTES=5 MiB`, `MAX_FILE_BYTES=50 MiB`.
- `server.py` defaults to `MAX_CONNECTIONS_GLOBAL=200`, `MAX_CONNECTIONS_PER_IP=10`, `MAX_UPLOADS_GLOBAL=200`, `MAX_UPLOADS_PER_USER=5`.
- `db.py` defaults to `DB_POOL_SIZE=10`.
- `server.py` defaults to `THREAD_POOL_SIZE=40`.
- `README.md` and `scaling.md` indicate the server is an asyncio TCP relay with synchronous MySQL calls pushed behind `asyncio.to_thread()`, which is lightweight for small deployments but still means DB latency and connection pooling matter.
Implication:
- `100 concurrent users` is feasible on a very small VPS.
- `More than 200 simultaneously connected devices` is not feasible with the current default connection limit unless configuration is raised and load-tested.
- File storage, not CPU, becomes the first recurring cost question if usage shifts from chat-heavy to attachment-heavy.
## Assumptions
To make the tiers comparable, I used these planning assumptions:
- The `500`, `1,000`, and `5,000` tiers are treated as total users, not fully concurrent users.
- Peak concurrent devices:
- 100-user tier: about 100
- 500-user tier: about 75 to 125
- 1,000-user tier: about 125 to 175
- 5,000-user tier: about 300 to 500
- Average retained encrypted upload footprint:
- 100 users: 100 GB
- 500 users: 250 GB
- 1,000 users: 500 GB
- 5,000 users: 1 TB
- SMTP use is limited to registration codes and lightweight transactional mail, not marketing mail.
- EUR to USD conversion uses the ECB reference `1 EUR = 1.1539 USD` visible on 2026-03-27 in the ECB currency converter.
## Current Vendor Pricing Used
### Contabo
Official Contabo pricing page shows:
- `Cloud VPS 10`: `3 vCPU`, `8 GB RAM`, `75 GB NVMe`, `32 TB traffic`, `EUR4.50/mo`
- `Cloud VPS 20`: `6 vCPU`, `12 GB RAM`, `100 GB NVMe`, `32 TB traffic`, `EUR7.00/mo`
- `Cloud VPS 30`: `8 vCPU`, `24 GB RAM`, `200 GB NVMe`, `32 TB traffic`, `EUR14.00/mo`
- `Cloud VPS 40`: `12 vCPU`, `48 GB RAM`, `250 GB NVMe`, `32 TB traffic`, `EUR25.00/mo`
- `Storage VPS 10`: `2 vCPU`, `4 GB RAM`, `300 GB SSD`, `EUR4.50/mo`
- `Storage VPS 20`: `3 vCPU`, `8 GB RAM`, `400 GB SSD`, `EUR7.00/mo`
Note: Contabo also publishes separate location-fee pricing. For example, the location-fee page shows `Cloud VPS 10` in `United States (Central)` at `EUR0.95/mo` extra, for `EUR5.45/mo` total. Base prices above are the standard pricing page numbers.
### Domain, TLS, SMTP, Backup, Monitoring, Agent Costs
- Domain: Porkbun shows `.com` at `USD11.08/yr`, which is about `EUR9.60/yr` or `EUR0.80/mo`.
- TLS: Let's Encrypt certificates are free.
- SMTP:
- MailerSend free plan: `500 emails/month`
- MailerSend Hobby: `EUR5.15/mo` for `5,000 emails/month`
- MailerSend Starter: pricing page shows `EUR25/mo` and `50,000 emails/month`
- Backup/object storage:
- Backblaze B2 pricing page shows `USD6/TB/mo` pay-as-you-go.
- First `10 GB` is free.
- Monitoring:
- Self-hosted Uptime Kuma can be run on your own server at zero direct license cost.
- Managed alternative: UptimeRobot free plan exists; paid plans start at about `USD8/mo`.
- OpenAI / Codex API:
- OpenAI pricing page currently shows `gpt-5.4` standard at `USD2.50 / 1M input tokens` and `USD15.00 / 1M output tokens`.
- `gpt-5.4-mini` standard is `USD0.75 / 1M input` and `USD4.50 / 1M output`.
## Recommended Infrastructure By Tier
### Tier A: Minimum Viable, about 100 concurrent users
Recommended stack:
- 1 x `Cloud VPS 10`
- Let's Encrypt
- 1 `.com` domain
- Backblaze B2 for backups
- MailerSend free or Hobby depending email volume
Why this is enough:
- 8 GB RAM is adequate for Python app + MySQL on one box at this size.
- 75 GB NVMe is enough if uploads are modest and older media is backed up externally.
- 32 TB traffic is far above what this workload should consume.
Estimated monthly cost:
- VPS: `EUR4.50` / `USD5.19`
- Domain amortized monthly: `EUR0.80` / `USD0.92`
- Backup at about 100 GB retained: about `EUR0.47` / `USD0.54`
- TLS: `EUR0`
- SMTP:
- Free-tier case: `EUR0`
- Safer paid case: `EUR5.15` / `USD5.94`
Total:
- Absolute floor: about `EUR5.77/mo` / `USD6.65/mo`
- Safer operating floor: about `EUR10.92/mo` / `USD12.60/mo`
### Tier B: About 500 total users
Recommended stack:
- 1 x `Cloud VPS 20`
- Backblaze B2 backups
- MailerSend Hobby
Why:
- More CPU and RAM headroom for MySQL buffering, background cleanup, and multi-device behavior.
- 100 GB NVMe is enough for DB + hot uploads if colder data is backed up externally.
Estimated monthly cost:
- VPS: `EUR7.00` / `USD8.08`
- Domain: `EUR0.80` / `USD0.92`
- Backup at about 250 GB retained: about `EUR1.25` / `USD1.44`
- TLS: `EUR0`
- SMTP Hobby: `EUR5.15` / `USD5.94`
Total:
- About `EUR14.20/mo` / `USD16.38/mo`
### Tier C: About 1,000 total users
Recommended stack:
- 1 x `Cloud VPS 30`
- Backblaze B2 backups
- MailerSend Hobby or Starter
Why:
- `24 GB RAM` gives useful cache headroom for MySQL and smoother bursts.
- This is the point where a single node is still cheap, but monitoring and restore discipline matter more than raw VPS price.
Estimated monthly cost:
- VPS: `EUR14.00` / `USD16.15`
- Domain: `EUR0.80` / `USD0.92`
- Backup at about 500 GB retained: about `EUR2.55` / `USD2.94`
- TLS: `EUR0`
- SMTP Hobby: `EUR5.15` / `USD5.94`
Total:
- Lean setup: about `EUR22.50/mo` / `USD25.95/mo`
If you want higher mail headroom:
- Swap SMTP to Starter at `EUR25/mo`
- New total: about `EUR42.35/mo` / `USD48.87/mo`
### Tier D: About 5,000 total users
Recommended stack:
- 1 x `Cloud VPS 20` for app server
- 1 x `Cloud VPS 20` for MySQL
- Backblaze B2 backups for media + DB dumps
- MailerSend Starter
Why I would split here:
- The current codebase is still operationally simple. A two-node layout buys more reliability than buying one oversized single VPS.
- Separate failure domains help during DB spikes, backup jobs, and incident response.
- This tier likely exceeds the current default `MAX_CONNECTIONS_GLOBAL=200` if user concurrency climbs, so configuration and load testing become mandatory.
Estimated monthly cost:
- App VPS: `EUR7.00` / `USD8.08`
- DB VPS: `EUR7.00` / `USD8.08`
- Domain: `EUR0.80` / `USD0.92`
- Backup at about 1 TB retained: about `EUR5.15` / `USD5.94`
- TLS: `EUR0`
- SMTP Starter: `EUR25.00` / `USD28.85`
Total:
- About `EUR44.95/mo` / `USD51.87/mo`
Alternative:
- If you strongly prefer a single-node layout, `Cloud VPS 40` plus backups is still cheap, but I would consider it worse operationally than two smaller nodes.
## Minimum Viable Budget Answer
If the question is "what is the absolute minimum monthly spend to run this for about 100 concurrent users," the answer is:
- Roughly `EUR5.8-6.5/mo` (`USD6.7-7.5/mo`) with:
- `Cloud VPS 10`
- one cheap domain
- free TLS
- free SMTP tier
- minimal external backup
If the question is "what is the minimum I would actually recommend for production without pretending backups and mail do not exist," the answer is:
- Roughly `EUR11-16/mo` (`USD12.7-18.5/mo`)
## Additional Infrastructure Recommendations
### TLS certificates
- Use Let's Encrypt.
- Direct recurring certificate cost: `EUR0`.
### Domain
- Budget about `EUR10/yr` to `EUR15/yr`.
- Using current Porkbun `.com` pricing, a normal `.com` is about `EUR9.60/yr`.
### Backups
- Do not rely only on local VPS storage.
- Cheapest clean option: nightly MySQL dumps + uploaded file backup to Backblaze B2.
- Ballpark backup cost at current B2 pricing:
- 100 GB: about `EUR0.47/mo`
- 250 GB: about `EUR1.25/mo`
- 500 GB: about `EUR2.55/mo`
- 1 TB: about `EUR5.15/mo`
### SMTP relay
- Free tier is enough for early registration-code traffic.
- Move to Hobby quickly once real users arrive; it is still cheap and removes needless friction.
### Monitoring
- Cheapest option: self-host Uptime Kuma.
- Managed option: UptimeRobot free or paid.
- I would treat managed monitoring as optional until there is paying traffic.
## Agent Operational Cost Estimate
These costs depend entirely on token volume, not server size.
Using current OpenAI standard pricing:
- `gpt-5.4`: `USD2.50 / 1M input`, `USD15.00 / 1M output`
- `gpt-5.4-mini`: `USD0.75 / 1M input`, `USD4.50 / 1M output`
Illustrative monthly spend per active engineering agent:
- Light usage, `gpt-5.4-mini`:
- 10M input + 2M output
- about `USD16.50/mo` / `EUR14.30/mo`
- Moderate usage, `gpt-5.4`:
- 10M input + 2M output
- about `USD55.00/mo` / `EUR47.66/mo`
- Heavy usage, `gpt-5.4`:
- 40M input + 8M output
- about `USD220.00/mo` / `EUR190.66/mo`
For a small team of 3 active agents, a realistic monthly AI tooling band is:
- Lean: about `EUR43-50/mo`
- Moderate: about `EUR143/mo`
- Heavy: about `EUR570+/mo`
## Risks And Constraints
- The code currently defaults to `MAX_CONNECTIONS_GLOBAL=200`. If "500 users" or "1,000 users" means concurrent devices, current defaults are not enough.
- The cheapest single-node layout mixes app, MySQL, and hot uploads on one VPS. That is acceptable early, but it increases recovery risk during disk or instance failure.
- Attachment-heavy usage can outgrow cheap NVMe faster than message traffic will outgrow CPU.
- SMTP, domain, and monitoring are trivial costs compared with the cost of not having backups.
## Final Recommendation
If I had to choose one path now:
- Launch on `Cloud VPS 10` if the immediate target is only about `100 concurrent users` and budget is extremely tight.
- Launch on `Cloud VPS 20` if you want a safer early-production baseline without materially changing cost.
- Move to a split app/DB layout by the time you are targeting `5,000 total users` or any scenario above `200 concurrently connected devices`.
In short: Contabo pricing is not the bottleneck here. Operational discipline, connection-limit tuning, and backup/storage policy are the real budget drivers once the app starts seeing real usage.

326
README.md Normal file
View File

@@ -0,0 +1,326 @@
# Encrypted Chat
End-to-end encrypted chat s forward secrecy (X3DH + Double Ratchet, Signal Protocol).
Server ukládá a přeposílá šifrované bloby — nikdy nevidí plaintext.
## Architektura
```
┌─────────────┐ TLS/TCP ┌─────────────┐ MySQL ┌─────────┐
│ GUI/CLI │◄───────────────►│ Server │◄──────────────►│ DB │
│ klient │ JSON + base64 │ (asyncio) │ │ │
└─────────────┘ └─────────────┘ └─────────┘
│ │
│ X3DH + Double Ratchet │ Opaque blobs
│ Sender Keys (skupiny) │ (server nevidí plaintext)
▼ ▼
Lokální klíče Šifrované zprávy
(~/.encrypted_chat/) + metadata
```
## Soubory
### Server
| Soubor | Řádky | Účel |
|--------|-------|------|
| `server.py` | ~2 900 | Asyncio TCP server, 45 handlerů, rate limiting, 5 asyncio.Lock guardů, real-time notifikace |
| `db.py` | ~1 700 | MySQL CRUD, connection pooling (pool_size=10), phantom users, reactions/pins CRUD |
| `schema.sql` | ~190 | MySQL schéma (14 tabulek) |
### Klient
| Soubor | Řádky | Účel |
|--------|-------|------|
| `gui_client.py` | ~6 300 | PyQt6 GUI — dark/light téma, widget-based message bubbles, verifikace kontaktů, privacy overlay |
| `client.py` | ~900 | CLI klient — 23 menu opcí |
| `chat_core.py` | ~3 500 | Sdílená logika — session management, X3DH/ratchet šifrování, lokální klíče, multi-device |
| `theme.py` | ~540 | Catppuccin dark + Signal-inspired light téma, live switching |
### Sdílené (server + klient)
| Soubor | Účel |
|--------|------|
| `crypto_utils.py` (~935 ř.) | Ed25519, X25519, AES-256-GCM, HKDF, PBKDF2, X3DH, Double Ratchet (state rollback), Sender Keys (state rollback), ECP1 key encryption, contact verification (fingerprints, safety numbers, QR), message padding |
| `protocol.py` (~140 ř.) | Newline-delimited JSON protokol, base64 encoding, verze (0.8.4) |
### iOS klient
| Složka | Účel |
|--------|------|
| `ios_client/` (47 Swift souborů, ~5 000 ř.) | Nativní iOS port — CryptoKit + pure Swift GF(2^255-19) + Security.framework RSA, SwiftUI views, wire-kompatibilní s Python serverem |
### Testy
| Soubor | Účel |
|--------|------|
| `tests/pentest_client.py` (~340 ř.) | Automatizované security regresní testy (AuthZ, malformed headers, session reset, rate limits) |
## Quick Start
1. `pip install -r requirements.txt`
2. Spustit `schema.sql` v MySQL
3. `python server.py`
4. Klient: `python gui_client.py` (GUI) nebo `python client.py` (CLI)
## Jak funguje šifrování
### Klíče na uživatele
| Klíč | Typ | Účel |
|------|-----|------|
| RSA-4096 | Asymetrický | Pouze login challenge-response. Šifrovaný ECP1 (PBKDF2 600k + AES-256-GCM). |
| Identity Key (IK) | Ed25519 | Podpisy, konverze na X25519 pro X3DH. Šifrovaný ECP1. |
| Signed Pre-Key (SPK) | X25519 | DH v X3DH, podepsaný IK. **Rotuje se každých 7 dní** s grace periodem. |
| One-Time Pre-Keys (OPK) | X25519 | Jednorázové, spotřebuje se při X3DH, automaticky doplňované (< 20 → +50). |
### DM (1:1 zprávy) — X3DH + Double Ratchet
1. Alice stáhne Bobovy per-device key bundles (IK, SPK, OPK) → X3DH per device → shared secret per device.
2. Double Ratchet inicializován ze shared secret — jedna session per (user, device).
3. Každá zpráva: symmetric ratchet (HMAC chain) → message key → AES-256-GCM.
4. Každá odpověď: DH ratchet (nový X25519 keypair) → nový root key + chain key.
5. Per-device ciphertext — každé zařízení příjemce dostane individuálně šifrovaný blob.
6. Self-encrypted kopie s SELF_DEVICE_ID sentinel, čitelná všemi vlastními zařízeními.
### Skupiny — Sender Keys
1. Každý odesílatel má vlastní SenderKeyState per group.
2. Sender key distribuován členům přes pairwise Double Ratchet (jako control DM).
3. Skupinové zprávy: symmetric ratchet na sender key → AES-256-GCM.
4. Stejný ciphertext pro všechny příjemce (efektivní).
### Kontaktní verifikace (Signal-style)
- **Safety numbers** — 60-digit číslo (12 skupin × 5 číslic), deterministické pro každý pár.
- **QR kódy** — binární payload zakódovaný jako base64.
- **Fingerprints** — 30-digit per-user číslo.
- **TOFU** — Trust On First Use + explicit verification + key change warning.
### Lokální úložiště klíčů
```
~/.encrypted_chat/{email}/
private.pem / public.pem — RSA (login, ECP1 formát)
identity_private.bin / _public.bin — Ed25519 (ECP1 formát)
device_id.txt — UUID tohoto zařízení
spk_private.bin / spk_id.txt — Aktuální SPK (AES-256-GCM)
prev_spk_private.bin / prev_spk_id.txt — Předchozí SPK, grace period
opk_private/{opk_id}.bin — One-time prekeys (AES-256-GCM)
sessions/{uid}_{did}.bin — Double Ratchet stavy (AES-256-GCM)
sender_keys/{conv_id}.bin — Vlastní sender keys
sender_keys_recv/{conv}_{uid}_{did}.bin — Přijaté sender keys
known_identity_keys.bin — TOFU registr (AES-256-GCM)
verified_contacts.bin — Explicitní verifikace (AES-256-GCM)
message_cache/{conv_id}.bin — Šifrovaný message cache
login_lockout.json — Brute-force lockout stav
```
## Bezpečnostní hardening
### Šifrování privátních klíčů (ECP1 formát)
- **PBKDF2-HMAC-SHA256** s 600 000 iteracemi (OWASP 2023)
- **AES-256-GCM**, magic bytes "ECP1" jako AAD
- **Formát:** `ECP1(4B) + salt(16B) + nonce(12B) + ciphertext+tag`
- Zpětná kompatibilita: staré PEM se migrují automaticky
### Lokální šifrování dat
- Session/sender key soubory, OPK, SPK, message cache, verifikační soubory — AES-256-GCM klíčem z HKDF(identity_key)
- `chmod 0o700` na adresáře, `0o600` na soubory
### Brute-force ochrana
- Exponenciální backoff: `min(2^N, 300)` sekund po N chybných pokusech
- Aplikováno na login + privacy overlay unlock
### SPK rotace (7 dní)
- Automatická rotace s grace periodem pro in-flight X3DH
- Omezuje dopad kompromitace SPK
### Ratchet state rollback
- Snapshot/restore při selhání dešifrování (DoubleRatchet + SenderKeyState)
### Secure deletion
- Overwrite `os.urandom()` + `fsync` + `unlink` na smazané citlivé soubory
### Message padding
- Bucketed padding (64B64KB) maskuje délku zpráv
### Metadata privacy
- Log sanitizace (žádná PII), metadata retention (90 dní), sender chain minimalizace
### Anti-enumeration
- Phantom users pro neregistrované emaily
- Generické odpovědi na register/login/get_user_info
## Multi-Device Support
Pravý multi-device (Signal-like) — každé zařízení má nezávislé Double Ratchet sessions.
- **Devices tabulka** — každé přihlášení registruje device (UUID)
- **Per-device prekeys** — každé zařízení má vlastní SPK + OPKs
- **Per-device sessions** — klíčované `"user_id:device_id"`
- **Self-encryption** — statický klíč z identity key (čitelné všemi vlastními zařízeními)
- **Pairing** — přenos RSA + Ed25519, nové zařízení generuje vlastní SPK + OPKs
## Features
### Protokol & šifrování
- X3DH + Double Ratchet (DM) s forward secrecy
- Sender Keys (skupiny) s distribucí přes pairwise ratchet
- Per-device šifrování (multi-device)
- SPK rotace (7 dní) + grace period
- Ratchet state rollback při selhání
- ECP1 šifrování klíčů (PBKDF2 600k)
- Message padding (bucketed 64B64KB)
- Kontaktní verifikace (safety numbers, fingerprints, QR kódy)
### Komunikace
- DM + skupinové konverzace
- Reakce na zprávy (thumbsup, heart, laugh, surprised, sad, thumbsdown)
- Přeposílání zpráv (text, obrázky, soubory)
- Připnuté zprávy (pin/unpin + dialog)
- @Mentions s autocomplete
- Odpovědi na zprávy (reply_to)
- Hledání zpráv (client-side, Ctrl+F)
- Šifrované obrázky (AES-256-GCM, chunked upload, thumbnail)
- Šifrované soubory (až 50 MB, chunked upload)
- Read receipts (real-time)
### Skupiny
- Skupinové pozvánky (accept/decline)
- Leave group + přenos creatora
- Rename group (creator only)
- Delete conversation (DMs per-user, groups creator-only)
- Group avatar
### 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
### GUI (PyQt6)
- Dark (Catppuccin Mocha) + Light (Signal) téma s live switching
- Widget-based message bubbles s ConversationDelegate
- Cirkulární avatary + online zelená tečka
- Unread count badges
- Privacy overlay / lock screen (30s timeout + heslo)
- Drag & drop souborů
- Frameless dialogy
- Connection indicator (green/red/orange) + auto-reconnect
- VerificationDialog (safety numbers, QR, fingerprints)
- Key change warning dialog
### CLI
- 23 menu opcí (DM, skupiny, soubory, reakce, piny, forwarding, verifikace, zařízení, search)
### iOS (SwiftUI)
- Wire-kompatibilní s Python serverem
- Kompletní Signal Protocol (X3DH, Double Ratchet, Sender Keys)
- CryptoKit + pure Swift field arithmetic + Security.framework RSA
- SwiftUI views (login, chat, groups, profiles, search)
## Konfigurace
### Server + DB
- `SERVER_HOST` (default `127.0.0.1`), `SERVER_PORT` (default `9999`)
- `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
- `DB_POOL_SIZE` (default `10`)
### TLS
- `TLS_ENABLED` — zapne TLS (default `false`)
- `TLS_REQUIRED` — vyžaduje TLS_ENABLED
- `TLS_CERT_FILE`, `TLS_KEY_FILE` — cesty k certifikátu (PEM)
- `TLS_AUTOGEN` — auto-generuje self-signed cert (**jen s `ENVIRONMENT=dev`**)
- `TLS_CA_FILE` (klient) — vlastní CA certifikát
- `TLS_INSECURE` (klient) — vypne ověření certifikátu (**jen s `ENVIRONMENT=dev`**)
### SMTP
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`
- Bez SMTP = dev mód (kód se vrací přímo klientovi)
### Limity
- `MAX_MESSAGE_BYTES` (default `65536`), `MAX_IMAGE_BYTES` (5 MB), `MAX_FILE_BYTES` (50 MB)
- `MAX_INPUT_CHARS` (GUI, default `2000`)
- `METADATA_RETENTION_DAYS` (default `90`)
- Rate limity: register 3/min, login 10/min, send_message 20/min
- Connection: 20 req/s, max 10/IP, 200 global
### Logging
- `LOG_LEVEL` (default `INFO`)
## Bezpečnostní audit
Dva bezpečnostní audity provedeny (kód review). Nalezeno 6 CRITICAL, 12 HIGH, 12 MEDIUM, 8 LOW nálezů.
| Závažnost | Celkem | Opraveno | Zbývá |
|-----------|--------|----------|-------|
| CRITICAL | 6 | **6** | 0 |
| HIGH | 12 | **11** | 1 (H9 — by-design) |
| MEDIUM | 12 | **11** | 1 (M7) |
| LOW | 8 | **1** | 7 |
Detaily viz `SECURITY_AUDIT.md` a `CLAUDE.md`.
## Known Issues
- **Sender Key Redistribution:** Nový člen skupiny nedešifruje staré skupinové zprávy (sender keys se nedistribuují znovu při přidání).
- **iOS: Contact Key Verification** — safety numbers, QR kódy, TOFU zatím neimplementovány v iOS klientu.
## Docker a CI/CD
### Lokální vývoj s Docker Compose
```bash
# Spustit server + MySQL
docker compose up
# Rebuild po změně kódu
docker compose up --build
# Zastavit a smazat data
docker compose down -v
```
Server bude dostupný na `localhost:5000`. MySQL na `localhost:3306`.
Schéma se automaticky importuje při prvním spuštění.
### Ruční build Docker image
```bash
docker build -t encrypted-chat-server .
docker run -p 5000:5000 \
-e MYSQL_HOST=host.docker.internal \
-e MYSQL_USER=chat \
-e MYSQL_PASSWORD=chatpassword \
-e MYSQL_DATABASE=encrypted_chat \
-e ENVIRONMENT=dev \
encrypted-chat-server
```
### Produkční deployment
1. Získat TLS certifikát (Let's Encrypt / vlastní CA)
2. Nastavit env vars — viz `.env.example`
3. Spustit:
```bash
docker compose -f docker-compose.yml up -d
```
4. Ověřit health: `docker compose ps`
Kritické produkční proměnné:
- `TLS_ENABLED=true`, `TLS_CERT_FILE`, `TLS_KEY_FILE`
- `MYSQL_PASSWORD` — silné heslo
- `ENVIRONMENT=production` (ne `dev`)
- `SMTP_*` — pro registrační emaily
### CI/CD (GitHub Actions)
Pipeline v `.github/workflows/ci.yml` spouští při každém push/PR:
1. **Lint** — `ruff check` na všechny Python soubory
2. **Crypto testy** — `test_crypto_integration.py` (bez serveru)
3. **Integration testy** — spustí MySQL + server, pak `test_server_integration.py`
4. **Docker build** — ověří že se image builduje bez chyb
## 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ů

491
SECURITY_AUDIT.md Normal file
View File

@@ -0,0 +1,491 @@
# Security Audit (Encrypted Chat)
Aktualizace: 2026-03-27
Scope: `server.py`, `db.py`, `chat_core.py`, `gui_client.py`, `client.py`, `protocol.py`, `schema.sql`, `.env.example`, markdown dokumentace.
Metodika: statický audit kódu + konfigurace. Nebyl proveden aktivní penetrační test ani fuzzing.
## Refresh 2026-03-27
Při re-review aktuálního stavu kódu byly uzavřeny tyto nálezy (KEC-26):
- MySQL TLS konfigurace je podporovaná přes `MYSQL_SSL_CA`, `MYSQL_SSL_CERT`, `MYSQL_SSL_KEY` (`db.py`, `.env.example`).
- SMTP `STARTTLS` používá explicitní `ssl.create_default_context()` a `EHLO` před/po TLS upgrade (`server.py`).
- Avatar upload flow nastavuje explicitně `chmod(0o600)` i pro user/group avatary (`server.py`).
Scope limitation aktuálního workspace:
- V repozitáři chybí `ios_client/` i jakýkoli Android klient, přestože jsou zmiňované v zadání i README. Tento refresh proto pokrývá pouze server a Python klienty.
- Historický nález o plaintext secrets v `.env` nelze v tomto snapshotu reprodukovat; workspace obsahuje pouze `.env.example`.
Reziduální architektonické riziko:
- Self-encryption klíč je z definice statický a deterministický; kompromitace identity private key proto zpřístupní všechny self-copies napříč historií (`crypto_utils.py:329-341`). To je tradeoff současného cross-device designu, ne implementační bug.
## Executive Summary
Nejzávažnější aktuálně otevřené nálezy:
- Reziduální architektonický tradeoff: statický/deterministický self-encryption klíč pro self-copies.
- Mobilní klienti deklarovaní v dokumentaci nejsou součástí tohoto workspace, takže jejich security stav zůstává neověřený.
## CRITICAL
### ~~C1. TOFU / verifikace identity klíče se obchází při běžném X3DH flow~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- TOFU kontrola existuje jen v `_get_user_info()` (`chat_core.py:799-803`).
- Při navazování session (`_get_or_create_session`) se `identity_key` z bundle bere přímo bez TOFU kontroly (`chat_core.py:1497-1501`, `chat_core.py:1534-1538`).
- U příchozího X3DH (`_process_x3dh_header`) se remote IK také uloží bez TOFU kontroly (`chat_core.py:1551-1553`, `chat_core.py:1580-1584`).
**Dopad**
- Pokud server nebo MITM podstrčí jiný identity key, klient může navázat session bez varování.
- Prakticky to obchází uživatelskou verifikaci kontaktu ve výchozím messaging flow.
**Oprava**
- Nová výjimka `IdentityKeyChanged(user_id, new_key_bytes, status)` v `chat_core.py` — hard-fail při změně identity klíče.
- `_get_or_create_session()`: TOFU check přes `check_identity_key()` před X3DH initiate. Při `changed`/`changed_verified` vyhodí `IdentityKeyChanged` — session se nenaváže.
- `_process_x3dh_header()`: TOFU check před X3DH respond. Stejný hard-fail — příchozí zpráva s podvrženým klíčem je odmítnuta.
- GUI: `IdentityKeyChanged` zachycena v notification loopu (emituje `key_change_warning` signál místo pádu loopu) a v `_do_send_message` (zobrazí error + warning dialog).
- Session je blokována dokud uživatel explicitně neakceptuje key change přes `accept_key_change()`.
- `decrypt_notification()`: explicitní `except IdentityKeyChanged: raise` před generickým `except Exception` — výjimka se propaguje do notification loopu místo tichého spolknutí.
- `key_change_warning` signál rozšířen o 5. parametr `new_key_bytes: bytes` — "Accept New Key" dialog předává nový klíč přímo z výjimky, ne z cache (která mohla obsahovat starý klíč).
- `IdentityKeyChanged` ošetřena ve všech GUI send cestách: `_do_send_image`, `_do_send_file`, `_do_forward_message`, `_do_find_or_create_and_send` — zobrazí warning dialog + error message.
- CLI (`client.py`): `IdentityKeyChanged` ošetřena ve všech 6 send cestách (send_message ×3, send_image, send_file, forward_message).
---
### ~~C2. Perzistentní DoS konverzace přes nevalidní message headers~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Server přijímá `ratchet_header` / `x3dh_header` i jako raw `str/bytes` bez JSON schema validace (`server.py:1105-1112`, `server.py:1146-1151`).
- Při `get_messages` se hodnoty bez ochrany parsují `json.loads(...)` (`server.py:1266`, `server.py:1274`).
**Dopad**
- Útočník v konverzaci může uložit “poisoned” hlavičku a rozbít načtení historie ostatním členům (`Internal server error`).
- Chyba je perzistentní, dokud je vadná zpráva v historii.
**Oprava**
- Nový helper `_validate_header(raw, name)` v `server.py` — přijímá pouze `dict`, odmítá `str`/`bytes`, limit 4096 bajtů.
- `handle_send_message`: message-level i per-recipient headers procházejí `_validate_header()`. Nevalidní hlavička → error response, zpráva se neuloží.
- `handle_get_messages`: `json.loads()` obaleno `try/except` (JSONDecodeError, TypeError, UnicodeDecodeError). Corrupted header → prázdný dict `{}` + warning log, ostatní zprávy se načtou normálně.
- `_validate_header()` rozšířena o validaci očekávaných klíčů a typů pro ratchet headers (`dh_pub`: str, `n`: int, `pn`: int) a používá striktní kontrolu typu pro `n/pn` (`type(...) is int`) — `bool` je explicitně odmítnut.
- Realtime push notifikace nyní čtou data z validovaných `db_recipients` (ne z `recipients_raw`). Per-recipient hlavičky se dekódují z validovaných bytes zpět do `dict` pro JSON notifikaci.
- `encrypted_content` a `nonce` v push notifikacích se skládají z validovaných raw bytes a serializují se přes `encode_binary()` — untrusted hodnoty z raw requestu se do push větve nepropíší.
---
### C3. Historický nález: plaintext tajemství v `.env` a `zaloha/.env`
Status 2026-03-27: v aktuálním workspace nereprodukovatelné.
**Evidence**
- Tento snapshot obsahuje `.env.example`, ale neobsahuje `.env` ani `zaloha/`.
- Původní nález tedy nelze znovu ověřit bez jiného artefaktu nebo deploy prostředí.
**Dopad**
- Pokud jsou runtime secrets stále ukládány v reálném `.env` mimo tento snapshot, únik takového souboru by stále znamenal okamžitý přístup do DB.
**Doporučení**
1. Okamžitě rotovat DB heslo.
2. Nahradit repozitářové `.env` šablonou (`.env.example`) bez tajemství.
3. Použít secrets manager / deployment-level secret injection.
## HIGH
### ~~H1. Chybí TLS mezi aplikací a MySQL~~ ✅ OPRAVENO (2026-03-27)
**Evidence**
- DB pool načítá volitelné TLS parametry `MYSQL_SSL_CA`, `MYSQL_SSL_CERT`, `MYSQL_SSL_KEY` a předává je do `MySQLConnectionPool` (`db.py`).
- Konfigurační šablona je doplněná o stejné proměnné (`.env.example`).
**Dopad**
- Odposlech nebo MITM na trase app<->DB může odhalit credentials i data.
**Oprava**
1. Přidána podpora TLS parametrů v DB vrstvě.
2. Přidány dokumentované env proměnné pro CA/client cert/client key.
---
### ~~H2. Upload/avatary na disku mají slabá oprávnění~~ ✅ OPRAVENO (2026-03-27)
**Evidence**
- Avatar upload flow nyní po zápisu explicitně nastavuje `chmod(0o600)` pro user i group avatary (`server.py`).
**Dopad**
- Lokální uživatelé na stejném hostu mohou číst citlivá data (včetně avatarů v plaintextu).
**Oprava**
1. Explicitní `chmod(0o600)` po zápisu avatar souborů.
2. Adresář `uploads/avatars` zůstává s `0700`.
---
### ~~H3. `session_reset` nemá autorizační vazbu na vztah mezi uživateli~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Handler přijme libovolné validní `peer_user_id` a pošle notifikaci (`server.py:2040-2052`).
- Neověřuje, že uživatelé sdílí konverzaci nebo existuje session.
**Dopad**
- Možnost spam/DoS reset notifikací na cílové uživatele.
**Oprava**
- Nová DB funkce `db.shares_conversation(user_id_a, user_id_b)``SELECT 1 ... LIMIT 1` přes `conversation_members` JOIN.
- `handle_session_reset`: před push notifikací ověřuje `shares_conversation()`. Pokud uživatelé nesdílí žádnou konverzaci → error response.
- Rate limit 5 požadavků/min na `session_reset` per user (`session_reset|{user_id}`) — IP adresa není součást klíče, takže změnou IP nejde limit obejít.
- Pokud je předán `peer_device_id`, reset notifikace se doručí pouze cílovému zařízení (filtr přes `writer_device_map`). Bez `peer_device_id` zůstává broadcast na všechna zařízení peera.
---
### ~~H4. User enumeration přes pairing a user-info endpointy~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `pairing_start` vrací explicitně `User not found` (`server.py:763-766`).
- `get_user_info` vrací metadata uživatele při lookupu přes email/user_id (`server.py:551-564`).
**Dopad**
- Snadné mapování existence účtů.
**Oprava**
- `handle_pairing_start`: vždy vrací `ok` s platně vypadajícím kódem a session se vytvoří vždy (i pro neexistující email), takže `pairing_poll` vrací nerozlišitelné `ready: false`.
- Přidán globální cap `PAIRING_MAX_SESSIONS = 100` pro omezení počtu současných pairing sessions (DoS hardening).
- `pairing_start` rate limit je per-IP (bez email komponenty), aby nešel obcházet rotací emailů.
- `pairing_claim` i `pairing_send`: sjednocená chyba `Invalid or expired code` (žádné rozlišení "neexistuje" vs "patří jinému účtu").
- V pairing flow se síťové I/O (`send_resp`) volá až po uvolnění `_pairing_lock`.
- `handle_get_user_info`: přidán parametr `session` (vyžaduje login). Lookupovat lze jen sebe nebo kontakty (ověřeno přes `shares_conversation()`). Pro neexistující i nepovolené cíle vrací neutrální "User not found".
- Doplňuje dřívější anti-enumeration opravy: `register_start` (generická odpověď), `login_start` (fake challenge), `login_finish` (generická chyba).
---
### ~~H5. Phantom user inflation přes `create_conversation` / `find_conversation` / `add_member` (DoS)~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `create_conversation` vytváří phantom účty pro neznámé emaily bez dedikovaného rate limitu (`server.py:906-920`).
- `find_conversation` a `add_member` rate limitují přes `_rate_limit_key(..., addr, email)`, takže rotace emailů obchází limit (`server.py:972`, `server.py:1001`, `server.py:209-212`).
- `create_phantom_user()` pro každý nový email generuje IK+SPK+OTP a zapisuje více řádků do DB (`db.py:1470-1507`).
**Dopad**
- Útočník může nafukovat DB a CPU náklady (kryptografická generace + zápisy), případně degradovat výkon serveru.
**Oprava**
1. `_can_create_phantom(addr, user_id)` helper kontroluje 3 limity před každým `create_phantom_user()`:
- Globální cap: `MAX_PHANTOM_USERS = 500` (počet v `phantom_user_ids` setu)
- Per-user rate: `phantom_create|{user_id}` — 10/min (email-nezávislé, neobejitelné rotací)
- Per-IP rate: `phantom_create_ip|{addr}` — 10/min (email-nezávislé)
2. `create_conversation` nově má per-user rate limit 10/min + phantom check před každým členem.
3. `find_conversation` a `add_member` — existující per-addr+email limit zůstává (brání hammering jednoho emailu), přidán `_can_create_phantom` check před vytvořením phantomu.
4. Stávající `cleanup_stale_phantoms(30)` v periodic cleanup (10 min) zajišťuje garbage collection.
---
### ~~H6. `pending_registrations` nemá hard cap (memory/SMTP abuse)~~ ✅ OPRAVENO (2026-03-16)
**Evidence**
- `pending_registrations` byl původně globální in-memory dict bez horního limitu.
- Cleanup expirovaných registrací se dříve spouštěl jen v `register_*` flow.
- Rate limit `register_start` byl vázaný i na email, takže šel obcházet rotací emailových adres.
**Dopad**
- Riziko růstu paměti, zaplnění slotů a SMTP abuse při masivním `register_start`.
**Oprava**
1. Přidán globální cap `MAX_PENDING_REGISTRATIONS = 1000` (`server.py:180`).
2. Přidány slot limity `MAX_PENDING_PER_IP = 5` a `MAX_PENDING_PER_SUBNET = 20` (`server.py:181-182`).
3. `_cleanup_registrations()` běží i v periodickém cleanup tasku (`server.py:327`, `server.py:2920`).
4. Přidán per-IP rate limit `register_start_ip|{addr}` (`server.py:476`) a pressure mode s PoW při vysokém zaplnění (`server.py:183`, `server.py:521`).
5. SMTP throttling je vícevrstvý: global, per-IP a per-target (`server.py:185-187`, `server.py:580`).
**Poznámka**
- Residual risk zůstává při multi-process nasazení nad stejnou DB: caps a rate-limity jsou in-memory per process, ne globálně distribuované.
---
### ~~H7. Pairing flow důvěřuje serverem vrácenému `temp_public_key` (exfiltrace account private keys)~~ ✅ OPRAVENO (2026-03-16)
**Evidence**
- Nové zařízení pošle do `pairing_start` svůj `temp_public_key`; server si ho uloží do `pairing_sessions` (`server.py:1067`, `server.py:1089-1095`).
- Staré zařízení v `pairing_claim` získá `temp_public_key` zpět ze serveru a bez další autentizace ho načte (`server.py:1116-1140`, `chat_core.py:1441-1446`).
- Staré zařízení následně do payloadu zabalí `rsa_private` a `identity_private` a zašifruje je právě pod tento `temp_public_key` (`chat_core.py:1451-1477`).
**Dopad**
- Kompromitovaný nebo aktivně škodlivý server může v odpovědi na `pairing_claim` podvrhnout vlastní `temp_public_key`.
- Staré zařízení pak zašifruje exportovaný payload pod klíč útočníka/serveru, který tím získá `rsa_private` i `identity_private` oběti.
- To znamená plné převzetí účtu, možnost přihlášení jako oběť a přístup k self-encrypted historii (derivace `self/local` klíčů z identity private key).
**Oprava**
1. Přidán `compute_pairing_fingerprint()` helper nad raw dočasným veřejným klíčem (30 číslic pro ruční porovnání).
2. Pairing bootstrap už nepoužívá dočasný RSA transport, ale `X25519 + HKDF + AES-GCM`: nové zařízení pošle dočasný X25519 public key, staré zařízení vygeneruje jednorázový X25519 sender key a obě strany odvodí stejný symmetric bootstrap key z DH shared secret.
3. Nové zařízení po `pairing_start` zobrazuje 8místný kód i fingerprint dočasného pairing klíče.
4. Staré zařízení při `authorize_device` vyžaduje fingerprint opsaný z nového zařízení; před `pairing_send` vypočítá fingerprint klíče vráceného serverem a při neshodě celý pairing odmítne.
5. Tím se zavádí povinná out-of-band vazba mezi oběma zařízeními a server už nemůže nepozorovaně podvrhnout vlastní pairing key ani získat bootstrap secret.
---
## MEDIUM
### ~~M6. Chybí auditní notifikace po přidání nového zařízení~~ ✅ OPRAVENO (2026-03-16)
**Evidence**
- Po úspěšném `pairing_send` server pouze uloží payload a vrátí `OK` (`server.py:1143-1175`).
- V serveru ani klientech není samostatný notif type typu `device_added` / `device_linked`; zařízení lze zjistit až dodatečně přes `list_devices`.
**Dopad**
- Pokud uživatel omylem nebo po sociálním inženýrství schválí cizí pairing kód, ostatní aktivní zařízení nedostanou okamžitý auditní signál.
- Zhoršuje to detekci zneužití a forenzní dohledatelnost.
**Oprava**
1. Server po prvním loginu nově vytvořeného zařízení posílá na ostatní zařízení účtu push notifikaci `device_added`.
2. Payload notifikace obsahuje `device_id`, `device_name`, zdrojovou IP a čas přidání.
3. GUI zobrazuje bezpečnostní alert a zvýrazněný status bar.
4. CLI vypisuje explicitní auditní hlášku s doporučením okamžité rotace klíčů, pokud zařízení uživatel nepoznává.
---
### ~~M1. `mark_read` a `confirm_delivery` neověřují, že `message_ids` patří do dané konverzace~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Handler validuje členství jen v `conversation_id` (`server.py:1464-1479`, `server.py:1516-1531`).
- DB insert metody pro receipts neváží `message_id` na konverzaci (`db.py:1102-1113`, `db.py:1188-1200`).
**Dopad**
- Možná manipulace read/delivery stavu cizích zpráv (integrita metadat).
**Oprava**
- `db.mark_messages_read()` a `db.mark_messages_delivered()` nahrazeny z per-row `INSERT IGNORE` na batch `INSERT IGNORE ... SELECT m.id, %s FROM messages m WHERE m.id IN (...) AND m.conversation_id = %s`.
- Message IDs, které nepatří do dané konverzace, jsou tiše přeskočeny (SELECT je nevrátí).
---
### ~~M2. SMTP STARTTLS bez explicitního TLS contextu~~ ✅ OPRAVENO (2026-03-27)
**Evidence**
- SMTP flow používá `server.starttls(context=ssl.create_default_context())` a `EHLO` před/po TLS upgrade (`server.py`).
**Dopad**
- Slabší kontrola TLS parametrů/verifikace dle runtime prostředí.
**Oprava**
1. Přidán explicitní TLS context pro STARTTLS.
2. Přidán `EHLO` před i po TLS upgrade.
---
### ~~M3. CLI klient: několik lokálních hardening mezer~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- Heslo se zadává přes `input()` (echo on) (`client.py:730`, `client.py:749`, `client.py:754`).
- Zprávy se tisknou bez sanitace escape sekvencí (`client.py:491`).
- Default save path při downloadu je převzat z remote `filename` (`client.py:523-530`).
**Dopad**
- Shoulder-surfing hesla, terminal escape spoofing, riskantní defaultní save path.
**Oprava**
- Všechny password prompty (register, login, pairing, authorize device, rotate keys) nahrazeny `prompt_password()` wrapping `getpass.getpass()` — heslo se nezobrazuje na terminálu.
- `_sanitize_text()` helper stripuje control znaky (`\x00-\x1f` kromě `\t`/`\n`/`\r`) a ANSI escape sekvence. Aplikováno na `sender`, `text`, `filename` při výpisu zpráv v `_print_messages()`.
- Follow-up: `_sanitize_text()` nyní bezpečně přijímá i non-string vstupy (`None -> ""`, jinak `str(...)`), čímž se eliminuje `TypeError` při neočekávaném typu z payloadu (`client.py:32-36`).
- Follow-up: sanitace rozšířena na zbývající user-controlled CLI výpisy — seznam konverzací (`client.py:63-67`), search výsledky (`client.py:293-300`), seznam pozvánek (`client.py:612-614`), profil (`client.py:637-644`), seznam zařízení (`client.py:709-713`), verify view (`client.py:435`, `client.py:446`) a notifikace včetně reaction hodnoty (`client.py:752-774`).
- `_safe_filename()` helper: `os.path.basename()` + odstranění NUL + fallback na `"download"` pro prázdné/tečkové názvy. Aplikováno na default save path při downloadu.
---
### ~~M4. `get_key_bundle` umožňuje OPK depletion (availability)~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `handle_get_key_bundle` nemá rate limit ani authorizační vazbu na vztah mezi uživateli (`server.py:648-660`).
- DB vrstva při každém volání spotřebovává one-time prekeys (`get_key_bundles_for_user` — „Consumes one OPK per device atomically”, `db.py:394-450`).
- `target_user_id` lze získat přes `find_conversation` lookup (`server.py:966-987`).
**Dopad**
- Útočník může opakovanými dotazy vyčerpat OPK oběti, zhoršit doručitelnost a vynutit časté doplňování prekeys.
**Oprava**
1. Per-caller rate limit: `get_key_bundle|{user_id}` — 10/min (omezuje celkový počet fetchů jednoho uživatele).
2. Per-target rate limit: `get_key_bundle_target|{target_user_id}` — 20/min (omezuje rychlost vyčerpávání OPK konkrétní oběti). Autorizace probíhá před per-target RL (neautorizovaný request nespálí bucket cíle).
3. Autorizace: `shares_conversation()` — caller musí sdílet konverzaci s cílem (self-fetch povolen vždy).
4. Chybová zpráva pro neautorizovaný přístup je neutrální (`”Key bundle not available”`) — shodná s neexistujícím uživatelem.
5. **Doplňující per-user rate limity** na všechny zbývající výpočetně/DB náročné handlery (celkem 29 RL checks):
- Crypto+DB: `upload_prekeys` 5/min, `ensure_prekeys` 5/min, `rotate_keys` 3/min, `reencrypt` 10/min
- DB-heavy: `get_messages` 30/min, `delete_conv` 5/min, `delete_msg` 20/min, `react` 20/min, `remove_member` 10/min, `rename_conv` 5/min
- File I/O: `update_avatar` 5/min (sdílený bucket pro user i group avatar)
---
### ~~M5. `upload_image_start` nemá anti-DoS cap na in-flight uploady~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `upload_image_start` nevynucuje request rate limit ani limit počtu aktivních uploadů na user/IP (`server.py:1786-1823`).
- In-memory `pending_uploads` je bez explicitního capu (`server.py:58`, `server.py:1812-1819`).
- Cleanup stale uploadů běží periodicky (600s) a DB stale threshold je 3600s (`server.py:2488-2490`, `db.py:1626-1633`).
**Dopad**
- Útočník může zahájit mnoho uploadů a vytvářet dočasné soubory/záznamy, což zvyšuje memory/disk tlak.
**Oprava**
1. Per-user rate limit: `upload_start|{user_id}` — 10/min.
2. Globální cap: `MAX_UPLOADS_GLOBAL = 200` (kontrola `len(pending_uploads)` pod `_uploads_lock`).
3. Per-user cap: `MAX_UPLOADS_PER_USER = 5` (počet záznamů s `uploader_id == user_id`).
4. Stale threshold snížen z 3600s na `UPLOAD_STALE_SECONDS = 600` (10 min).
5. Periodic cleanup interval snížen z 600s na 120s (2 min).
## LOW
### ~~L1. `decode_binary` není strict base64~~ ✅ OPRAVENO (2026-03-08)
**Evidence**
- `base64.b64decode(data)` bez `validate=True` (`protocol.py:18`).
**Dopad**
- Méně striktní input parsing (robustnost), ne přímý průnik.
**Oprava**
- `decode_binary()` nyní volá `base64.b64decode(data, validate=True)` — odmítá neplatné base64 znaky (whitespace, non-alphabet).
---
### ~~L2. Pairing používá dočasný RSA-2048 klíč, zatímco zbytek aplikace standardně generuje RSA-4096~~ ✅ OPRAVENO (2026-03-16)
**Evidence**
- `pairing_start()` generuje dočasný RSA klíč přes `generate_rsa_keypair(2048)` (`chat_core.py:1359`).
- Default helper `generate_rsa_keypair()` používá 4096 bitů (`crypto_utils.py:64`).
**Dopad**
- Není to primární slabina pairing flow; hlavní problém je autenticita `temp_public_key` (H7).
- Přesto jde o zbytečně slabší parametr pro přenos payloadu obsahujícího account private keys.
**Oprava**
1. Pairing bootstrap už dočasné RSA vůbec nepoužívá; byl nahrazen `X25519 + HKDF + AES-GCM`.
2. Tím odpadá původní důvod pro sjednocení na RSA-4096.
---
### ~~L3. Pairing UX explicitně nevaruje, že párovací kód se nesmí sdílet~~ ✅ OPRAVENO (2026-03-16)
**Evidence**
- GUI po vygenerování kódu zobrazuje instrukci k jeho schválení, ale bez výrazného warningu proti sdílení kódu.
- Aktuální pairing model spoléhá na to, že uživatel 8místný kód neprozradí třetí straně.
**Dopad**
- Zvyšuje se riziko sociálního inženýrství ("nadiktujte mi kód z nového zařízení"), i když brute-force samotného kódu je při současných limitech nepraktický.
**Oprava**
1. GUI i CLI nyní při párování zobrazují explicitní warning `Never share this pairing code.`
2. Fingerprint nového zařízení se zobrazuje spolu s kódem, takže uživatel dostává zároveň instrukci k bezpečnému ručnímu ověření.
3. GUI nově zobrazuje pairing QR a staré zařízení ho může načíst ze souboru místo ručního opisování kódu a fingerprintu.
---
### ~~L4. `reencrypt_history()` po pairingu prozrazuje serveru timing a rozsah self-history~~ ✅ MITIGOVÁNO (2026-03-17)
**Evidence**
- Po úspěšném `pairing_send` staré zařízení asynchronně spouští `reencrypt_history()` (`chat_core.py:1477-1485`).
- Server z batch operací vidí, že právě proběhlo párování, a přibližně kolik self-encrypted zpráv bylo potřeba přegenerovat.
**Dopad**
- Jde o metadata leak, nikoli o únik obsahu zpráv.
- Server může odhadnout velikost historie a intenzitu používání účtu.
**Oprava**
1. Post-pairing history resync už nezačíná okamžitě; běží po náhodném odkladu.
2. Pořadí konverzací i pořadí zpráv se před fetch/upload fází míchá.
3. Mezi fetch cykly i mezi upload batchi je náhodný jitter, takže pairing už negeneruje tak snadno korelovatelný burst.
4. Residual leak zůstává nízký: server stále ví, že nějaký history resync proběhl, ale výrazně hůř z něj odvodí přesný okamžik pairingu a strukturu resyncu po konverzacích.
## Positive Findings
- Dev-only guardy: `TLS_INSECURE` a `TLS_AUTOGEN` jsou blokovány mimo `ENVIRONMENT=dev`.
- Server používá UUID validace v řadě handlerů.
- Upload/download ověřuje členství v konverzaci.
- Klientské private keys/storage používají PBKDF2 + AES-GCM a restriktivní perms (`0700`/`0600`) v key storage.
- Přítomný client-side lockout na opakované chybné login pokusy.
- Pairing má anti-enumeration ochrany: generická odpověď, per-IP rate limit, `poll_token`, sjednocené chyby `Invalid or expired code` a krátkodobé pairing sessions.
## Prioritní plán oprav
### 0-48 hodin
1. Rotace DB hesla + odstranění tajemství z `.env`.
2. ~~Zavést OOB autentizaci pairingu (fingerprint / QR / SAS) a nepovažovat serverem vrácený `temp_public_key` za důvěryhodný.~~ ✅ DONE
3. ~~Oprava TOFU bypassu v obou X3DH cestách.~~ ✅ DONE
4. ~~Zablokování nevalidních message headers na vstupu.~~ ✅ DONE
5. Přepnutí upload storage perms na `0700/0600`.
6. ~~Omezit phantom creation (rate limit bez emailu + cap).~~ ✅ DONE
7. ~~Zavést cap pro `pending_registrations` a čistit je i v periodickém cleanupu.~~ ✅ DONE
8. ~~Přidat cap/rate limit na in-flight uploady.~~ ✅ DONE
### 7 dní
1. Zapnout TLS mezi app a MySQL (mTLS nebo minimálně server cert verify).
2. ~~Opravit autorizaci `session_reset`.~~ ✅ DONE
3. ~~Opravit vazbu `message_ids` na `conversation_id` pro receipts.~~ ✅ DONE
4. ~~Omezit `get_key_bundle` (rate limit + policy sdílené konverzace).~~ ✅ DONE
5. ~~Přidat `device_added` audit notifikaci a zobrazit ji v GUI/CLI.~~ ✅ DONE
### 30 dní
1. ~~Anti-enumeration sjednotit napříč endpointy.~~ ✅ DONE
2. ~~CLI hardening (`getpass`, output sanitace, filename sanitace).~~ ✅ DONE
3. Doplnit integrační testy pro bezpečnostní regresi (TOFU, poisoned headers, receipt authz, session_reset device targeting, anti-enumeration, DoS caps, pairing MITM / device-added audit).

138
TODO.md Normal file
View File

@@ -0,0 +1,138 @@
# TODO
## Zbývající bezpečnostní nálezy
### HIGH
- [ ] **H9: Self-encryption key** — statický/deterministický klíč z identity key (by-design pro cross-device čtení, architektonické omezení — žádná forward secrecy pro self-copies)
### MEDIUM
- [x] **M7: MySQL TLS**`db.get_connection()` podporuje SSL parametry (`MYSQL_SSL_CA`, `MYSQL_SSL_CERT`, `MYSQL_SSL_KEY`).
### LOW (nízké riziko)
- [ ] L1: Hex string keys v skipped messages dict — timing side-channel (post-auth)
- [ ] L2: RatchetHeader redundantní type konverze
- [ ] L3: `notif_label.setText()` vs `setHtml()` křehkost
- [ ] L4: SQL column interpolation v `update_user_profile` (whitelist chrání)
- [ ] L5: TLS cipher suite hardening (Python defaults rozumné, ne explicitní)
- [ ] L6: Temporary pairing key cleanup z paměti
- [ ] L7: `_user_cache` indefinite growth
## Funkční TODO
### High Priority
- [ ] **Sender Key Redistribution** — při `add_member` redistribuovat sender keys všem členům včetně nového. Nový člen skupiny momentálně nedešifruje staré zprávy.
### Medium Priority
- [ ] **iOS: Contact Key Verification** — safety numbers, fingerprints, QR kódy, TOFU registr. Spec viz CLAUDE.md (iOS implementation spec).
- [x] Typing indicators (`typing_start`/`typing_stop` + 3s timeout, debounce)
- [x] 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.
- [x] **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] M2 (SMTP): STARTTLS s explicitním `ssl.create_default_context()` + `EHLO` před/po TLS upgrade
- [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] Avatar file perms: explicitní `chmod(0o600)` pro user/group avatary
- [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 64B64KB)
- [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] TCP keepalive (SO_KEEPALIVE idle=25s, interval=10s, count=3) + dead writer cleanup
- [x] Optimistic message send (okamžité zobrazení v UI, server potvrzení na pozadí)
- [x] Cache-first message loading (okamžité zobrazení z disku, server sync na pozadí)
- [x] Fetch deduplication (_messages_inflight set)
- [x] Notification push logging ([PUSH] s počtem writerů per příjemce)
- [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í)

101
certs/README.md Normal file
View File

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

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

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

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

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

3907
chat_core.py Normal file

File diff suppressed because it is too large Load Diff

928
client.py Normal file
View File

@@ -0,0 +1,928 @@
"""Interactive CLI client for encrypted chat (X3DH + Double Ratchet)."""
import asyncio
import getpass
import logging
import os
import re
from chat_core import ChatClient, IdentityKeyChanged
def setup_logging():
level_name = os.getenv("LOG_LEVEL", "WARNING").upper()
level = getattr(logging, level_name, logging.WARNING)
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
async def prompt(text: str) -> str:
"""Non-blocking terminal input."""
return await asyncio.get_event_loop().run_in_executor(None, lambda: input(text).strip())
async def prompt_password(text: str = "Password: ") -> str:
"""Non-blocking hidden password input (M3 fix)."""
return await asyncio.get_event_loop().run_in_executor(None, lambda: getpass.getpass(text))
# M3 fix: strip terminal control/escape sequences from untrusted text
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]")
def _sanitize_text(s) -> str:
"""Remove control characters and ANSI escape sequences."""
if not isinstance(s, str):
s = str(s) if s is not None else ""
return _CONTROL_RE.sub("", s)
def _safe_filename(name: str) -> str:
"""Sanitize remote filename: basename only, no path traversal, no NUL."""
name = os.path.basename(name)
name = name.replace("\x00", "")
if not name or name.startswith("."):
name = "download"
return name
def _human_size(n: int) -> str:
if n >= 1024 * 1024:
return f"{n / (1024*1024):.1f} MB"
if n >= 1024:
return f"{n / 1024:.0f} KB"
return f"{n} B"
async def _select_conversation(client: ChatClient, label: str = "Select conversation") -> tuple[dict | None, list[dict]]:
"""List conversations and let user pick one. Returns (conv, convs) or (None, [])."""
convs = await client.list_conversations()
if not convs:
print("[*] No conversations.")
return None, []
def conv_label(c):
if c.get("name"):
return _sanitize_text(c["name"])
others = [_sanitize_text(m.get("username") or m.get("email") or "?") for m in c["members"] if m.get("email") != client.email]
return ", ".join(others) if others else _sanitize_text(client.username)
print()
for i, c in enumerate(convs):
print(f" {i+1}) {conv_label(c)}")
choice = await prompt(f"{label}: ")
try:
idx = int(choice) - 1
if not (0 <= idx < len(convs)):
print("[!] Invalid selection.")
return None, convs
except ValueError:
print("[!] Invalid selection.")
return None, convs
return convs[idx], convs
async def interactive_menu(client: ChatClient):
"""Interactive terminal menu."""
while True:
print("\n--- Encrypted Chat ---")
print("1) Send direct message")
print("2) Send to conversation")
print("3) Read messages")
print("4) Create group conversation")
print("5) Add member to group")
print("6) Send image")
print("7) Send file")
print("8) Invitations")
print("9) Leave group")
print("10) Rename group")
print("11) Delete conversation")
print("12) Search messages")
print("13) My profile")
print("14) View user profile")
print("15) Manage devices")
print("16) React to message")
print("17) Pin/Unpin message")
print("18) View pinned messages")
print("19) Forward message")
print("20) Verify contact")
print("21) Show my fingerprint")
print("22) Change password")
print("23) Change username")
print("q) Quit")
choice = await prompt("> ")
if choice == "1":
email = await prompt("To (email): ")
if not email:
continue
text = await prompt("Message: ")
if not text:
continue
conv_id, msg = await client.find_or_create_conversation(email)
if not conv_id:
print(f"[!] {msg}")
continue
convs = await client.list_conversations()
members = []
for c in convs:
if c["conversation_id"] == conv_id:
members = c["members"]
break
try:
ok, result = await client.send_message(conv_id, text, members)
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
elif choice == "2":
conv, _ = await _select_conversation(client)
if not conv:
continue
text = await prompt("Message: ")
if not text:
continue
try:
ok, result = await client.send_message(conv["conversation_id"], text, conv["members"])
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
elif choice == "3":
conv, _ = await _select_conversation(client)
if not conv:
continue
messages = await client.get_messages(conv["conversation_id"])
if not messages:
print("[*] No messages.")
continue
_print_messages(messages, client, conv)
action = await prompt("\nAction (r=reply, d=delete, dl=download file, empty=back): ")
if not action:
continue
if action.lower().startswith("dl"):
await _download_file_action(client, messages)
continue
if action.lower().startswith("d"):
await _delete_message_action(client, messages)
continue
if action.lower().startswith("r"):
reply_choice = await prompt("Reply to message #: ")
else:
reply_choice = action
try:
reply_idx = int(reply_choice) - 1
if not (0 <= reply_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
reply_to_id = messages[reply_idx]["message_id"]
text = await prompt("Message: ")
if not text:
continue
try:
ok, result = await client.send_message(conv["conversation_id"], text, conv["members"], reply_to=reply_to_id)
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
elif choice == "4":
name = await prompt("Group name (empty for none): ")
members_input = await prompt("Member emails (comma-separated): ")
members = [m.strip() for m in members_input.split(",") if m.strip()]
if not members:
continue
conv_id, msg = await client.create_conversation(members, name=name.strip() or None)
if conv_id:
print(f"[+] Group created with: {', '.join(members)}")
else:
print(f"[!] {msg}")
elif choice == "5":
conv, _ = await _select_conversation(client)
if not conv:
continue
email = await prompt("Email to add: ")
ok, msg = await client.add_member(conv["conversation_id"], email)
print(f"[{'+'if ok else '!'}] {msg or 'Invitation sent.'}")
elif choice == "6":
conv, _ = await _select_conversation(client)
if not conv:
continue
image_path = await prompt("Image path: ")
if not image_path:
continue
try:
ok, msg = await client.send_image(conv["conversation_id"], image_path, conv["members"])
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "7":
conv, _ = await _select_conversation(client)
if not conv:
continue
file_path = await prompt("File path: ")
if not file_path:
continue
if not os.path.isfile(file_path):
print("[!] File not found.")
continue
try:
ok, msg = await client.send_file(conv["conversation_id"], file_path, conv["members"])
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "8":
await _invitations_menu(client)
elif choice == "9":
conv, _ = await _select_conversation(client, "Select group to leave")
if not conv:
continue
confirm = await prompt(f"Leave '{conv.get('name', 'this conversation')}'? (y/n): ")
if confirm.lower() != "y":
continue
ok, msg = await client.leave_group(conv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "10":
conv, _ = await _select_conversation(client, "Select group to rename")
if not conv:
continue
name = await prompt("New name: ")
if not name:
continue
ok, msg = await client.rename_conversation(conv["conversation_id"], name.strip())
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "11":
conv, _ = await _select_conversation(client, "Select conversation to delete")
if not conv:
continue
confirm = await prompt("Delete this conversation? This cannot be undone. (y/n): ")
if confirm.lower() != "y":
continue
ok, msg = await client.delete_conversation(conv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "12":
conv, _ = await _select_conversation(client, "Select conversation to search")
if not conv:
continue
query = await prompt("Search query: ")
if not query:
continue
# First ensure we have messages cached by fetching them
await client.get_messages(conv["conversation_id"])
results = client.search_messages(conv["conversation_id"], query)
if not results:
print("[*] No matches found.")
continue
print(f"\n[*] {len(results)} match(es):")
for r in results:
sender = _sanitize_text(r.get("sender", "???"))
text = _sanitize_text(r.get("text", ""))
ts = r.get("created_at", "")[:16]
# Highlight match in text
idx = text.lower().find(query.lower())
if idx >= 0:
text = text[:idx] + "\033[33m" + text[idx:idx+len(query)] + "\033[0m" + text[idx+len(query):]
print(f" [{ts}] {sender}: {text}")
elif choice == "13":
await _my_profile_menu(client)
elif choice == "14":
email = await prompt("User email: ")
if not email:
continue
# Need to find user_id from email — try via conversation members
user_id = None
convs = await client.list_conversations()
for c in convs:
for m in c.get("members", []):
if m.get("email") == email:
user_id = m.get("user_id") or m.get("id")
break
if user_id:
break
if not user_id:
print("[!] User not found in your conversations.")
continue
profile = await client.get_profile(user_id)
if not profile:
print("[!] Could not load profile.")
continue
_print_profile(profile)
elif choice == "15":
await _devices_menu(client)
elif choice == "16":
conv, _ = await _select_conversation(client)
if not conv:
continue
messages = await client.get_messages(conv["conversation_id"])
if not messages:
print("[*] No messages.")
continue
_print_messages(messages, client, conv)
msg_choice = await prompt("React to message #: ")
try:
msg_idx = int(msg_choice) - 1
if not (0 <= msg_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown")
reaction = await prompt("Reaction: ").strip().lower()
if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"):
print("[!] Invalid reaction.")
continue
ok, msg = await client.react_message(messages[msg_idx]["message_id"], reaction, "add")
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "17":
conv, _ = await _select_conversation(client)
if not conv:
continue
messages = await client.get_messages(conv["conversation_id"])
if not messages:
print("[*] No messages.")
continue
_print_messages(messages, client, conv)
msg_choice = await prompt("Pin/Unpin message #: ")
try:
msg_idx = int(msg_choice) - 1
if not (0 <= msg_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
m = messages[msg_idx]
action = "unpin" if m.get("pinned_at") else "pin"
ok, msg = await client.pin_message(m["message_id"], conv["conversation_id"], action)
print(f"[{'+'if ok else '!'}] {action.capitalize()}: {msg}")
elif choice == "18":
conv, _ = await _select_conversation(client)
if not conv:
continue
pinned = await client.get_pinned_messages(conv["conversation_id"])
if not pinned:
print("[*] No pinned messages.")
continue
print(f"\n[*] {len(pinned)} pinned message(s):")
for p in pinned:
print(f" {p.get('message_id', '?')[:8]}... pinned at {p.get('pinned_at', '?')}")
elif choice == "19":
conv, _ = await _select_conversation(client, "Select source conversation")
if not conv:
continue
messages = await client.get_messages(conv["conversation_id"])
if not messages:
print("[*] No messages.")
continue
_print_messages(messages, client, conv)
msg_choice = await prompt("Forward message #: ")
try:
msg_idx = int(msg_choice) - 1
if not (0 <= msg_idx < len(messages)):
print("[!] Invalid message number.")
continue
except ValueError:
print("[!] Invalid number.")
continue
target_conv, _ = await _select_conversation(client, "Select target conversation")
if not target_conv:
continue
fwd_msg = messages[msg_idx]
fwd_msg["conversation_id"] = conv["conversation_id"]
try:
ok, result = await client.forward_message(
target_conv["conversation_id"], fwd_msg, target_conv["members"]
)
except IdentityKeyChanged as ikc:
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
continue
print(f"[{'+'if ok else '!'}] {'Forwarded.' if ok else result}")
elif choice == "20":
# Verify contact — show safety number for a DM conversation
conv, _ = await _select_conversation(client, "Select DM to verify")
if not conv:
continue
# Find peer user_id
peer_uid = ""
peer_name = ""
for m in conv.get("members", []):
if m.get("email") != client.email:
peer_uid = m.get("user_id") or m.get("id") or ""
peer_name = _sanitize_text(m.get("username") or m.get("email") or "?")
break
if not peer_uid:
print("[!] Could not identify peer user.")
continue
# Ensure we have their identity key in cache
info = await client._get_user_info(user_id=peer_uid)
if not info or not info.get("identity_key_bytes"):
print("[!] Could not retrieve identity key for this user.")
continue
status = client.get_verification_status(peer_uid)
print(f"\n--- Verification: {peer_name} ---")
print(f"Status: {status.upper()}")
safety = client.get_safety_number(peer_uid)
if safety:
print(f"\nSafety Number:\n{safety}")
fp = client.get_peer_fingerprint(peer_uid)
if fp:
print(f"\nTheir Fingerprint:\n{fp}")
my_fp = client.get_my_fingerprint()
if my_fp:
print(f"\nYour Fingerprint:\n{my_fp}")
if status != "verified":
action = await prompt("\nMark as verified? (y/n): ")
if action.lower() == "y":
client.verify_contact(peer_uid, info["identity_key_bytes"],
method="safety_number")
print("[+] Contact marked as verified.")
else:
action = await prompt("\nRemove verification? (y/n): ")
if action.lower() == "y":
client.unverify_contact(peer_uid)
print("[+] Verification removed.")
elif choice == "21":
# Show own fingerprint
fp = client.get_my_fingerprint()
if fp:
print(f"\n--- Your Fingerprint ---\n{fp}")
else:
print("[!] Not logged in or identity key not available.")
elif choice == "22":
# Change password
old_pw = getpass.getpass("Current password: ")
new_pw = getpass.getpass("New password: ")
confirm_pw = getpass.getpass("Confirm new password: ")
if new_pw != confirm_pw:
print("[!] Passwords do not match.")
elif not new_pw:
print("[!] Password cannot be empty.")
else:
ok, msg = client.change_password(old_pw, new_pw)
if ok:
print(f"[+] {msg}")
else:
print(f"[!] {msg}")
elif choice == "23":
new_un = await prompt("New username: ")
new_un = new_un.strip() if new_un else ""
if not new_un:
print("[!] Username cannot be empty.")
else:
ok, msg = await client.change_username(new_un)
if ok:
print(f"[+] {msg}")
else:
print(f"[!] {msg}")
elif choice in ("q", "Q", "quit", "exit"):
print("[*] Bye.")
break
def _print_messages(messages, client, conv):
"""Print messages to terminal."""
print()
for i, m in enumerate(messages):
if m.get("deleted"):
print(f" #{i+1} [Message deleted]")
continue
reply_info = ""
if m.get("reply_to"):
for j, orig in enumerate(messages):
if orig["message_id"] == m["reply_to"]:
reply_info = f" (reply to #{j+1})"
break
else:
reply_info = " (reply to older message)"
image_info = ""
if m.get("image"):
img = m["image"]
image_info = f" [Image: {_sanitize_text(img.get('filename', '?'))} ({_human_size(img.get('size', 0))})]"
file_info = ""
if m.get("file"):
fi = m["file"]
file_info = f" [File: {_sanitize_text(fi.get('filename', '?'))} ({_human_size(fi.get('size', 0))})]"
read_info = ""
if m.get("sender") == client.username:
read_by = m.get("read_by", [])
delivered_to = m.get("delivered_to", [])
member_map = {}
for mem in conv.get("members", []):
uid = mem.get("user_id") or mem.get("id", "")
if uid:
member_map[uid] = _sanitize_text(mem.get("username") or mem.get("email") or "?")
my_uid = client.session.get("user_id", "") if client.session else ""
others_read = [r for r in read_by if r.get("user_id") != my_uid]
others_delivered = [d for d in delivered_to if d.get("user_id") != my_uid]
if others_read:
names = ", ".join(member_map.get(r["user_id"], r["user_id"][:8]) for r in others_read)
read_info = f" [\u2713\u2713 Read by {names}]"
elif others_delivered:
read_info = " [\u2713\u2713 Delivered]"
else:
read_info = " [\u2713 Sent]"
pin_info = ""
if m.get("pinned_at"):
pin_info = " \U0001f4cc"
reaction_info = ""
reactions = m.get("reactions", [])
if reactions:
grouped = {}
for r in reactions:
grouped.setdefault(r["reaction"], 0)
grouped[r["reaction"]] += 1
_REMOJI = {"thumbsup": "\U0001f44d", "heart": "\u2764\ufe0f", "laugh": "\U0001f602",
"surprised": "\U0001f62e", "sad": "\U0001f622", "thumbsdown": "\U0001f44e"}
parts = [f"{_REMOJI.get(k, k)}{v}" for k, v in grouped.items()]
reaction_info = " [" + " ".join(parts) + "]"
fwd_info = ""
if m.get("forwarded_from"):
fwd_sender = _sanitize_text(m["forwarded_from"].get("sender", "?"))
fwd_info = f" (fwd from {fwd_sender})"
text = _sanitize_text(m.get("text", ""))
sender = _sanitize_text(m.get("sender", "?"))
print(f" #{i+1} {sender}: {text}{image_info}{file_info}{reply_info}{read_info}{pin_info}{reaction_info}{fwd_info}")
async def _delete_message_action(client, messages):
del_choice = await prompt("Delete message #: ")
try:
del_idx = int(del_choice) - 1
if not (0 <= del_idx < len(messages)):
print("[!] Invalid message number.")
return
except ValueError:
print("[!] Invalid number.")
return
ok, msg = await client.delete_message(messages[del_idx]["message_id"])
print(f"[{'+'if ok else '!'}] {msg}")
async def _download_file_action(client, messages):
dl_choice = await prompt("Download from message #: ")
try:
dl_idx = int(dl_choice) - 1
if not (0 <= dl_idx < len(messages)):
print("[!] Invalid message number.")
return
except ValueError:
print("[!] Invalid number.")
return
m = messages[dl_idx]
file_info = m.get("file") or m.get("image")
if not file_info:
print("[!] No file/image in this message.")
return
filename = _safe_filename(file_info.get("filename", "download"))
save_path = await prompt(f"Save as [{filename}]: ")
if not save_path:
save_path = filename
data = await client.download_file(file_info["file_id"], file_info)
if data:
with open(save_path, "wb") as f:
f.write(data)
print(f"[+] Saved to {save_path} ({_human_size(len(data))})")
else:
print("[!] Download failed.")
async def _invitations_menu(client):
invitations = await client.list_invitations()
if not invitations:
print("[*] No pending invitations.")
return
print("\nPending invitations:")
for i, inv in enumerate(invitations):
inv_name = _sanitize_text(inv.get("conversation_name") or inv.get("conversation_id", "")[:8])
invited_by = _sanitize_text(inv.get("invited_by_username") or inv.get("invited_by", "")[:8])
print(f" {i+1}) {inv_name} (invited by {invited_by})")
choice = await prompt("Select invitation (or empty to go back): ")
if not choice:
return
try:
idx = int(choice) - 1
if not (0 <= idx < len(invitations)):
print("[!] Invalid selection.")
return
except ValueError:
print("[!] Invalid selection.")
return
inv = invitations[idx]
action = await prompt("(a)ccept or (d)ecline? ")
if action.lower().startswith("a"):
ok, msg = await client.accept_invitation(inv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
elif action.lower().startswith("d"):
ok, msg = await client.decline_invitation(inv["conversation_id"])
print(f"[{'+'if ok else '!'}] {msg}")
def _print_profile(profile):
print(f"\n Username: {_sanitize_text(profile.get('username', '?'))}")
print(f" Email: {_sanitize_text(profile.get('email', '?'))}")
phone = profile.get("phone")
if phone:
print(f" Phone: {_sanitize_text(phone)}")
location = profile.get("location")
if location:
print(f" Location: {_sanitize_text(location)}")
has_avatar = profile.get("avatar_file")
print(f" Avatar: {'Yes' if has_avatar else 'No'}")
async def _my_profile_menu(client):
profile = await client.get_profile()
if not profile:
print("[!] Could not load profile.")
return
print("\n--- My Profile ---")
_print_profile(profile)
print(f" Phone visible: {profile.get('phone_visible', False)}")
print(f" Email visible: {profile.get('email_visible', False)}")
print(f" Location visible: {profile.get('location_visible', False)}")
action = await prompt("\n(e)dit, (a)vatar upload, or empty to go back: ")
if not action:
return
if action.lower().startswith("e"):
print("[*] Leave fields empty to keep current value.")
phone = await prompt(f"Phone [{profile.get('phone', '')}]: ")
location = await prompt(f"Location [{profile.get('location', '')}]: ")
phone_vis = await prompt(f"Phone visible [{profile.get('phone_visible', False)}] (y/n): ")
email_vis = await prompt(f"Email visible [{profile.get('email_visible', False)}] (y/n): ")
loc_vis = await prompt(f"Location visible [{profile.get('location_visible', False)}] (y/n): ")
fields = {}
if phone:
fields["phone"] = phone
if location:
fields["location"] = location
if phone_vis.lower() in ("y", "n"):
fields["phone_visible"] = phone_vis.lower() == "y"
if email_vis.lower() in ("y", "n"):
fields["email_visible"] = email_vis.lower() == "y"
if loc_vis.lower() in ("y", "n"):
fields["location_visible"] = loc_vis.lower() == "y"
if fields:
ok, msg = await client.update_profile(**fields)
print(f"[{'+'if ok else '!'}] {msg}")
else:
print("[*] No changes.")
elif action.lower().startswith("a"):
path = await prompt("Avatar image path: ")
if not path or not os.path.isfile(path):
print("[!] File not found.")
return
data = open(path, "rb").read()
ok, msg = await client.update_avatar(data)
print(f"[{'+'if ok else '!'}] {msg}")
async def _devices_menu(client):
resp = await client.send_and_recv("list_devices")
if resp.get("status") != "ok":
print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
return
devices = resp["data"].get("devices", [])
if not devices:
print("[*] No devices found.")
return
current_device_id = client.device_id
print("\nYour devices:")
for i, d in enumerate(devices):
name = _sanitize_text(d.get("device_name") or "Unnamed")
did = d.get("device_id", "?")
last_seen = _sanitize_text(d.get("last_seen_at", "?"))
current = " (this device)" if did == current_device_id else ""
print(f" {i+1}) {name}{did[:8]}... — last seen: {last_seen}{current}")
action = await prompt("\n(r)emove a device, or empty to go back: ")
if not action or not action.lower().startswith("r"):
return
choice = await prompt("Remove device #: ")
try:
idx = int(choice) - 1
if not (0 <= idx < len(devices)):
print("[!] Invalid selection.")
return
except ValueError:
print("[!] Invalid selection.")
return
d = devices[idx]
if d.get("device_id") == current_device_id:
print("[!] Cannot remove current device.")
return
resp = await client.send_and_recv("remove_device", device_id=d["device_id"])
if resp.get("status") == "ok":
print("[+] Device removed.")
else:
print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
async def notification_printer(client: ChatClient):
"""Print real-time notifications with sender name."""
while True:
notif = await client._notification_queue.get()
notif_type = notif.get("type", "")
data = notif.get("data", {})
if notif_type == "messages_read":
continue # Silent - read receipts shown when reading messages
if notif_type == "typing_start":
who = _sanitize_text(data.get("username") or data.get("user_id", "")[:8] or "Someone")
print(f"\n[*] {who} is typing...")
continue
if notif_type == "typing_stop":
continue
if notif_type == "session_reset":
from_uid = data.get("from_user_id", "")[:8]
client.handle_session_reset_notification(
data.get("from_user_id", ""),
data.get("from_device_id") or None,
)
print(f"\n[*] Session with {from_uid}... was reset. New session will be created on next message.")
continue
if notif_type == "group_invitation":
inv_name = _sanitize_text(data.get("conversation_name", "?"))
invited_by = _sanitize_text(data.get("invited_by_username", "?"))
print(f"\n[*] New invitation to '{inv_name}' from {invited_by}. Use option 8 to accept/decline.")
continue
if notif_type == "device_added":
device_name = _sanitize_text(data.get("device_name", "Unknown device"))
device_id = _sanitize_text((data.get("device_id", "") or "")[:8])
ip = _sanitize_text(data.get("ip", "unknown"))
print(
f"\n[!] New device added to your account: {device_name} ({device_id}) from {ip}.\n"
f" If this was not you, rotate keys immediately."
)
continue
if notif_type in ("conversation_created", "member_removed", "conversation_renamed"):
print(f"\n[*] Conversation updated ({notif_type}).")
continue
if notif_type == "member_added":
print(f"\n[*] Conversation updated (member_added).")
conv_id = data.get("conversation_id", "")
new_user_id = data.get("user_id", "")
if conv_id and new_user_id:
asyncio.ensure_future(
client.redistribute_sender_key_to_member(conv_id, new_user_id)
)
continue
if notif_type == "message_reacted":
username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
reaction = _sanitize_text(data.get("reaction", "?"))
action = data.get("action", "add")
print(f"\n[*] {username} {'added' if action == 'add' else 'removed'} reaction '{reaction}'")
continue
if notif_type in ("message_pinned", "message_unpinned"):
username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
act = "pinned" if notif_type == "message_pinned" else "unpinned"
print(f"\n[*] {username} {act} a message")
continue
if notif_type in ("user_online", "user_offline", "online_users"):
continue # Silent for CLI
payload = client.decrypt_notification(data)
if payload:
print(f"\n[*] New message from {_sanitize_text(payload['sender'])} in conversation {data.get('conversation_id', '?')[:8]}...")
# None = control message (sender key distribution), skip silently
async def main():
setup_logging()
client = ChatClient()
await client.connect()
client._listener_task = asyncio.create_task(client._background_listener())
notif_task = asyncio.create_task(notification_printer(client))
print("=== Encrypted Chat Client ===")
print("1) Register")
print("2) Login")
print("3) Link new device (this device)")
print("4) Authorize new device (from this device)")
print("5) Rotate keys (revoke other devices)")
choice = await prompt("> ")
if choice == "1":
username = await prompt("Username (display): ")
email = await prompt("Email: ")
password = await prompt_password("Password (for private key): ")
if not email or not password:
print("[!] Email and password required.")
await client.close()
return
ok, code_or_msg = await client.register(username, password, email=email)
if not ok:
print(f"[!] {code_or_msg}")
await client.close()
return
print(f"[*] Registration code: {code_or_msg}")
code = await prompt("Enter code: ")
ok2, msg2 = await client.confirm_registration(email, username, code)
print(f"[{'+'if ok2 else '!'}] {msg2}")
if ok2:
ok3, msg3 = await client.login(email, password)
print(f"[{'+'if ok3 else '!'}] {msg3}")
elif choice == "2":
email = await prompt("Email: ")
password = await prompt_password("Password (for private key): ")
ok, msg = await client.login(email, password)
print(f"[{'+'if ok else '!'}] {msg}")
elif choice == "3":
email = await prompt("Email: ")
password = await prompt_password("Password (for private key): ")
if not password:
print("[!] Password required.")
await client.close()
return
ok, code_or_msg = await client.pairing_start(email)
if not ok:
print(f"[!] {code_or_msg}")
await client.close()
return
code = code_or_msg
fingerprint = client.pairing_fingerprint()
print(f"[*] Pairing code: {code}")
print("[*] Pairing fingerprint:")
print(fingerprint)
print("[*] Approve this code on an already-logged-in device.")
print("[!] Never share this pairing code.")
ok2, msg2 = await client.pairing_wait(code, email, password)
if not ok2:
print(f"[!] {msg2}")
await client.close()
return
print(f"[+] {msg2}")
ok3, msg3 = await client.login(email, password)
print(f"[{'+'if ok3 else '!'}] {msg3}")
elif choice == "4":
email = await prompt("Email: ")
password = await prompt_password("Password (for private key): ")
ok, msg = await client.login(email, password)
print(f"[{'+'if ok else '!'}] {msg}")
if not ok:
await client.close()
return
code = await prompt("Pairing code: ")
fingerprint = await prompt("Fingerprint shown on the new device: ")
ok2, msg2 = await client.authorize_device(code, fingerprint)
print(f"[{'+'if ok2 else '!'}] {msg2}")
elif choice == "5":
email = await prompt("Email: ")
password = await prompt_password("Password (for private key): ")
ok, msg = await client.login(email, password)
print(f"[{'+'if ok else '!'}] {msg}")
if not ok:
await client.close()
return
confirm = await prompt("This will revoke other devices. Type 'YES' to continue: ")
if confirm != "YES":
print("[*] Cancelled.")
await client.close()
return
ok2, msg2 = await client.rotate_keys(client.username, password)
print(f"[{'+'if ok2 else '!'}] {msg2}")
else:
print("[!] Invalid choice.")
await client.close()
return
if client.session:
await interactive_menu(client)
notif_task.cancel()
await client.close()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n[*] Bye.")

988
crypto_utils.py Normal file
View File

@@ -0,0 +1,988 @@
"""Cryptographic utilities: Ed25519, X25519, AES-256-GCM, Double Ratchet, Sender Keys.
RSA functions retained for login challenge-response only.
"""
import hashlib
import hmac
import json
import os
import struct
import uuid
from dataclasses import dataclass, field
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# ---------------------------------------------------------------------------
# Password-based key encryption (M3: PBKDF2 600k iterations + AES-256-GCM)
# ---------------------------------------------------------------------------
PBKDF2_ITERATIONS = 600_000
_ECP1_MAGIC = b"ECP1" # Encrypted Chat PBKDF v1 format marker
def _encrypt_private_key(raw_bytes: bytes, password: bytes) -> bytes:
"""Encrypt raw key bytes with PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM.
Output format: MAGIC(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
"""
salt = os.urandom(16)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32,
salt=salt, iterations=PBKDF2_ITERATIONS)
derived = kdf.derive(password)
nonce = os.urandom(12)
aesgcm = AESGCM(derived)
ct = aesgcm.encrypt(nonce, raw_bytes, _ECP1_MAGIC) # AAD = magic bytes
return _ECP1_MAGIC + salt + nonce + ct
def _decrypt_private_key(data: bytes, password: bytes) -> bytes:
"""Decrypt key bytes encrypted with _encrypt_private_key."""
if not data.startswith(_ECP1_MAGIC):
raise ValueError("Not ECP1 format")
salt = data[4:20]
nonce = data[20:32]
ct = data[32:]
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32,
salt=salt, iterations=PBKDF2_ITERATIONS)
derived = kdf.derive(password)
aesgcm = AESGCM(derived)
return aesgcm.decrypt(nonce, ct, _ECP1_MAGIC)
# ---------------------------------------------------------------------------
# RSA (login challenge-response ONLY)
# ---------------------------------------------------------------------------
def generate_rsa_keypair(key_size: int = 4096) -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
return private_key, private_key.public_key()
def serialize_private_key(key: rsa.RSAPrivateKey, password: bytes | None = None) -> bytes:
if password:
raw = key.private_bytes(serialization.Encoding.DER, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
return _encrypt_private_key(raw, password)
return key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
def serialize_public_key(key: rsa.RSAPublicKey) -> bytes:
return key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
def load_private_key(data: bytes, password: bytes | None = None) -> rsa.RSAPrivateKey:
if data.startswith(_ECP1_MAGIC):
raw = _decrypt_private_key(data, password)
return serialization.load_der_private_key(raw, password=None)
# Legacy PEM format (old BestAvailableEncryption or unencrypted)
return serialization.load_pem_private_key(data, password=password)
def load_public_key(pem: bytes) -> rsa.RSAPublicKey:
return serialization.load_pem_public_key(pem)
def compute_pairing_fingerprint(public_key_data: bytes | str) -> str:
"""Format a temporary pairing public key as a human-verifiable fingerprint."""
if isinstance(public_key_data, str):
key_bytes = public_key_data.encode("utf-8")
else:
key_bytes = public_key_data
canonical = key_bytes.replace(b"\r\n", b"\n").strip() if b"-----BEGIN" in key_bytes else key_bytes
digest = hashlib.sha256(b"EncryptedChat_PairingKey_v1\x00" + canonical).digest()
return format_fingerprint(digest)
def normalize_pairing_fingerprint(value: str) -> str:
"""Normalize user-entered pairing fingerprints for comparison."""
return "".join(ch for ch in value if ch.isdigit())
def encode_pairing_qr(code: str, fingerprint: str) -> bytes:
"""Encode pairing code + fingerprint for QR transport.
Format: magic(5='PAIR1') + code(8 ASCII digits) + fingerprint(30 ASCII digits)
"""
code_digits = "".join(ch for ch in code if ch.isdigit())
fp_digits = normalize_pairing_fingerprint(fingerprint)
if len(code_digits) != 8:
raise ValueError("Pairing code must contain 8 digits")
if len(fp_digits) != 30:
raise ValueError("Pairing fingerprint must contain 30 digits")
return b"PAIR1" + code_digits.encode("ascii") + fp_digits.encode("ascii")
def decode_pairing_qr(data: bytes) -> tuple[str, str]:
"""Decode pairing QR payload. Returns (code, formatted_fingerprint)."""
if len(data) != 43 or not data.startswith(b"PAIR1"):
raise ValueError("Invalid pairing QR payload")
code = data[5:13].decode("ascii")
fp_digits = data[13:43].decode("ascii")
if not code.isdigit() or not fp_digits.isdigit():
raise ValueError("Invalid pairing QR payload")
groups = [fp_digits[i:i + 5] for i in range(0, 30, 5)]
return code, " ".join(groups[:3]) + "\n" + " ".join(groups[3:])
def rsa_sign(private_key: rsa.RSAPrivateKey, data: bytes) -> bytes:
return private_key.sign(
data,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256(),
)
def rsa_verify(public_key: rsa.RSAPublicKey, signature: bytes, data: bytes) -> bool:
try:
public_key.verify(
signature, data,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.AUTO),
hashes.SHA256(),
)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# AES-256-GCM (symmetric encryption — used by ratchet message keys & images)
# ---------------------------------------------------------------------------
def aes_encrypt(plaintext: bytes, key: bytes | None = None) -> tuple[bytes, bytes, bytes, bytes]:
"""Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag)."""
if key is None:
key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ct_with_tag = aesgcm.encrypt(nonce, plaintext, None)
ciphertext = ct_with_tag[:-16]
tag = ct_with_tag[-16:]
return key, nonce, ciphertext, tag
def aes_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, tag: bytes) -> bytes:
"""Decrypt with AES-256-GCM."""
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext + tag, None)
# ---------------------------------------------------------------------------
# Ed25519 Identity Keys
# ---------------------------------------------------------------------------
def generate_identity_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
priv = Ed25519PrivateKey.generate()
return priv, priv.public_key()
def serialize_ed25519_private(key: Ed25519PrivateKey, password: bytes | None = None) -> bytes:
if password:
raw = serialize_ed25519_private_raw(key) # 32 bytes
return _encrypt_private_key(raw, password)
return serialize_ed25519_private_raw(key) # 32 bytes, no password
def serialize_ed25519_private_raw(key: Ed25519PrivateKey) -> bytes:
"""Serialize Ed25519 private key to 32 raw bytes (unencrypted)."""
return key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
def serialize_ed25519_public(key: Ed25519PublicKey) -> bytes:
"""Serialize Ed25519 public key to 32 raw bytes."""
return key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
def load_ed25519_private(data: bytes, password: bytes | None = None) -> Ed25519PrivateKey:
if data.startswith(_ECP1_MAGIC):
raw = _decrypt_private_key(data, password)
return Ed25519PrivateKey.from_private_bytes(raw)
# Legacy formats: PEM (old BestAvailableEncryption) or 32-byte raw
if password:
return serialization.load_pem_private_key(data, password=password)
if len(data) == 32:
return Ed25519PrivateKey.from_private_bytes(data)
return serialization.load_pem_private_key(data, password=None)
def load_ed25519_public(data: bytes) -> Ed25519PublicKey:
if len(data) == 32:
return Ed25519PublicKey.from_public_bytes(data)
return serialization.load_pem_public_key(data)
def ed25519_sign(private_key: Ed25519PrivateKey, data: bytes) -> bytes:
"""Sign data with Ed25519. Returns 64-byte signature."""
return private_key.sign(data)
def ed25519_verify(public_key: Ed25519PublicKey, signature: bytes, data: bytes) -> bool:
"""Verify Ed25519 signature."""
try:
public_key.verify(signature, data)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# X25519 Key Exchange
# ---------------------------------------------------------------------------
def generate_x25519_keypair() -> tuple[X25519PrivateKey, X25519PublicKey]:
priv = X25519PrivateKey.generate()
return priv, priv.public_key()
def serialize_x25519_private(key: X25519PrivateKey) -> bytes:
"""Serialize X25519 private key to 32 raw bytes."""
return key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
def serialize_x25519_public(key: X25519PublicKey) -> bytes:
"""Serialize X25519 public key to 32 raw bytes."""
return key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
def load_x25519_private(data: bytes) -> X25519PrivateKey:
return X25519PrivateKey.from_private_bytes(data)
def load_x25519_public(data: bytes) -> X25519PublicKey:
return X25519PublicKey.from_public_bytes(data)
def x25519_dh(private_key: X25519PrivateKey, public_key: X25519PublicKey) -> bytes:
"""Perform X25519 Diffie-Hellman. Returns 32-byte shared secret."""
return private_key.exchange(public_key)
def derive_pairing_shared_key(shared_secret: bytes, public_key_a: bytes, public_key_b: bytes) -> bytes:
"""Derive a symmetric bootstrap key for device pairing.
The key derivation is direction-agnostic: both peers sort the two public
keys lexicographically before binding them into HKDF salt.
"""
pub1, pub2 = sorted((public_key_a, public_key_b))
salt = hashlib.sha256(b"EncryptedChat_PairingSalt_v1\x00" + pub1 + pub2).digest()
return hkdf_derive(shared_secret, salt=salt, info=b"EncryptedChat_PairingBootstrap", length=32)
# ---------------------------------------------------------------------------
# Ed25519 <-> X25519 conversion (for Identity Key dual use)
# ---------------------------------------------------------------------------
def ed25519_private_to_x25519(ed_private: Ed25519PrivateKey) -> X25519PrivateKey:
"""Derive X25519 private key from Ed25519 private key via RFC 7748 clamping."""
raw = ed_private.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)
# SHA-512 hash of the seed, take first 32 bytes, clamp per RFC 7748
h = hashlib.sha512(raw).digest()[:32]
clamped = bytearray(h)
clamped[0] &= 248
clamped[31] &= 127
clamped[31] |= 64
return X25519PrivateKey.from_private_bytes(bytes(clamped))
def ed25519_public_to_x25519(ed_public: Ed25519PublicKey) -> X25519PublicKey:
"""Derive X25519 public key from Ed25519 public key.
Uses the cryptography library's internal conversion. For production use,
we compute the X25519 public key from the converted private key when possible.
For remote keys (where we don't have the private key), we use a pure-Python
implementation of the Ed25519->X25519 point conversion.
"""
# Montgomery u = (1 + y) / (1 - y) mod p, where p = 2^255 - 19
raw = ed_public.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
y = int.from_bytes(raw, "little")
# Clear the sign bit
y &= (1 << 255) - 1
p = (1 << 255) - 19
# u = (1 + y) * inverse(1 - y) mod p
one_plus_y = (1 + y) % p
one_minus_y = (1 - y) % p
inv = pow(one_minus_y, p - 2, p)
u = (one_plus_y * inv) % p
x25519_bytes = u.to_bytes(32, "little")
return X25519PublicKey.from_public_bytes(x25519_bytes)
# ---------------------------------------------------------------------------
# HKDF
# ---------------------------------------------------------------------------
_HKDF_INFO_SELF = b"EncryptedChat_SelfKey"
_HKDF_INFO_RK = b"EncryptedChat_RootKey"
def derive_self_encryption_key(identity_private: Ed25519PrivateKey) -> bytes:
"""Derive a static AES-256 key from identity key for encrypting own sent messages.
This is NOT a ratchet — it's a static key. Safe because only the owner
has the identity private key, and self-copies don't need forward secrecy.
"""
raw = identity_private.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)
return hkdf_derive(raw, salt=b"self_encryption", info=_HKDF_INFO_SELF, length=32)
_HKDF_INFO_LOCAL = b"EncryptedChat_LocalStorage"
def derive_local_storage_key(identity_private: Ed25519PrivateKey) -> bytes:
"""Derive AES-256 key for encrypting local session/sender key files."""
raw = identity_private.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()
)
return hkdf_derive(raw, salt=b"local_storage", info=_HKDF_INFO_LOCAL, length=32)
_HKDF_INFO_CK_MSG = b"\x01" # chain key -> message key
_HKDF_INFO_CK_NEXT = b"\x02" # chain key -> next chain key
def hkdf_derive(input_key: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=length, salt=salt, info=info).derive(input_key)
def kdf_rk(root_key: bytes, dh_output: bytes) -> tuple[bytes, bytes]:
"""Root key KDF. Returns (new_root_key, chain_key).
Uses HKDF with the root key as salt and DH output as input key material.
Derives 64 bytes: first 32 = new root key, last 32 = chain key.
"""
derived = hkdf_derive(dh_output, salt=root_key, info=_HKDF_INFO_RK, length=64)
return derived[:32], derived[32:]
def kdf_ck(chain_key: bytes) -> tuple[bytes, bytes]:
"""Chain key KDF. Returns (new_chain_key, message_key).
Uses HMAC-SHA256:
message_key = HMAC(chain_key, 0x01)
new_chain_key = HMAC(chain_key, 0x02)
"""
message_key = hmac.new(chain_key, _HKDF_INFO_CK_MSG, hashlib.sha256).digest()
new_chain_key = hmac.new(chain_key, _HKDF_INFO_CK_NEXT, hashlib.sha256).digest()
return new_chain_key, message_key
# ---------------------------------------------------------------------------
# X3DH
# ---------------------------------------------------------------------------
_X3DH_INFO = b"EncryptedChat_X3DH"
def generate_signed_prekey(identity_private: Ed25519PrivateKey) -> dict:
"""Generate a signed pre-key (SPK).
Returns {private: X25519PrivateKey, public: X25519PublicKey, signature: bytes, id: str}.
"""
spk_priv, spk_pub = generate_x25519_keypair()
spk_pub_bytes = serialize_x25519_public(spk_pub)
signature = ed25519_sign(identity_private, spk_pub_bytes)
return {
"private": spk_priv,
"public": spk_pub,
"signature": signature,
"id": str(uuid.uuid4()),
}
def generate_one_time_prekeys(count: int = 50) -> list[dict]:
"""Generate a batch of one-time pre-keys.
Returns [{private: X25519PrivateKey, public: X25519PublicKey, id: str}, ...].
"""
result = []
for _ in range(count):
priv, pub = generate_x25519_keypair()
result.append({"private": priv, "public": pub, "id": str(uuid.uuid4())})
return result
def x3dh_initiate(
ik_private_ed: Ed25519PrivateKey,
ik_public_remote_ed: Ed25519PublicKey,
spk_remote: X25519PublicKey,
spk_signature: bytes,
opk_remote: X25519PublicKey | None = None,
) -> tuple[bytes, X25519PrivateKey, X25519PublicKey]:
"""Initiator side of X3DH.
Args:
ik_private_ed: Our Ed25519 identity private key
ik_public_remote_ed: Remote Ed25519 identity public key
spk_remote: Remote signed pre-key (X25519 public)
spk_signature: Ed25519 signature of spk_remote by ik_public_remote_ed
opk_remote: Optional one-time pre-key (X25519 public)
Returns:
(shared_secret, ephemeral_private, ephemeral_public)
"""
# Verify SPK signature
spk_remote_bytes = serialize_x25519_public(spk_remote)
if not ed25519_verify(ik_public_remote_ed, spk_signature, spk_remote_bytes):
raise ValueError("Invalid SPK signature")
# Convert identity keys to X25519
ik_x25519_private = ed25519_private_to_x25519(ik_private_ed)
ik_x25519_remote = ed25519_public_to_x25519(ik_public_remote_ed)
# Generate ephemeral keypair
ek_priv, ek_pub = generate_x25519_keypair()
# DH computations
dh1 = x25519_dh(ik_x25519_private, spk_remote) # IK_A, SPK_B
dh2 = x25519_dh(ek_priv, ik_x25519_remote) # EK_A, IK_B
dh3 = x25519_dh(ek_priv, spk_remote) # EK_A, SPK_B
dh_concat = dh1 + dh2 + dh3
if opk_remote is not None:
dh4 = x25519_dh(ek_priv, opk_remote) # EK_A, OPK_B
dh_concat += dh4
# Derive shared secret
shared_secret = hkdf_derive(dh_concat, salt=b"\x00" * 32, info=_X3DH_INFO, length=32)
return shared_secret, ek_priv, ek_pub
def x3dh_respond(
ik_private_ed: Ed25519PrivateKey,
spk_private: X25519PrivateKey,
ik_remote_ed: Ed25519PublicKey,
ek_remote: X25519PublicKey,
opk_private: X25519PrivateKey | None = None,
) -> bytes:
"""Responder side of X3DH.
Args:
ik_private_ed: Our Ed25519 identity private key
spk_private: Our signed pre-key private (X25519)
ik_remote_ed: Remote Ed25519 identity public key
ek_remote: Remote ephemeral key (X25519 public)
opk_private: Our one-time pre-key private (X25519), if used
Returns:
shared_secret (32 bytes)
"""
ik_x25519_private = ed25519_private_to_x25519(ik_private_ed)
ik_x25519_remote = ed25519_public_to_x25519(ik_remote_ed)
dh1 = x25519_dh(spk_private, ik_x25519_remote) # SPK_B, IK_A
dh2 = x25519_dh(ik_x25519_private, ek_remote) # IK_B, EK_A
dh3 = x25519_dh(spk_private, ek_remote) # SPK_B, EK_A
dh_concat = dh1 + dh2 + dh3
if opk_private is not None:
dh4 = x25519_dh(opk_private, ek_remote) # OPK_B, EK_A
dh_concat += dh4
shared_secret = hkdf_derive(dh_concat, salt=b"\x00" * 32, info=_X3DH_INFO, length=32)
return shared_secret
# ---------------------------------------------------------------------------
# Double Ratchet
# ---------------------------------------------------------------------------
MAX_SKIP = 256 # max messages to skip in a single chain (out-of-order tolerance)
@dataclass
class RatchetHeader:
"""Header sent with each ratchet message."""
dh_pub: bytes # sender's current ratchet public key (32 bytes)
n: int # message number in current sending chain
pn: int # number of messages in previous sending chain
def serialize(self) -> bytes:
return json.dumps({
"dh_pub": serialize_x25519_public(load_x25519_public(self.dh_pub)).hex()
if isinstance(self.dh_pub, bytes) else serialize_x25519_public(self.dh_pub).hex(),
"n": self.n,
"pn": self.pn,
}).encode()
def to_dict(self) -> dict:
pub_hex = self.dh_pub.hex() if isinstance(self.dh_pub, bytes) else \
serialize_x25519_public(self.dh_pub).hex()
return {"dh_pub": pub_hex, "n": self.n, "pn": self.pn}
@classmethod
def from_dict(cls, d: dict) -> "RatchetHeader":
return cls(dh_pub=bytes.fromhex(d["dh_pub"]), n=d["n"], pn=d["pn"])
class DoubleRatchet:
"""Signal Double Ratchet implementation."""
def __init__(self):
self.dh_pair: tuple[X25519PrivateKey, X25519PublicKey] | None = None
self.dh_remote: X25519PublicKey | None = None
self.root_key: bytes = b""
self.send_chain_key: bytes | None = None
self.recv_chain_key: bytes | None = None
self.send_n: int = 0
self.recv_n: int = 0
self.prev_send_n: int = 0
# (dh_pub_hex, n) -> message_key for out-of-order messages
self.skipped: dict[tuple[str, int], bytes] = {}
@classmethod
def init_alice(cls, shared_secret: bytes, bob_spk_pub: X25519PublicKey) -> "DoubleRatchet":
"""Initialize as initiator (Alice) after X3DH.
Alice performs the first DH ratchet step immediately.
"""
ratchet = cls()
ratchet.dh_pair = generate_x25519_keypair()
ratchet.dh_remote = bob_spk_pub
# Perform DH ratchet to derive send chain
dh_output = x25519_dh(ratchet.dh_pair[0], ratchet.dh_remote)
ratchet.root_key, ratchet.send_chain_key = kdf_rk(shared_secret, dh_output)
ratchet.recv_chain_key = None
ratchet.send_n = 0
ratchet.recv_n = 0
ratchet.prev_send_n = 0
return ratchet
@classmethod
def init_bob(cls, shared_secret: bytes, spk_pair: tuple[X25519PrivateKey, X25519PublicKey]) -> "DoubleRatchet":
"""Initialize as responder (Bob) after X3DH.
Bob uses his SPK as the initial ratchet key pair.
"""
ratchet = cls()
ratchet.dh_pair = spk_pair
ratchet.root_key = shared_secret
ratchet.send_chain_key = None
ratchet.recv_chain_key = None
ratchet.send_n = 0
ratchet.recv_n = 0
ratchet.prev_send_n = 0
return ratchet
def encrypt(self, plaintext: bytes) -> dict:
"""Encrypt a message.
Returns {header: {dh_pub, n, pn}, ciphertext: bytes, nonce: bytes}.
"""
if self.send_chain_key is None:
raise RuntimeError("Send chain not initialized")
self.send_chain_key, message_key = kdf_ck(self.send_chain_key)
header = RatchetHeader(
dh_pub=serialize_x25519_public(self.dh_pair[1]),
n=self.send_n,
pn=self.prev_send_n,
)
# Encrypt with AES-256-GCM using the message key
nonce = os.urandom(12)
aesgcm = AESGCM(message_key)
# Include header as AAD to bind ciphertext to header
aad = header.serialize()
ct_with_tag = aesgcm.encrypt(nonce, plaintext, aad)
self.send_n += 1
return {
"header": header.to_dict(),
"ciphertext": ct_with_tag, # includes 16-byte tag
"nonce": nonce,
}
def decrypt(self, header_dict: dict, ciphertext: bytes, nonce: bytes) -> bytes:
"""Decrypt a message. Handles DH ratchet step if new dh_pub.
State is snapshotted before modification and restored on failure (M9 fix).
"""
header = RatchetHeader.from_dict(header_dict)
remote_dh_pub_bytes = header.dh_pub
# Check if this is from a skipped message (no state modification needed)
skip_key = (remote_dh_pub_bytes.hex(), header.n)
if skip_key in self.skipped:
mk = self.skipped.pop(skip_key)
aad = header.serialize()
aesgcm = AESGCM(mk)
try:
return aesgcm.decrypt(nonce, ciphertext, aad)
except Exception:
self.skipped[skip_key] = mk # restore skipped key
raise
# Snapshot state before modifications
snap = self._snapshot()
try:
remote_dh_pub = load_x25519_public(remote_dh_pub_bytes)
current_remote_bytes = serialize_x25519_public(self.dh_remote) if self.dh_remote else None
if current_remote_bytes is None or remote_dh_pub_bytes != current_remote_bytes:
# New DH ratchet step
self._skip_messages(header.pn)
self._dh_ratchet(remote_dh_pub)
self._skip_messages(header.n)
# Derive message key from receive chain
self.recv_chain_key, mk = kdf_ck(self.recv_chain_key)
self.recv_n += 1
aad = header.serialize()
aesgcm = AESGCM(mk)
return aesgcm.decrypt(nonce, ciphertext, aad)
except Exception:
self._restore(snap)
raise
def _snapshot(self) -> dict:
"""Capture mutable state for rollback on decrypt failure."""
return {
"dh_pair": self.dh_pair,
"dh_remote": self.dh_remote,
"root_key": self.root_key,
"send_chain_key": self.send_chain_key,
"recv_chain_key": self.recv_chain_key,
"send_n": self.send_n,
"recv_n": self.recv_n,
"prev_send_n": self.prev_send_n,
"skipped": dict(self.skipped),
}
def _restore(self, snap: dict):
"""Restore state from snapshot."""
self.dh_pair = snap["dh_pair"]
self.dh_remote = snap["dh_remote"]
self.root_key = snap["root_key"]
self.send_chain_key = snap["send_chain_key"]
self.recv_chain_key = snap["recv_chain_key"]
self.send_n = snap["send_n"]
self.recv_n = snap["recv_n"]
self.prev_send_n = snap["prev_send_n"]
self.skipped = snap["skipped"]
def _skip_messages(self, until: int):
"""Skip ahead in the receive chain, storing message keys for out-of-order delivery."""
if self.recv_chain_key is None:
return
if until - self.recv_n > MAX_SKIP:
raise RuntimeError(f"Too many skipped messages ({until - self.recv_n} > {MAX_SKIP})")
while self.recv_n < until:
self.recv_chain_key, mk = kdf_ck(self.recv_chain_key)
remote_hex = serialize_x25519_public(self.dh_remote).hex() if self.dh_remote else ""
self.skipped[(remote_hex, self.recv_n)] = mk
self.recv_n += 1
def _dh_ratchet(self, remote_dh_pub: X25519PublicKey):
"""Perform a DH ratchet step: update receive chain, generate new DH pair, update send chain."""
self.prev_send_n = self.send_n
self.send_n = 0
self.recv_n = 0
self.dh_remote = remote_dh_pub
# Derive new receive chain key
dh_output = x25519_dh(self.dh_pair[0], self.dh_remote)
self.root_key, self.recv_chain_key = kdf_rk(self.root_key, dh_output)
# Generate new DH pair and derive new send chain key
self.dh_pair = generate_x25519_keypair()
dh_output = x25519_dh(self.dh_pair[0], self.dh_remote)
self.root_key, self.send_chain_key = kdf_rk(self.root_key, dh_output)
def export_state(self) -> bytes:
"""Serialize full ratchet state for persistent storage."""
state = {
"dh_priv": serialize_x25519_private(self.dh_pair[0]).hex() if self.dh_pair else None,
"dh_pub": serialize_x25519_public(self.dh_pair[1]).hex() if self.dh_pair else None,
"dh_remote": serialize_x25519_public(self.dh_remote).hex() if self.dh_remote else None,
"root_key": self.root_key.hex(),
"send_ck": self.send_chain_key.hex() if self.send_chain_key else None,
"recv_ck": self.recv_chain_key.hex() if self.recv_chain_key else None,
"send_n": self.send_n,
"recv_n": self.recv_n,
"prev_send_n": self.prev_send_n,
"skipped": {f"{k[0]}:{k[1]}": v.hex() for k, v in self.skipped.items()},
}
return json.dumps(state).encode()
@classmethod
def import_state(cls, data: bytes) -> "DoubleRatchet":
"""Deserialize ratchet state."""
state = json.loads(data)
r = cls()
if state["dh_priv"] and state["dh_pub"]:
priv = load_x25519_private(bytes.fromhex(state["dh_priv"]))
pub = load_x25519_public(bytes.fromhex(state["dh_pub"]))
r.dh_pair = (priv, pub)
if state["dh_remote"]:
r.dh_remote = load_x25519_public(bytes.fromhex(state["dh_remote"]))
r.root_key = bytes.fromhex(state["root_key"])
r.send_chain_key = bytes.fromhex(state["send_ck"]) if state["send_ck"] else None
r.recv_chain_key = bytes.fromhex(state["recv_ck"]) if state["recv_ck"] else None
r.send_n = state["send_n"]
r.recv_n = state["recv_n"]
r.prev_send_n = state["prev_send_n"]
r.skipped = {}
for k_str, v_hex in state.get("skipped", {}).items():
parts = k_str.rsplit(":", 1)
dh_hex = parts[0]
n = int(parts[1])
r.skipped[(dh_hex, n)] = bytes.fromhex(v_hex)
return r
# ---------------------------------------------------------------------------
# Sender Keys (group messaging)
# ---------------------------------------------------------------------------
class SenderKeyState:
"""Sender key chain for group messaging.
Each sender in a group has their own sender key chain.
Other group members receive the initial sender_key via pairwise Double Ratchet.
"""
def __init__(self, sender_key: bytes | None = None):
if sender_key is None:
sender_key = os.urandom(32)
self.sender_key = sender_key
self.chain_id = hashlib.sha256(sender_key).digest()
self.chain_key = hkdf_derive(sender_key, salt=b"\x00" * 32, info=b"SenderKeyChain", length=32)
self.n = 0
# For receivers: track chain state to allow fast-forward
self._known_keys: dict[int, bytes] = {}
def encrypt(self, plaintext: bytes) -> dict:
"""Encrypt with current chain key.
Returns {chain_id: hex, n: int, ciphertext: bytes, nonce: bytes}.
"""
self.chain_key, message_key = kdf_ck(self.chain_key)
nonce = os.urandom(12)
aesgcm = AESGCM(message_key)
# AAD includes chain_id and message number
aad = self.chain_id + struct.pack(">I", self.n)
ct_with_tag = aesgcm.encrypt(nonce, plaintext, aad)
result = {
"chain_id": self.chain_id.hex(),
"n": self.n,
"ciphertext": ct_with_tag,
"nonce": nonce,
}
self.n += 1
return result
MAX_SENDER_KEY_SKIP = 256
def decrypt(self, chain_id_hex: str, n: int, ciphertext: bytes, nonce: bytes) -> bytes:
"""Decrypt a group message. Fast-forwards the chain if needed.
State is snapshotted before modification and restored on failure (M9 fix).
"""
chain_id = bytes.fromhex(chain_id_hex)
if chain_id != self.chain_id:
raise ValueError("Chain ID mismatch")
if n - self.n > self.MAX_SENDER_KEY_SKIP:
raise ValueError(f"Sender key skip too large ({n - self.n} > {self.MAX_SENDER_KEY_SKIP})")
# Snapshot before fast-forward
snap_chain_key = self.chain_key
snap_n = self.n
snap_known = dict(self._known_keys)
try:
# Fast-forward the chain to reach message n
while self.n <= n:
self.chain_key, mk = kdf_ck(self.chain_key)
self._known_keys[self.n] = mk
self.n += 1
mk = self._known_keys.pop(n, None)
if mk is None:
raise ValueError(f"Message key for n={n} not available (already consumed)")
aad = chain_id + struct.pack(">I", n)
aesgcm = AESGCM(mk)
return aesgcm.decrypt(nonce, ciphertext, aad)
except Exception:
self.chain_key = snap_chain_key
self.n = snap_n
self._known_keys = snap_known
raise
def export_key(self) -> bytes:
"""Export sender key for distribution to group members.
Contains everything needed to initialize a receiving SenderKeyState.
"""
return json.dumps({
"sender_key": self.sender_key.hex(),
}).encode()
def export_state(self) -> bytes:
"""Serialize full state for persistent storage."""
return json.dumps({
"sender_key": self.sender_key.hex(),
"chain_id": self.chain_id.hex(),
"chain_key": self.chain_key.hex(),
"n": self.n,
"known_keys": {str(k): v.hex() for k, v in self._known_keys.items()},
}).encode()
@classmethod
def import_state(cls, data: bytes) -> "SenderKeyState":
state = json.loads(data)
obj = cls.__new__(cls)
obj.sender_key = bytes.fromhex(state["sender_key"])
obj.chain_id = bytes.fromhex(state["chain_id"])
obj.chain_key = bytes.fromhex(state["chain_key"])
obj.n = state["n"]
obj._known_keys = {int(k): bytes.fromhex(v) for k, v in state.get("known_keys", {}).items()}
return obj
@classmethod
def from_key(cls, exported_key: bytes) -> "SenderKeyState":
"""Initialize a receiving SenderKeyState from an exported key."""
data = json.loads(exported_key)
return cls(sender_key=bytes.fromhex(data["sender_key"]))
# ---------------------------------------------------------------------------
# Contact Key Verification (Safety Numbers / Fingerprints / QR Codes)
# ---------------------------------------------------------------------------
FINGERPRINT_VERSION = 0
def compute_fingerprint(user_id: str, identity_key_bytes: bytes, iterations: int = 5200) -> bytes:
"""Compute a 32-byte fingerprint for a user's identity key.
Uses iterated SHA-512 (Signal's NumericFingerprint algorithm).
Seed: version(2B) + identity_key(32B) + user_id(UTF-8).
Each iteration: SHA-512(previous_hash + identity_key).
Output: first 32 bytes of final hash.
"""
version_bytes = FINGERPRINT_VERSION.to_bytes(2, "big")
data = version_bytes + identity_key_bytes + user_id.encode("utf-8")
for _ in range(iterations):
data = hashlib.sha512(data + identity_key_bytes).digest()
return data[:32]
def format_fingerprint(fp_bytes: bytes) -> str:
"""Format 32-byte fingerprint as 6 groups of 5 zero-padded digits (30 digits).
Each group: int(bytes[i*5:(i+1)*5], big-endian) % 100000.
Output: two lines of 3 groups each, space-separated.
"""
groups = []
for i in range(6):
num = int.from_bytes(fp_bytes[i * 5:(i + 1) * 5], "big") % 100000
groups.append(f"{num:05d}")
return " ".join(groups[:3]) + "\n" + " ".join(groups[3:])
def compute_safety_number(my_uid: str, my_ik_bytes: bytes,
their_uid: str, their_ik_bytes: bytes) -> str:
"""Compute a 60-digit safety number for a pair of users.
Both users see the same number regardless of who computes it.
Lower user_id's fingerprint comes first (deterministic ordering).
Output: 12 groups of 5 digits, formatted as 3 lines of 4 groups.
"""
fp_mine = compute_fingerprint(my_uid, my_ik_bytes)
fp_theirs = compute_fingerprint(their_uid, their_ik_bytes)
if my_uid < their_uid:
combined = fp_mine + fp_theirs
else:
combined = fp_theirs + fp_mine
# 64 bytes -> 12 groups of 5 digits
groups = []
for i in range(12):
num = int.from_bytes(combined[i * 5:(i + 1) * 5], "big") % 100000
groups.append(f"{num:05d}")
lines = [
" ".join(groups[0:4]),
" ".join(groups[4:8]),
" ".join(groups[8:12]),
]
return "\n".join(lines)
def encode_verification_qr(user_id: str, identity_key_bytes: bytes) -> bytes:
"""Encode user identity for QR code verification.
Format: version(1B=0x01) + uid_len(1B) + uid(UTF-8) + identity_key(32B).
"""
uid_bytes = user_id.encode("utf-8")
return b"\x01" + len(uid_bytes).to_bytes(1, "big") + uid_bytes + identity_key_bytes
def decode_verification_qr(data: bytes) -> tuple[str, bytes]:
"""Decode QR code verification payload.
Returns (user_id, identity_key_bytes).
Raises ValueError on invalid format.
"""
if len(data) < 3:
raise ValueError("QR data too short")
if data[0] != 0x01:
raise ValueError(f"Unknown QR version: {data[0]}")
uid_len = data[1]
if len(data) < 2 + uid_len + 32:
raise ValueError("QR data truncated")
user_id = data[2:2 + uid_len].decode("utf-8")
identity_key = data[2 + uid_len:2 + uid_len + 32]
return user_id, identity_key
# ---------------------------------------------------------------------------
# Message Padding (metadata privacy — hide plaintext length)
# ---------------------------------------------------------------------------
_PAD_MAGIC = b"\x01"
_PAD_BUCKETS = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]
def pad_plaintext(plaintext: bytes) -> bytes:
"""Pad plaintext to nearest bucket size to hide message length.
Format: 0x01 + plaintext + random_padding + pad_length(4B big-endian)
Prefix 0x01 distinguishes padded messages from legacy unpadded (which start with '{').
"""
content = _PAD_MAGIC + plaintext
# +4 for the length suffix
min_size = len(content) + 4
target = next((b for b in _PAD_BUCKETS if b >= min_size), min_size)
pad_len = target - len(content)
return content + os.urandom(pad_len - 4) + struct.pack(">I", pad_len)
def unpad_plaintext(data: bytes) -> bytes:
"""Remove padding. Returns raw plaintext for both padded and legacy unpadded messages."""
if not data or data[0:1] != _PAD_MAGIC:
return data # legacy unpadded message (starts with '{' for JSON)
if len(data) < 5:
return data # too short to be validly padded
pad_len = struct.unpack(">I", data[-4:])[0]
if pad_len < 4 or pad_len > len(data) - 1:
return data # invalid padding metadata, treat as legacy
return data[1:len(data) - pad_len]

1726
db.py Normal file

File diff suppressed because it is too large Load Diff

70
docker-compose.yml Normal file
View File

@@ -0,0 +1,70 @@
version: "3.9"
# Local development stack: encrypted-chat server + MySQL
# Usage:
# docker compose up — start server + db
# docker compose up --build — rebuild server image first
# docker compose down -v — stop and remove volumes (wipes DB data)
services:
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: devpassword
MYSQL_DATABASE: encrypted_chat
MYSQL_USER: chat
MYSQL_PASSWORD: chatpassword
volumes:
# Persist DB data between restarts
- db_data:/var/lib/mysql
# Auto-import schema on first start
- ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pdevpassword"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
server:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "5000:5000"
volumes:
- uploads:/app/uploads
environment:
# MySQL connection
MYSQL_HOST: db
MYSQL_PORT: 3306
MYSQL_USER: chat
MYSQL_PASSWORD: chatpassword
MYSQL_DATABASE: encrypted_chat
DB_POOL_SIZE: 10
# Server config
SERVER_HOST: 0.0.0.0
SERVER_PORT: 5000
UPLOAD_DIR: /app/uploads
# Dev mode: registration codes returned in response (no SMTP needed)
ENVIRONMENT: dev
# TLS: disabled by default for local dev (set TLS_ENABLED=true for prod)
TLS_ENABLED: "false"
# Logging
LOG_LEVEL: INFO
# Metadata retention (days)
METADATA_RETENTION_DAYS: 90
volumes:
db_data:
uploads:

152
gemini.md Normal file
View File

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

6987
gui_client.py Normal file

File diff suppressed because it is too large Load Diff

146
protocol.py Normal file
View File

@@ -0,0 +1,146 @@
"""Newline-delimited JSON protocol with base64 encoding for binary data."""
import asyncio
import base64
import binascii
import json
import os
def encode_binary(data: bytes) -> str:
"""Encode bytes to base64 string."""
return base64.b64encode(data).decode("ascii")
def decode_binary(data: str) -> bytes:
"""Decode base64 string to bytes."""
try:
return base64.b64decode(data, validate=True)
except (TypeError, binascii.Error) as e:
raise ValueError(f"Invalid base64: {e}")
VERSION = "0.8.6"
MIN_CLIENT_VERSION = "0.8.6" # server rejects clients older than this
def version_gte(version: str, minimum: str) -> bool:
"""Return True if version >= minimum (compares numeric tuples, e.g. '0.8.1' >= '0.8').
Returns False for malformed version strings (instead of silently treating them as 0).
"""
def _parse(v: str) -> tuple[int, ...] | None:
if not isinstance(v, str) or not v:
return None
parts = v.split(".")
try:
return tuple(int(x) for x in parts)
except (ValueError, AttributeError):
return None
parsed_ver = _parse(version)
parsed_min = _parse(minimum)
if parsed_ver is None or parsed_min is None:
return False
return parsed_ver >= parsed_min
MAX_MESSAGE_BYTES = int(os.getenv("MAX_MESSAGE_BYTES", str(1024 * 1024))) # 1 MiB default (was 64K, raised for 256K media chunks)
MAX_IMAGE_BYTES = int(os.getenv("MAX_IMAGE_BYTES", str(5 * 1024 * 1024))) # 5 MiB default, 0 = no limit
MAX_FILE_BYTES = int(os.getenv("MAX_FILE_BYTES", str(50 * 1024 * 1024))) # 50 MiB default
IMAGE_CHUNK_SIZE = 262144 # 256 KiB raw chunk size for image upload/download
def build_request(msg_type: str, request_id: str | None = None, **kwargs) -> bytes:
"""Build a protocol message (newline-terminated JSON)."""
msg = {"type": msg_type, **kwargs}
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def build_response(
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
) -> bytes:
"""Build a server response."""
msg = {"type": msg_type, "status": status}
if data is not None:
msg["data"] = data
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def parse_message(line: bytes) -> dict:
"""Parse a single protocol message from bytes."""
try:
return json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid message: {e}")
class ProtocolReader:
"""Read newline-delimited JSON messages from an asyncio StreamReader."""
def __init__(self, reader: asyncio.StreamReader):
self._reader = reader
async def read_message(self) -> dict | None:
"""Read and parse one message. Returns None on EOF."""
try:
line = await self._reader.readuntil(b"\n")
except (asyncio.IncompleteReadError, ConnectionError):
return None
except asyncio.LimitOverrunError as e:
# Message exceeded StreamReader limit — drain oversized data
# using public read() API (consumed=e.consumed bytes before limit).
# Read in chunks until newline found or EOF, then signal error.
remaining = e.consumed
while True:
chunk = await self._reader.read(max(remaining, 4096))
if not chunk:
return None # EOF while draining
if b"\n" in chunk:
break # found delimiter, oversized message fully drained
raise ValueError("Message exceeds maximum size")
if not line:
return None
return parse_message(line.strip())
class ProtocolWriter:
"""Write newline-delimited JSON messages to an asyncio StreamWriter."""
def __init__(self, writer: asyncio.StreamWriter):
self._writer = writer
async def send_request(self, msg_type: str, request_id: str | None = None, **kwargs):
"""Send a request message."""
payload = build_request(msg_type, request_id=request_id, **kwargs)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
async def send_response(
self,
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
):
"""Send a response message."""
payload = build_response(msg_type, status, data, request_id=request_id)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
def is_closing(self) -> bool:
"""Check if the underlying transport is closing or closed."""
return self._writer.is_closing()
def close(self):
self._writer.close()

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
cryptography>=42.0.0
mysql-connector-python>=8.3.0
python-dotenv>=1.0.0
# GUI client (optional, needed for gui_client.py)
PyQt6>=6.6.0
# Image sharing (optional, needed for send_image feature)
Pillow>=10.0.0
# QR code generation for contact verification (optional)
qrcode[pil]>=7.4
# QR code scanning (needed for gui_client.py QR scan feature)
pyzbar>=0.1.9

252
scaling.md Normal file
View File

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

189
schema.sql Normal file
View File

@@ -0,0 +1,189 @@
CREATE DATABASE IF NOT EXISTS encrypted_chat
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE encrypted_chat;
-- Users: identity_key is Ed25519 (32B), rsa_public_key for login challenge only
CREATE TABLE IF NOT EXISTS users (
id CHAR(36) NOT NULL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
rsa_public_key TEXT NOT NULL,
identity_key BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- Devices: each user can have multiple devices
CREATE TABLE IF NOT EXISTS devices (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
device_name VARCHAR(255) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME DEFAULT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_devices_user (user_id)
) ENGINE=InnoDB;
-- Signed Pre-Keys (X25519, signed by Ed25519 identity key) — per device
CREATE TABLE IF NOT EXISTS signed_prekeys (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
device_id CHAR(36) DEFAULT NULL,
public_key BLOB NOT NULL,
signature BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_spk_user_device (user_id, device_id)
) ENGINE=InnoDB;
-- One-Time Pre-Keys (consumed on use) — per device
CREATE TABLE IF NOT EXISTS one_time_prekeys (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
device_id CHAR(36) DEFAULT NULL,
public_key BLOB NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_opk_user_device (user_id, device_id)
) ENGINE=InnoDB;
-- Conversations
CREATE TABLE IF NOT EXISTS conversations (
id CHAR(36) NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
name VARCHAR(255) DEFAULT NULL,
created_by CHAR(36) DEFAULT NULL,
avatar_file VARCHAR(255) DEFAULT NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
joined_at DATETIME NULL,
PRIMARY KEY (conversation_id, user_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_cm_user (user_id)
) ENGINE=InnoDB;
-- Group invitations (pending invitations to join a group)
CREATE TABLE IF NOT EXISTS group_invitations (
id CHAR(36) NOT NULL PRIMARY KEY,
conversation_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
invited_by CHAR(36) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_conv_user (conversation_id, user_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_inv_user (user_id)
) ENGINE=InnoDB;
-- Messages: per-recipient ciphertext (Double Ratchet = each recipient has different ciphertext)
CREATE TABLE IF NOT EXISTS messages (
id CHAR(36) NOT NULL PRIMARY KEY,
conversation_id CHAR(36) NOT NULL,
sender_id CHAR(36) NOT NULL,
sender_device_id CHAR(36) DEFAULT NULL,
ratchet_header BLOB NOT NULL,
x3dh_header BLOB DEFAULT NULL,
sender_chain_id BLOB DEFAULT NULL,
sender_chain_n INT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
image_file_id CHAR(36) DEFAULT NULL,
pinned_at DATETIME DEFAULT NULL,
pinned_by CHAR(36) DEFAULT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_messages_conv_created (conversation_id, created_at),
INDEX idx_messages_deleted (conversation_id, deleted_at),
INDEX idx_messages_pinned (conversation_id, pinned_at)
) ENGINE=InnoDB;
-- Per-recipient encrypted content — per device
-- device_id '00000000-0000-0000-0000-000000000000' = self-encrypted / legacy
CREATE TABLE IF NOT EXISTS message_recipients (
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
device_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
encrypted_content BLOB NOT NULL,
nonce BLOB NOT NULL,
ratchet_header BLOB DEFAULT NULL,
x3dh_header BLOB DEFAULT NULL,
PRIMARY KEY (message_id, user_id, device_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Sender Keys for groups (distributed via pairwise ratchet) — per device
CREATE TABLE IF NOT EXISTS group_sender_keys (
conversation_id CHAR(36) NOT NULL,
sender_id CHAR(36) NOT NULL,
device_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
chain_id BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (conversation_id, sender_id, device_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Read receipts
CREATE TABLE IF NOT EXISTS message_reads (
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (message_id, user_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_reads_user (user_id),
INDEX idx_reads_read_at (read_at)
) ENGINE=InnoDB;
-- Delivery receipts
CREATE TABLE IF NOT EXISTS message_deliveries (
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
delivered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (message_id, user_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- User profiles
CREATE TABLE IF NOT EXISTS user_profiles (
user_id CHAR(36) NOT NULL PRIMARY KEY,
phone VARCHAR(50) DEFAULT NULL,
phone_visible TINYINT(1) NOT NULL DEFAULT 0,
email_visible TINYINT(1) NOT NULL DEFAULT 1,
location VARCHAR(255) DEFAULT NULL,
location_visible TINYINT(1) NOT NULL DEFAULT 0,
avatar_file VARCHAR(255) DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- Message reactions (emoji reactions on messages)
CREATE TABLE IF NOT EXISTS message_reactions (
id CHAR(36) NOT NULL PRIMARY KEY,
message_id CHAR(36) NOT NULL,
user_id CHAR(36) NOT NULL,
reaction VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_reaction (message_id, user_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_reactions_created_at (created_at)
) ENGINE=InnoDB;
-- Image uploads
CREATE TABLE IF NOT EXISTS image_uploads (
file_id CHAR(36) NOT NULL PRIMARY KEY,
conversation_id CHAR(36) NOT NULL,
uploader_id CHAR(36) NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
FOREIGN KEY (uploader_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

3204
server.py Normal file

File diff suppressed because it is too large Load Diff

539
theme.py Normal file
View File

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