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:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
The main Python modules live in the repository root. `server.py` contains the asyncio TCP server, request handlers, rate limiting, and upload flows. `chat_core.py` holds shared client logic, crypto workflows, and local key handling. `client.py` is the CLI, `gui_client.py` is the PyQt6 GUI, `db.py` is the MySQL layer, `protocol.py` defines the newline-delimited JSON protocol, and `crypto_utils.py` contains X3DH, Double Ratchet, Sender Keys, and local encryption helpers. Use `schema.sql` for a clean database bootstrap. Security and architecture notes are tracked in `SECURITY_AUDIT.md`, `README.md`, `scaling.md`, and `CLAUDE.md`. Put new test tooling under `tests/`. Treat `zaloha/` as archive code, not an active source directory.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use the project virtualenv and MySQL schema:
|
||||
|
||||
```bash
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
mysql -u <user> -p < schema.sql
|
||||
.venv/bin/python server.py
|
||||
.venv/bin/python client.py
|
||||
.venv/bin/python gui_client.py
|
||||
```
|
||||
|
||||
For quick validation, run:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m py_compile server.py chat_core.py client.py gui_client.py db.py
|
||||
.venv/bin/python tests/pentest_client.py --server-host <tls-host> --member-email ... --peer-email ... --outsider-email ...
|
||||
```
|
||||
|
||||
There is no full `pytest` suite yet; current regression coverage is mainly protocol-level through `tests/pentest_client.py`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Follow existing Python conventions: 4-space indentation, `snake_case` for functions and variables, `PascalCase` for classes, and type hints on new or changed code. Keep handlers non-blocking: DB, file, or SMTP work that can block should be moved behind async helpers or `asyncio.to_thread()`. Reuse central validation helpers instead of duplicating checks, and keep logs free of secrets, emails, or raw user-controlled text where possible.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Add tests in `tests/` with descriptive names. Prefer `test_<feature>.py` for focused checks and `<scenario>_client.py` for protocol or penetration probes. Every security fix should include a regression path that covers malformed input, authorization, replay, rate limiting, or multi-device behavior.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Git history is not available in this workspace snapshot, so use short imperative commit messages. Conventional Commit style is preferred, for example `fix: reject invalid ratchet headers`. PRs should summarize behavior changes, mention schema or `.env` updates, link related issues, and include CLI or GUI evidence for user-visible changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit `.env`, TLS private keys, uploaded files, or local key material from `~/.encrypted_chat/`. When testing TLS, remember that `0.0.0.0` is a server bind address, not a valid client hostname. Use a host or IP that matches the certificate SAN or CN.
|
||||
44
Dockerfile
Normal file
44
Dockerfile
Normal 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"]
|
||||
289
KEC-18_operational_cost_analysis.md
Normal file
289
KEC-18_operational_cost_analysis.md
Normal 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
326
README.md
Normal 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 (64B–64KB) maskuje délku zpráv
|
||||
|
||||
### Metadata privacy
|
||||
- Log sanitizace (žádná PII), metadata retention (90 dní), sender chain minimalizace
|
||||
|
||||
### Anti-enumeration
|
||||
- Phantom users pro neregistrované emaily
|
||||
- Generické odpovědi na register/login/get_user_info
|
||||
|
||||
## Multi-Device Support
|
||||
|
||||
Pravý multi-device (Signal-like) — každé zařízení má nezávislé Double Ratchet sessions.
|
||||
|
||||
- **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 64B–64KB)
|
||||
- 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
491
SECURITY_AUDIT.md
Normal 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
138
TODO.md
Normal 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 64B–64KB)
|
||||
- [x] Metadata privacy (log sanitizace, retention, sender chain minimalizace)
|
||||
- [x] Secure deletion (overwrite + fsync + unlink)
|
||||
- [x] Reakce na zprávy (6 emoji typů)
|
||||
- [x] Přeposílání zpráv (text, obrázky, soubory)
|
||||
- [x] Připnuté zprávy (pin/unpin + dialog)
|
||||
- [x] @Mentions s autocomplete
|
||||
- [x] Hledání zpráv (client-side, Ctrl+F)
|
||||
- [x] Šifrované obrázky + soubory (chunked upload, až 50 MB)
|
||||
- [x] Skupinové pozvánky (accept/decline)
|
||||
- [x] Leave group + přenos creatora
|
||||
- [x] Rename group (creator only)
|
||||
- [x] Delete conversation
|
||||
- [x] Group avatar
|
||||
- [x] User profily (telefon, lokace, avatar, viditelnost)
|
||||
- [x] Online/offline status
|
||||
- [x] Unread count badges (server-side pro offline uživatele)
|
||||
- [x] Privacy overlay / lock screen
|
||||
- [x] Dark/light téma (Catppuccin + Signal) s live switching
|
||||
- [x] Session recovery (reset + auto X3DH)
|
||||
- [x] Connection indicator + auto-reconnect
|
||||
- [x] 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
101
certs/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# TLS Setup — Let's Encrypt + Cloudflare DNS
|
||||
|
||||
TLS certifikát přes Let's Encrypt bez nutnosti otevírat port 80.
|
||||
Ověření domény probíhá přes DNS TXT záznam (Cloudflare API).
|
||||
|
||||
## Předpoklady
|
||||
|
||||
- Doména s DNS na Cloudflare (free tier stačí)
|
||||
- Cloudflare API token s oprávněním "Edit zone DNS"
|
||||
- Root přístup na serveru (certbot potřebuje `/etc/letsencrypt/`)
|
||||
|
||||
## Postup
|
||||
|
||||
### 1. Cloudflare API token
|
||||
|
||||
1. Jdi na https://dash.cloudflare.com/profile/api-tokens
|
||||
2. **Create Token** → Use template **"Edit zone DNS"**
|
||||
3. Zone Resources → vybrat svou doménu
|
||||
4. Zkopíruj vygenerovaný token
|
||||
|
||||
### 2. Credentials soubor
|
||||
|
||||
```bash
|
||||
cp cloudflare.ini.example cloudflare.ini
|
||||
nano cloudflare.ini # vlož API token
|
||||
chmod 600 cloudflare.ini
|
||||
```
|
||||
|
||||
### 3. Získání certifikátu
|
||||
|
||||
```bash
|
||||
sudo ./setup-tls.sh chat.example.com
|
||||
```
|
||||
|
||||
Skript nainstaluje certbot + Cloudflare plugin, získá certifikát a vytvoří symlinky v tomto adresáři.
|
||||
|
||||
### 4. Konfigurace serveru
|
||||
|
||||
Přidej do `.env` v kořenovém adresáři projektu:
|
||||
|
||||
```env
|
||||
TLS_ENABLED=true
|
||||
TLS_CERT_FILE=/etc/letsencrypt/live/chat.example.com/fullchain.pem
|
||||
TLS_KEY_FILE=/etc/letsencrypt/live/chat.example.com/privkey.pem
|
||||
```
|
||||
|
||||
### 5. Konfigurace klienta
|
||||
|
||||
Na klientovi stačí:
|
||||
|
||||
```env
|
||||
TLS_ENABLED=true
|
||||
```
|
||||
|
||||
Let's Encrypt je v systémovém trust store — klient ověří certifikát automaticky.
|
||||
|
||||
## Obnova certifikátu
|
||||
|
||||
Certbot obnovuje certifikát automaticky přes systemd timer (každých ~60 dní, cert platí 90).
|
||||
|
||||
```bash
|
||||
# Ověřit že timer běží
|
||||
systemctl status certbot.timer
|
||||
|
||||
# Ruční obnova (test)
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
Po úspěšné obnově se spustí `reload-server.sh` (deploy hook) — restartuje chat server aby načetl nový certifikát.
|
||||
|
||||
## Soubory
|
||||
|
||||
| Soubor | Účel |
|
||||
|--------|------|
|
||||
| `setup-tls.sh` | Instalace certbot + získání certifikátu |
|
||||
| `reload-server.sh` | Deploy hook — restartuje server po renew |
|
||||
| `cloudflare.ini.example` | Šablona pro Cloudflare API token |
|
||||
| `cloudflare.ini` | Tvůj API token (gitignored) |
|
||||
|
||||
## FAQ
|
||||
|
||||
**Funguje certifikát na nestandardním portu (např. 9999)?**
|
||||
Ano. Certifikát je vázaný na doménu, ne na port. `chat.example.com:9999` funguje.
|
||||
|
||||
**Musím otevírat port 80?**
|
||||
Ne. DNS challenge ověřuje doménu přes DNS TXT záznam, žádný HTTP požadavek na server.
|
||||
|
||||
**Co když nemám Cloudflare?**
|
||||
Můžeš použít ruční DNS challenge (bez automatického renew):
|
||||
```bash
|
||||
sudo certbot certonly --manual --preferred-challenges dns -d chat.example.com
|
||||
```
|
||||
Certbot ti řekne jaký TXT záznam přidat. Při renew to musíš opakovat ručně.
|
||||
|
||||
**Dev/testování bez certifikátu?**
|
||||
```env
|
||||
ENVIRONMENT=dev
|
||||
TLS_ENABLED=true
|
||||
TLS_AUTOGEN=true # server vygeneruje self-signed cert
|
||||
TLS_INSECURE=true # klient přeskočí ověření
|
||||
```
|
||||
28
certs/reload-server.sh
Executable file
28
certs/reload-server.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy hook — spustí se automaticky po úspěšném renew certifikátu
|
||||
# Certbot volá tento skript s RENEWED_LINEAGE a RENEWED_DOMAINS env vars
|
||||
#
|
||||
# Restartuje chat server aby načetl nový certifikát.
|
||||
# Přizpůsob podle toho jak server spouštíš (systemd / screen / přímý proces).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "Certifikát obnoven pro: ${RENEWED_DOMAINS:-unknown}"
|
||||
|
||||
# Varianta 1: Systemd service
|
||||
if systemctl is-active --quiet encrypted-chat 2>/dev/null; then
|
||||
systemctl restart encrypted-chat
|
||||
echo "Server restartován (systemd)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Varianta 2: Poslat SIGINT procesu (graceful shutdown + ruční restart)
|
||||
PID=$(pgrep -f "python.*server.py" || true)
|
||||
if [ -n "$PID" ]; then
|
||||
echo "Posílám SIGINT procesu $PID (server.py)"
|
||||
kill -INT "$PID"
|
||||
echo "Server zastaven. Spusť ho znovu ručně nebo přes systemd."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "VAROVÁNÍ: Server proces nenalezen. Restartuj server ručně."
|
||||
108
certs/setup-tls.sh
Executable file
108
certs/setup-tls.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup TLS certifikátu přes Let's Encrypt + Cloudflare DNS challenge
|
||||
# Nevyžaduje otevřený port 80 — ověření přes DNS TXT záznam
|
||||
#
|
||||
# Použití:
|
||||
# 1. Přesuň DNS domény na Cloudflare (free tier stačí)
|
||||
# 2. Vytvoř API token: https://dash.cloudflare.com/profile/api-tokens
|
||||
# -> Use template "Edit zone DNS" -> vybrat doménu
|
||||
# 3. cp cloudflare.ini.example cloudflare.ini
|
||||
# Vlož token, chmod 600 cloudflare.ini
|
||||
# 4. sudo ./setup-tls.sh chat.example.com
|
||||
#
|
||||
# Po úspěšném získání certifikátu přidej do .env:
|
||||
# TLS_ENABLED=true
|
||||
# TLS_CERT_FILE=/etc/letsencrypt/live/DOMENA/fullchain.pem
|
||||
# TLS_KEY_FILE=/etc/letsencrypt/live/DOMENA/privkey.pem
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="${1:-}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CREDS="$SCRIPT_DIR/cloudflare.ini"
|
||||
DEPLOY_HOOK="$SCRIPT_DIR/reload-server.sh"
|
||||
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
echo "Použití: sudo $0 <domena>"
|
||||
echo "Příklad: sudo $0 chat.example.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Spusť jako root: sudo $0 $DOMAIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$CREDS" ]; then
|
||||
echo "Chybí $CREDS"
|
||||
echo "Zkopíruj cloudflare.ini.example -> cloudflare.ini a vlož API token."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ověř oprávnění credentials souboru
|
||||
PERMS=$(stat -c %a "$CREDS" 2>/dev/null || stat -f %Lp "$CREDS" 2>/dev/null)
|
||||
if [ "$PERMS" != "600" ]; then
|
||||
echo "VAROVÁNÍ: $CREDS má oprávnění $PERMS, nastavuji 600"
|
||||
chmod 600 "$CREDS"
|
||||
fi
|
||||
|
||||
echo "=== Instalace certbot + Cloudflare pluginu ==="
|
||||
if ! command -v certbot &>/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y certbot python3-certbot-dns-cloudflare
|
||||
echo "Certbot nainstalován."
|
||||
else
|
||||
echo "Certbot již nainstalován."
|
||||
# Doinstaluj plugin pokud chybí
|
||||
if ! python3 -c "import certbot_dns_cloudflare" 2>/dev/null; then
|
||||
apt-get install -y python3-certbot-dns-cloudflare
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Získání certifikátu pro $DOMAIN ==="
|
||||
DEPLOY_ARGS=""
|
||||
if [ -f "$DEPLOY_HOOK" ] && [ -x "$DEPLOY_HOOK" ]; then
|
||||
DEPLOY_ARGS="--deploy-hook $DEPLOY_HOOK"
|
||||
echo "Deploy hook: $DEPLOY_HOOK"
|
||||
fi
|
||||
|
||||
certbot certonly \
|
||||
--dns-cloudflare \
|
||||
--dns-cloudflare-credentials "$CREDS" \
|
||||
--dns-cloudflare-propagation-seconds 30 \
|
||||
-d "$DOMAIN" \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--register-unsafely-without-email \
|
||||
$DEPLOY_ARGS
|
||||
|
||||
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
|
||||
if [ -d "$CERT_DIR" ]; then
|
||||
echo ""
|
||||
echo "=== Certifikát úspěšně získán ==="
|
||||
echo ""
|
||||
echo "Soubory:"
|
||||
echo " Certifikát: $CERT_DIR/fullchain.pem"
|
||||
echo " Klíč: $CERT_DIR/privkey.pem"
|
||||
echo ""
|
||||
echo "Přidej do .env:"
|
||||
echo " TLS_ENABLED=true"
|
||||
echo " TLS_CERT_FILE=$CERT_DIR/fullchain.pem"
|
||||
echo " TLS_KEY_FILE=$CERT_DIR/privkey.pem"
|
||||
echo ""
|
||||
echo "Na klientovi stačí:"
|
||||
echo " TLS_ENABLED=true"
|
||||
echo ""
|
||||
echo "Automatický renew: certbot timer (systemd) nebo cron"
|
||||
echo " systemctl status certbot.timer"
|
||||
echo ""
|
||||
|
||||
# Symlinky pro snadný přístup
|
||||
ln -sf "$CERT_DIR/fullchain.pem" "$SCRIPT_DIR/fullchain.pem"
|
||||
ln -sf "$CERT_DIR/privkey.pem" "$SCRIPT_DIR/privkey.pem"
|
||||
echo "Symlinky vytvořeny v $SCRIPT_DIR/"
|
||||
else
|
||||
echo "CHYBA: Certifikát nebyl vytvořen."
|
||||
exit 1
|
||||
fi
|
||||
3907
chat_core.py
Normal file
3907
chat_core.py
Normal file
File diff suppressed because it is too large
Load Diff
928
client.py
Normal file
928
client.py
Normal 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
988
crypto_utils.py
Normal 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]
|
||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal 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
152
gemini.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Gemini Advanced Roadmap: Beyond the Basics
|
||||
|
||||
Tento dokument obsahuje pokročilé návrhy na vylepšení bezpečnosti, architektury a UX aplikace `encrypted_chat`. Tyto body jdou nad rámec běžného "best practice" a směřují k funkcionalitě profesionálních secure messengerů (Signal, Threema, Wire) se zaměřením na ochranu metadat a anti-forenzní techniky.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ochrana Metadat & Traffic Analysis Resistance
|
||||
*Cíl: Server by neměl vědět, KDO s KÝM komunikuje, ani JAKÝ typ dat si posílají.*
|
||||
|
||||
### Sealed Sender (Odesílatel v obálce)
|
||||
- **Koncept:** Server zná pouze `recipient_id`. Identita odesílatele (`sender_id`) je zašifrována uvnitř zprávy (v "obálce"), kterou server nedokáže přečíst.
|
||||
- **Implementace:**
|
||||
1. Odesílatel vygeneruje klíč pro obálku (např. z profilu příjemce).
|
||||
2. Zabalí `sender_id` a payload do šifrovaného bloku.
|
||||
3. Server doručí blob příjemci bez ověření odesílatele (ověření proběhne až na klientovi po rozbalení).
|
||||
4. **Výhoda:** Při kompromitaci serveru útočník nevidí sociální graf (kdo se s kým baví).
|
||||
|
||||
### Traffic Padding & Constant Bitrate
|
||||
- **Problém:** Délka paketu prozrazuje obsah (krátký paket = "Ahoj", dlouhý paket = obrázek/klíč). Intervaly prozrazují aktivitu.
|
||||
- **Řešení:**
|
||||
1. **Padding:** Všechny zprávy doplňovat náhodnými daty na fixní velikosti (např. bloky 4KB).
|
||||
2. **Dummy Traffic (Chaff):** Klient náhodně odesílá "falešné" pakety na server, které server zahodí nebo vrátí (echo).
|
||||
3. **Výhoda:** Pro síťového analytika (ISP) vypadá tok dat jako konstantní šum.
|
||||
|
||||
---
|
||||
|
||||
## 2. Anti-Forenzní Ochrana (Client-side)
|
||||
*Cíl: Minimalizovat dopad fyzického zabavení zařízení nebo vynuceného odemčení.*
|
||||
|
||||
### Duress Password (Heslo pod nátlakem)
|
||||
- **Funkce:** Uživatel si nastaví *druhé* heslo.
|
||||
- **Chování:** Pokud se přihlásí tímto heslem:
|
||||
- **Varianta A (Decoy):** Odemkne se prázdná nebo falešná databáze s neškodnými konverzacemi.
|
||||
- **Varianta B (Panic):** Aplikace na pozadí tiše provede **secure wipe** (přepis) privátních klíčů a reálné DB, zatímco uživateli zobrazí "Connection Error".
|
||||
|
||||
### Secure Deletion & DB Vacuuming
|
||||
- **Problém:** SQL `DELETE` data nesmaže fyzicky, jen označí místo jako volné.
|
||||
- **Řešení:**
|
||||
1. Před smazáním zprávy přepsat obsah náhodnými byty (`UPDATE messages SET content = random_blob WHERE id = ...`).
|
||||
2. Pravidelně spouštět `VACUUM` (u SQLite) nebo optimalizaci tabulek.
|
||||
3. Pro soubory (obrázky) použít bezpečné mazání (overwrite passes) před `os.unlink()`.
|
||||
|
||||
### Disappearing Messages (TTL)
|
||||
- **Funkce:** Odesílatel nastaví životnost zprávy (např. 1 minuta).
|
||||
- **Implementace:** Odpočet začíná okamžikem zobrazení (Read Receipt). Po uplynutí času klient data nenávratně smaže z disku (včetně secure wipe). Server maže ihned po doručení.
|
||||
|
||||
---
|
||||
|
||||
## 3. Infrastruktura & Škálování
|
||||
*Cíl: Odlehčit Python procesu a databázi pro podporu tisíců uživatelů.*
|
||||
|
||||
### Object Storage (MinIO / S3) + Presigned URLs
|
||||
- **Problém:** `server.py` blokuje I/O při příjmu velkých souborů.
|
||||
- **Řešení:**
|
||||
1. Klient požádá server o upload.
|
||||
2. Server vygeneruje **Presigned PUT URL** (časově omezený token pro přímý upload do MinIO/S3).
|
||||
3. Klient nahrává data přímo do úložiště (obchází aplikační server).
|
||||
4. Server ukládá pouze odkaz (URL/Key).
|
||||
- **Výhoda:** Masivní zrychlení, server řeší jen metadata.
|
||||
|
||||
### Read/Write Splitting (MySQL Replication)
|
||||
- **Architektura:**
|
||||
- **Master DB:** Pouze pro `INSERT`, `UPDATE`, `DELETE`.
|
||||
- **Read Replicas (Slaves):** Pro těžké `SELECT` dotazy (historie zpráv, hledání).
|
||||
- **Implementace v `db.py`:** Router, který podle typu dotazu volí connection pool.
|
||||
|
||||
---
|
||||
|
||||
## 4. Protokol & Funkce
|
||||
*Cíl: Rozšíření možností komunikace bez nutnosti centralizovaného streamování.*
|
||||
|
||||
### P2P Volání (WebRTC Signalizace)
|
||||
- **Koncept:** Využít existující bezpečný kanál (Double Ratchet) pro výměnu SDP (Session Description Protocol) paketů.
|
||||
- **Flow:**
|
||||
1. Alice pošle Bobovi zašifrovanou zprávu typu `CALL_OFFER` s parametry WebRTC.
|
||||
2. Bob odpoví `CALL_ANSWER`.
|
||||
3. Klienti si vymění `ICE_CANDIDATES` (IP adresy/porty) a naváží přímé P2P spojení (UDP).
|
||||
4. Audio/Video stream (SRTP) jde mimo server.
|
||||
|
||||
### Diferenciální Synchronizace (Merkle Trees)
|
||||
- **Problém:** Stahování seznamu kontaktů (`get_user_contacts`) je pomalé při velkém množství dat.
|
||||
- **Řešení:** Klient a server si udržují Hash Tree (Merkle Tree) stavu. Při synchronizaci porovnají pouze root hash. Pokud se liší, stahují se jen změněné větve stromu (delta update).
|
||||
|
||||
---
|
||||
|
||||
## 5. UI/UX (PyQt Speciality)
|
||||
*Cíl: Ochrana soukromí na úrovni OS a skrytá komunikace.*
|
||||
|
||||
### Privacy Overlay (Task Switcher)
|
||||
- **Funkce:** Detekovat událost ztráty fokusu okna (`QEvent.WindowDeactivate`) nebo minimalizace.
|
||||
- **Akce:** Překrýt obsah okna rozmazaným efektem (`QGraphicsBlurEffect`) nebo logem aplikace.
|
||||
- **Důvod:** Zabrání operačnímu systému (Windows/Linux/macOS) vytvořit čitelný náhled okna v Alt+Tab menu nebo v historii aktivit.
|
||||
|
||||
### Steganografie
|
||||
- **Funkce:** Ukrýt šifrovanou zprávu do obrazových dat nevinného obrázku (např. meme kočky).
|
||||
- **Implementace:** Modifikace LSB (Least Significant Bit) pixelů obrázku.
|
||||
- **Výhoda:** Pro síťového admina nebo forenzní analýzu to vypadá jako běžné posílání obrázků, přítomnost šifrované komunikace je popiratelná.
|
||||
|
||||
---
|
||||
|
||||
## 6. High Availability Architecture (Distribuovaný Cluster)
|
||||
*Cíl: Zajištění provozu i při výpadku/napadení serveru (Active-Active "RAID 1 přes síť").*
|
||||
|
||||
### Architektura: Geograficky Distribuovaný "Zero-Trust" Cluster
|
||||
|
||||
#### 1. Vstupní brána (Global Traffic Manager)
|
||||
- **Funkce:** Rozděluje klienty mezi dostupné servery (Round Robin / Geo-DNS).
|
||||
- **Self-Healing:** Při výpadku Serveru A okamžitě přesměruje provoz na Server B. Uživatel nic nepozná.
|
||||
|
||||
#### 2. Aplikační vrstva (Stateless Servers)
|
||||
- **Stav:** Servery jsou **bezstavové**. `server.py` neukládá nic důležitého v RAM.
|
||||
- **Škálování:** Můžete spustit N instancí serveru. Je jedno, ke kterému se uživatel připojí.
|
||||
- **Komunikace:** Servery spolu mluví přes rychlý Message Bus (Redis Pub/Sub) pro doručování real-time zpráv mezi uživateli na různých uzlech.
|
||||
|
||||
#### 3. Datová vrstva (Zrcadlení Dat - "RAID 1")
|
||||
- **Databáze (MySQL Galera Cluster):** Synchronní multi-master replikace. Zápis na Serveru A se potvrdí, až když je fyzicky zapsán i na Serveru B (a C).
|
||||
- *Efekt:* Ztráta serveru neznamená ztrátu dat (klíčů, zpráv).
|
||||
- **Soubory (MinIO Cluster):** Distribuovaný Object Storage s Erasure Coding. Soubory jsou matematicky rozprostřeny přes všechny servery. Výpadek disku/serveru nevadí.
|
||||
|
||||
#### 4. Bezpečnostní pojistky ("Poisoned Node")
|
||||
- **Soft Delete:** Databáze nemaže data ihned, ale označuje je jako smazané (ochrana proti `DELETE *` od útočníka).
|
||||
- **Client-Side Verification:** I kdyby kompromitovaný server posílal podvržené klíče, klienti ověřují digitální podpisy (Identity Keys). Server nemůže zfalšovat identitu uživatelů.
|
||||
|
||||
---
|
||||
|
||||
## 7. Technický Upgrade pro Stateless Architekturu
|
||||
*Cíl: Odstranit závislost na paměti procesu (RAM) pro umožnění horizontálního škálování.*
|
||||
|
||||
### 1. Redis jako Distribuovaná Paměť
|
||||
Nahrazení Python `dict` struktur, které jsou lokální pro jeden proces, za centrální Redis úložiště přístupné všem serverům.
|
||||
|
||||
* **Párovací Session:**
|
||||
* *Stav:* `pairing_sessions` (dict) -> Redis Key `pair:{code}` (Hash/String s TTL).
|
||||
* *Efekt:* Uživatel může vyžádat kód na Serveru A a potvrdit ho na Serveru B.
|
||||
* **Rate Limiting:**
|
||||
* *Stav:* `rate_limits` (dict) -> Redis Key `rl:{ip}:{action}` (Counter s EXPIRE).
|
||||
* *Efekt:* Limity platí globálně pro celý cluster, ne jen per server.
|
||||
|
||||
### 2. Redis Pub/Sub pro Real-Time Routing
|
||||
Doručení zprávy uživateli, který je připojen k JINÉMU serveru než odesílatel.
|
||||
|
||||
* **Princip:**
|
||||
1. Server A (odesílatel) zjistí, že příjemce Bob není připojen lokálně.
|
||||
2. Server A publikuje zprávu do Redis kanálu `user:{bob_user_id}`.
|
||||
3. Server B (kde je Bob připojen) tento kanál poslouchá (subscribe).
|
||||
4. Server B přijme zprávu z Redisu a pošle ji Bobovi do otevřeného TCP socketu.
|
||||
|
||||
### 3. Session Sticky vs. Stateless Uploads
|
||||
Řešení pro nahrávání souborů po částech (chunks).
|
||||
|
||||
* **Varianta A (Infrastructure - Sticky Sessions):** Load Balancer (HAProxy/Nginx) zajistí, že všechny požadavky od jedné IP jdou vždy na stejný server. Nejjednodušší, nevyžaduje změnu kódu.
|
||||
* **Varianta B (Architectural - Direct Upload):** Viz bod 3 "Object Storage + Presigned URLs". Server vůbec nepřijímá data souboru, pouze vygeneruje token. Plně stateless řešení.
|
||||
6987
gui_client.py
Normal file
6987
gui_client.py
Normal file
File diff suppressed because it is too large
Load Diff
146
protocol.py
Normal file
146
protocol.py
Normal 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
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
cryptography>=42.0.0
|
||||
mysql-connector-python>=8.3.0
|
||||
python-dotenv>=1.0.0
|
||||
# GUI client (optional, needed for gui_client.py)
|
||||
PyQt6>=6.6.0
|
||||
# Image sharing (optional, needed for send_image feature)
|
||||
Pillow>=10.0.0
|
||||
# QR code generation for contact verification (optional)
|
||||
qrcode[pil]>=7.4
|
||||
# QR code scanning (needed for gui_client.py QR scan feature)
|
||||
pyzbar>=0.1.9
|
||||
252
scaling.md
Normal file
252
scaling.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Škálování serveru — plán kapacitního růstu
|
||||
|
||||
## Cílový hardware
|
||||
|
||||
- **CPU:** Intel Xeon E5-2630v4 (10 cores / 20 threads, 2.2 GHz)
|
||||
- **RAM:** 256 GB REG ECC
|
||||
- **Disk:** 500 GB SSD (boot/OS/DB) + 4 TB HDD (soubory)
|
||||
- **Síť:** 1 Gbit
|
||||
|
||||
Odhadovaná kapacita po optimalizaci: **10 000–20 000 uživatelů**, **2000–5000 zpráv/s**
|
||||
|
||||
---
|
||||
|
||||
## Krok 1: Okamžité změny (hotovo v kódu)
|
||||
|
||||
### 1a. Thread pool — `server.py`
|
||||
|
||||
```env
|
||||
THREAD_POOL_SIZE=40
|
||||
```
|
||||
|
||||
Nastavuje `ThreadPoolExecutor(max_workers=40)` jako default executor pro `asyncio.to_thread()`.
|
||||
S 20 HW thready a DB latencí ~2–5ms je 40 workerů optimální (2x HW threads — workery čekají na I/O).
|
||||
|
||||
### 1b. DB pool — `.env`
|
||||
|
||||
```env
|
||||
DB_POOL_SIZE=30
|
||||
```
|
||||
|
||||
30 simultánních MySQL spojení. S 40 thread workers a ~2ms query je 30 pool konexí dostatek.
|
||||
|
||||
### 1c. Chybějící DB indexy — `schema.sql`
|
||||
|
||||
Přidány 5 nových indexů pro nejčastější dotazy:
|
||||
|
||||
| Index | Tabulka | Dotaz který zrychlí |
|
||||
|-------|---------|---------------------|
|
||||
| `idx_cm_user (user_id)` | `conversation_members` | `list_user_conversations` — **kritický**, bez něj full table scan |
|
||||
| `idx_inv_user (user_id)` | `group_invitations` | `get_pending_invitations` |
|
||||
| `idx_messages_deleted (conversation_id, deleted_at)` | `messages` | `get_deleted_messages_since` |
|
||||
| `idx_messages_pinned (conversation_id, pinned_at)` | `messages` | `get_pinned_messages` |
|
||||
| `idx_reads_user (user_id)` | `message_reads` | `get_unread_counts` |
|
||||
|
||||
**SQL migrace pro existující databázi:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE conversation_members ADD INDEX idx_cm_user (user_id);
|
||||
ALTER TABLE group_invitations ADD INDEX idx_inv_user (user_id);
|
||||
ALTER TABLE messages ADD INDEX idx_messages_deleted (conversation_id, deleted_at);
|
||||
ALTER TABLE messages ADD INDEX idx_messages_pinned (conversation_id, pinned_at);
|
||||
ALTER TABLE message_reads ADD INDEX idx_reads_user (user_id);
|
||||
```
|
||||
|
||||
### 1d. Upload adresář na HDD
|
||||
|
||||
```env
|
||||
UPLOAD_DIR=/mnt/hdd/encrypted_chat/uploads
|
||||
```
|
||||
|
||||
Šifrované soubory a avatary na 4TB HDD — SSD zůstane pro OS a MySQL data.
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/hdd/encrypted_chat/uploads
|
||||
chmod 700 /mnt/hdd/encrypted_chat/uploads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Krok 2: MySQL tuning pro 256 GB RAM
|
||||
|
||||
### `/etc/mysql/mysql.conf.d/tuning.cnf` (nebo ekvivalent v Dockeru)
|
||||
|
||||
```ini
|
||||
[mysqld]
|
||||
# === Buffer Pool — hlavní cache pro data + indexy ===
|
||||
# 96 GB = ~37% RAM (MySQL + app na stejném stroji)
|
||||
innodb_buffer_pool_size = 96G
|
||||
innodb_buffer_pool_instances = 16
|
||||
|
||||
# === Redo Log — větší = méně I/O, rychlejší zápisy ===
|
||||
innodb_redo_log_capacity = 4G
|
||||
|
||||
# === Flush strategie ===
|
||||
# 2 = flush do OS cache každou sekundu (ne každý commit)
|
||||
# Ztráta max 1s dat při pádu OS, ale 10x rychlejší zápisy
|
||||
innodb_flush_log_at_trx_commit = 2
|
||||
# O_DIRECT = bypass OS page cache (InnoDB má vlastní)
|
||||
innodb_flush_method = O_DIRECT
|
||||
|
||||
# === I/O kapacita (SSD) ===
|
||||
innodb_io_capacity = 2000
|
||||
innodb_io_capacity_max = 4000
|
||||
|
||||
# === Connections ===
|
||||
max_connections = 200
|
||||
|
||||
# === Sort/Join buffery ===
|
||||
sort_buffer_size = 4M
|
||||
join_buffer_size = 4M
|
||||
read_buffer_size = 2M
|
||||
read_rnd_buffer_size = 2M
|
||||
|
||||
# === Temporary tables ===
|
||||
tmp_table_size = 256M
|
||||
max_heap_table_size = 256M
|
||||
|
||||
# === Query cache (MySQL 8.0+ nemá, pro 5.7) ===
|
||||
# query_cache_type = 0
|
||||
|
||||
# === Thread cache ===
|
||||
thread_cache_size = 64
|
||||
|
||||
# === Binary logging (pro budoucí repliky) ===
|
||||
# server-id = 1
|
||||
# log_bin = /var/log/mysql/mysql-bin
|
||||
# binlog_expire_logs_seconds = 604800
|
||||
# max_binlog_size = 256M
|
||||
```
|
||||
|
||||
**Pokud MySQL běží v Dockeru:**
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
volumes:
|
||||
- /var/lib/mysql:/var/lib/mysql # data na SSD
|
||||
- ./tuning.cnf:/etc/mysql/conf.d/tuning.cnf
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128G # limitovat aby zbylo pro app
|
||||
environment:
|
||||
MYSQL_DATABASE: encrypted_chat
|
||||
```
|
||||
|
||||
### Po aplikaci restartovat MySQL a ověřit:
|
||||
|
||||
```sql
|
||||
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
|
||||
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
|
||||
SHOW ENGINE INNODB STATUS\G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Krok 3: Doporučená `.env` pro produkci
|
||||
|
||||
```env
|
||||
# Server
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=9999
|
||||
|
||||
# MySQL
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=sifrator
|
||||
MYSQL_PASSWORD=<silne-heslo>
|
||||
MYSQL_DATABASE=encrypted_chat
|
||||
DB_POOL_SIZE=30
|
||||
|
||||
# Performance
|
||||
THREAD_POOL_SIZE=40
|
||||
|
||||
# Storage
|
||||
UPLOAD_DIR=/mnt/hdd/encrypted_chat/uploads
|
||||
|
||||
# TLS (zapnout pro produkci)
|
||||
TLS_ENABLED=true
|
||||
TLS_CERT_FILE=/etc/letsencrypt/live/chat.example.com/fullchain.pem
|
||||
TLS_KEY_FILE=/etc/letsencrypt/live/chat.example.com/privkey.pem
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Krok 4: Monitoring (doporučeno)
|
||||
|
||||
### Jednoduché metriky bez externích nástrojů
|
||||
|
||||
Přidat do serveru periodické logování:
|
||||
|
||||
```python
|
||||
# V _periodic_cleanup() (každých 10 min):
|
||||
async with _clients_lock:
|
||||
total_connections = sum(len(v) for v in connected_clients.values())
|
||||
unique_users = len(connected_clients)
|
||||
logger.info("[STATS] users=%d connections=%d", unique_users, total_connections)
|
||||
```
|
||||
|
||||
### S externími nástroji (volitelně)
|
||||
|
||||
- **htop** — CPU / RAM využití procesu
|
||||
- **mysqladmin status** — queries/s, slow queries, connections
|
||||
- **Prometheus + Grafana** — dlouhodobé trendy (přidat až při potřebě)
|
||||
|
||||
---
|
||||
|
||||
## Budoucí škálování
|
||||
|
||||
### Fáze A: Separace MySQL (15K+ uživatelů)
|
||||
|
||||
MySQL na separátní stroj (nebo managed DB). App server + Redis na jednom, DB na druhém.
|
||||
|
||||
```
|
||||
[Server: App + Redis] ──TCP──▶ [Server: MySQL]
|
||||
│
|
||||
└──▶ [HDD/S3: soubory]
|
||||
```
|
||||
|
||||
### Fáze B: Horizontální škálování (50K+ uživatelů)
|
||||
|
||||
Více app serverů za load balancerem + Redis Pub/Sub pro cross-server notifikace.
|
||||
|
||||
```
|
||||
┌─── App server 1 ───┐
|
||||
Client ──▶ │ connected_clients │──┐
|
||||
└─────────────────────┘ │
|
||||
├──▶ Redis Pub/Sub ──▶ MySQL
|
||||
┌─── App server 2 ───┐ │
|
||||
Client ──▶ │ connected_clients │──┘
|
||||
└─────────────────────┘
|
||||
▲
|
||||
Load Balancer (HAProxy / nginx stream)
|
||||
(sticky sessions by user_id)
|
||||
```
|
||||
|
||||
Hlavní změna: `_notify_users()` posílá do Redis místo lokálního `connected_clients` pokud uživatel není na tomto serveru.
|
||||
|
||||
### Fáze C: DB škálování (100K+ uživatelů)
|
||||
|
||||
- Read replicas pro SELECT dotazy
|
||||
- Partitioning tabulky `messages` podle měsíce
|
||||
- Sharding podle `conversation_id`
|
||||
|
||||
---
|
||||
|
||||
## Přehled — co je hotovo
|
||||
|
||||
| Krok | Stav | Popis |
|
||||
|------|------|-------|
|
||||
| asyncio.to_thread() pro DB | **Hotovo** | 131 DB volání offloadováno do thread poolu |
|
||||
| ThreadPoolExecutor(40) | **Hotovo** | Konfigurovatelný přes `THREAD_POOL_SIZE` |
|
||||
| DB indexy (5 nových) | **Hotovo** | Schema + SQL migrace připraveny |
|
||||
| UPLOAD_DIR na HDD | **Konfigurace** | Nastavit v `.env` |
|
||||
| MySQL tuning | **Konfigurace** | Aplikovat `tuning.cnf` |
|
||||
| TLS certifikát | **TODO** | Let's Encrypt nebo vlastní CA |
|
||||
| Monitoring | **Volitelné** | Periodické logování stats |
|
||||
189
schema.sql
Normal file
189
schema.sql
Normal file
@@ -0,0 +1,189 @@
|
||||
CREATE DATABASE IF NOT EXISTS encrypted_chat
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE encrypted_chat;
|
||||
|
||||
-- Users: identity_key is Ed25519 (32B), rsa_public_key for login challenge only
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
rsa_public_key TEXT NOT NULL,
|
||||
identity_key BLOB NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Devices: each user can have multiple devices
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
device_name VARCHAR(255) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME DEFAULT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_devices_user (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Signed Pre-Keys (X25519, signed by Ed25519 identity key) — per device
|
||||
CREATE TABLE IF NOT EXISTS signed_prekeys (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
device_id CHAR(36) DEFAULT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
signature BLOB NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_spk_user_device (user_id, device_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- One-Time Pre-Keys (consumed on use) — per device
|
||||
CREATE TABLE IF NOT EXISTS one_time_prekeys (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
device_id CHAR(36) DEFAULT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_opk_user_device (user_id, device_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Conversations
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
name VARCHAR(255) DEFAULT NULL,
|
||||
created_by CHAR(36) DEFAULT NULL,
|
||||
avatar_file VARCHAR(255) DEFAULT NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id CHAR(36) NOT NULL,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
joined_at DATETIME NULL,
|
||||
PRIMARY KEY (conversation_id, user_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_cm_user (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Group invitations (pending invitations to join a group)
|
||||
CREATE TABLE IF NOT EXISTS group_invitations (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
conversation_id CHAR(36) NOT NULL,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
invited_by CHAR(36) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_conv_user (conversation_id, user_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_inv_user (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Messages: per-recipient ciphertext (Double Ratchet = each recipient has different ciphertext)
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
conversation_id CHAR(36) NOT NULL,
|
||||
sender_id CHAR(36) NOT NULL,
|
||||
sender_device_id CHAR(36) DEFAULT NULL,
|
||||
ratchet_header BLOB NOT NULL,
|
||||
x3dh_header BLOB DEFAULT NULL,
|
||||
sender_chain_id BLOB DEFAULT NULL,
|
||||
sender_chain_n INT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME DEFAULT NULL,
|
||||
image_file_id CHAR(36) DEFAULT NULL,
|
||||
pinned_at DATETIME DEFAULT NULL,
|
||||
pinned_by CHAR(36) DEFAULT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_messages_conv_created (conversation_id, created_at),
|
||||
INDEX idx_messages_deleted (conversation_id, deleted_at),
|
||||
INDEX idx_messages_pinned (conversation_id, pinned_at)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Per-recipient encrypted content — per device
|
||||
-- device_id '00000000-0000-0000-0000-000000000000' = self-encrypted / legacy
|
||||
CREATE TABLE IF NOT EXISTS message_recipients (
|
||||
message_id CHAR(36) NOT NULL,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
device_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||||
encrypted_content BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
ratchet_header BLOB DEFAULT NULL,
|
||||
x3dh_header BLOB DEFAULT NULL,
|
||||
PRIMARY KEY (message_id, user_id, device_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Sender Keys for groups (distributed via pairwise ratchet) — per device
|
||||
CREATE TABLE IF NOT EXISTS group_sender_keys (
|
||||
conversation_id CHAR(36) NOT NULL,
|
||||
sender_id CHAR(36) NOT NULL,
|
||||
device_id CHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||||
chain_id BLOB NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (conversation_id, sender_id, device_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Read receipts
|
||||
CREATE TABLE IF NOT EXISTS message_reads (
|
||||
message_id CHAR(36) NOT NULL,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (message_id, user_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_reads_user (user_id),
|
||||
INDEX idx_reads_read_at (read_at)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Delivery receipts
|
||||
CREATE TABLE IF NOT EXISTS message_deliveries (
|
||||
message_id CHAR(36) NOT NULL,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
delivered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (message_id, user_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- User profiles
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
phone VARCHAR(50) DEFAULT NULL,
|
||||
phone_visible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
email_visible TINYINT(1) NOT NULL DEFAULT 1,
|
||||
location VARCHAR(255) DEFAULT NULL,
|
||||
location_visible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
avatar_file VARCHAR(255) DEFAULT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Message reactions (emoji reactions on messages)
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
message_id CHAR(36) NOT NULL,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
reaction VARCHAR(32) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_reaction (message_id, user_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_reactions_created_at (created_at)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Image uploads
|
||||
CREATE TABLE IF NOT EXISTS image_uploads (
|
||||
file_id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
conversation_id CHAR(36) NOT NULL,
|
||||
uploader_id CHAR(36) NOT NULL,
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (uploader_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
539
theme.py
Normal file
539
theme.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""Theme system for Encrypted Chat GUI — light + dark mode with live switching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeColors:
|
||||
"""All colour tokens for one theme."""
|
||||
|
||||
# Surface hierarchy
|
||||
bg_primary: str # Main background (messages area, right panel)
|
||||
bg_secondary: str # Cards, inputs, elevated surfaces
|
||||
bg_tertiary: str # Sidebar, deeper surfaces
|
||||
bg_hover: str # Hover state on list items
|
||||
bg_selected: str # Selected list item
|
||||
|
||||
# Text
|
||||
text_primary: str # Main text
|
||||
text_secondary: str # Secondary / muted text
|
||||
text_muted: str # Timestamps, counters, hints
|
||||
|
||||
# Accent (brand blue)
|
||||
accent: str
|
||||
accent_hover: str
|
||||
accent_text: str # Text on accent background
|
||||
|
||||
# Message bubbles
|
||||
bubble_sent_bg: str
|
||||
bubble_sent_text: str
|
||||
bubble_recv_bg: str
|
||||
bubble_recv_text: str
|
||||
bubble_sent_meta: str # Timestamp/read inside sent bubble
|
||||
bubble_recv_meta: str # Timestamp inside received bubble
|
||||
|
||||
# Semantic colours
|
||||
success: str
|
||||
warning: str
|
||||
error: str
|
||||
info: str
|
||||
|
||||
# Chrome / borders
|
||||
border: str
|
||||
border_focus: str
|
||||
scrollbar: str
|
||||
separator: str
|
||||
overlay: str # Privacy overlay background (rgba)
|
||||
|
||||
# Links
|
||||
link_https: str
|
||||
link_http: str # Insecure link (orange)
|
||||
|
||||
# Mentions & search
|
||||
mention: str
|
||||
search_highlight: str
|
||||
search_current: str
|
||||
|
||||
# Reactions
|
||||
reaction_bg: str
|
||||
reaction_bg_own: str
|
||||
reaction_border: str
|
||||
reaction_border_own: str
|
||||
|
||||
# Misc
|
||||
online_dot: str
|
||||
online_dot_border: str
|
||||
pin_color: str
|
||||
sender_name_other: str # Non-self sender name colour in groups
|
||||
receipt_read: str # Read receipt checkmarks (must contrast with sent bubble bg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark theme — Catppuccin Mocha palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DARK_THEME = ThemeColors(
|
||||
bg_primary="#1e1e2e",
|
||||
bg_secondary="#313244",
|
||||
bg_tertiary="#181825",
|
||||
bg_hover="#252536",
|
||||
bg_selected="#313244",
|
||||
|
||||
text_primary="#cdd6f4",
|
||||
text_secondary="#bac2de",
|
||||
text_muted="#6c7086",
|
||||
|
||||
accent="#89b4fa",
|
||||
accent_hover="#74c7ec",
|
||||
accent_text="#1e1e2e",
|
||||
|
||||
bubble_sent_bg="#2a4a7f",
|
||||
bubble_sent_text="#cdd6f4",
|
||||
bubble_recv_bg="#2c2c3e",
|
||||
bubble_recv_text="#cdd6f4",
|
||||
bubble_sent_meta="#8899bb",
|
||||
bubble_recv_meta="#6c7086",
|
||||
|
||||
success="#a6e3a1",
|
||||
warning="#f9e2af",
|
||||
error="#f38ba8",
|
||||
info="#74c7ec",
|
||||
|
||||
border="#45475a",
|
||||
border_focus="#89b4fa",
|
||||
scrollbar="#45475a",
|
||||
separator="#45475a",
|
||||
overlay="rgba(30, 30, 46, 245)",
|
||||
|
||||
link_https="#89b4fa",
|
||||
link_http="#fab387",
|
||||
|
||||
mention="#89b4fa",
|
||||
search_highlight="#f9e2af",
|
||||
search_current="#fab387",
|
||||
|
||||
reaction_bg="#313244",
|
||||
reaction_bg_own="#45475a",
|
||||
reaction_border="#45475a",
|
||||
reaction_border_own="#585b70",
|
||||
|
||||
online_dot="#a6e3a1",
|
||||
online_dot_border="#181825",
|
||||
pin_color="#f9e2af",
|
||||
sender_name_other="#f9e2af",
|
||||
receipt_read="#74c7ec",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Light theme — Signal-inspired palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LIGHT_THEME = ThemeColors(
|
||||
bg_primary="#ffffff",
|
||||
bg_secondary="#f2f2f7",
|
||||
bg_tertiary="#e5e5ea",
|
||||
bg_hover="#dcdce4",
|
||||
bg_selected="#c7c7d1",
|
||||
|
||||
text_primary="#1c1c1e",
|
||||
text_secondary="#3a3a3c",
|
||||
text_muted="#8a8a8e",
|
||||
|
||||
accent="#3478f6",
|
||||
accent_hover="#2563eb",
|
||||
accent_text="#ffffff",
|
||||
|
||||
bubble_sent_bg="#3478f6",
|
||||
bubble_sent_text="#ffffff",
|
||||
bubble_recv_bg="#e5e5ea",
|
||||
bubble_recv_text="#1c1c1e",
|
||||
bubble_sent_meta="#a3c4ff",
|
||||
bubble_recv_meta="#8a8a8e",
|
||||
|
||||
success="#34c759",
|
||||
warning="#ff9500",
|
||||
error="#ff3b30",
|
||||
info="#5ac8fa",
|
||||
|
||||
border="#c6c6c8",
|
||||
border_focus="#3478f6",
|
||||
scrollbar="#aeaeb2",
|
||||
separator="#c6c6c8",
|
||||
overlay="rgba(0, 0, 0, 200)",
|
||||
|
||||
link_https="#2563eb",
|
||||
link_http="#ea580c",
|
||||
|
||||
mention="#2563eb",
|
||||
search_highlight="#fde68a",
|
||||
search_current="#fb923c",
|
||||
|
||||
reaction_bg="#e5e5ea",
|
||||
reaction_bg_own="#c7c7d1",
|
||||
reaction_border="#c6c6c8",
|
||||
reaction_border_own="#a0a0a8",
|
||||
|
||||
online_dot="#34c759",
|
||||
online_dot_border="#e5e5ea",
|
||||
pin_color="#ff9500",
|
||||
sender_name_other="#7c3aed",
|
||||
receipt_read="#d0e8ff",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ThemeManager singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ThemeManager:
|
||||
"""Manages the active theme, persistence and change notification."""
|
||||
|
||||
_instance: ThemeManager | None = None
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> ThemeManager:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
self._is_dark: bool = True
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
self._email: str | None = None
|
||||
self._load_global()
|
||||
|
||||
# -- Public API --
|
||||
|
||||
@property
|
||||
def is_dark(self) -> bool:
|
||||
return self._is_dark
|
||||
|
||||
@property
|
||||
def colors(self) -> ThemeColors:
|
||||
return DARK_THEME if self._is_dark else LIGHT_THEME
|
||||
|
||||
def toggle(self):
|
||||
self._is_dark = not self._is_dark
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def set_dark(self, dark: bool):
|
||||
if dark == self._is_dark:
|
||||
return
|
||||
self._is_dark = dark
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def set_email(self, email: str):
|
||||
"""After login, bind to user-specific preference file."""
|
||||
self._email = email
|
||||
self._load_user()
|
||||
|
||||
def on_change(self, callback: Callable[[], None]):
|
||||
self._listeners.append(callback)
|
||||
|
||||
def remove_listener(self, callback: Callable[[], None]):
|
||||
try:
|
||||
self._listeners.remove(callback)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def generate_qss(self) -> str:
|
||||
return _build_qss(self.colors)
|
||||
|
||||
# -- Persistence --
|
||||
|
||||
def _global_path(self) -> Path:
|
||||
p = Path.home() / ".encrypted_chat"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p / "global_settings.json"
|
||||
|
||||
def _user_path(self) -> Path | None:
|
||||
if not self._email:
|
||||
return None
|
||||
p = Path.home() / ".encrypted_chat" / self._email
|
||||
if not p.exists():
|
||||
return None
|
||||
return p / "theme.json"
|
||||
|
||||
def _load_global(self):
|
||||
try:
|
||||
p = self._global_path()
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text())
|
||||
self._is_dark = data.get("dark", True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_user(self):
|
||||
try:
|
||||
p = self._user_path()
|
||||
if p and p.exists():
|
||||
data = json.loads(p.read_text())
|
||||
self._is_dark = data.get("dark", self._is_dark)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save(self):
|
||||
data = {"dark": self._is_dark}
|
||||
try:
|
||||
self._global_path().write_text(json.dumps(data))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
p = self._user_path()
|
||||
if p:
|
||||
p.write_text(json.dumps(data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _notify(self):
|
||||
for cb in list(self._listeners):
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
logger.debug("Theme listener error", exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def c() -> ThemeColors:
|
||||
"""Shorthand for ThemeManager.instance().colors."""
|
||||
return ThemeManager.instance().colors
|
||||
|
||||
|
||||
def qss() -> str:
|
||||
"""Shorthand for ThemeManager.instance().generate_qss()."""
|
||||
return ThemeManager.instance().generate_qss()
|
||||
|
||||
|
||||
def tm() -> ThemeManager:
|
||||
"""Shorthand for ThemeManager.instance()."""
|
||||
return ThemeManager.instance()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QSS generator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FONT_STACK = (
|
||||
'"Segoe UI Variable", "Segoe UI", "Helvetica Neue", '
|
||||
'"SF Pro Text", "Calibri", sans-serif'
|
||||
)
|
||||
|
||||
|
||||
def _build_qss(t: ThemeColors) -> str:
|
||||
return f"""
|
||||
/* ── Global ──────────────────────────────────────────────── */
|
||||
QWidget {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
font-family: {_FONT_STACK};
|
||||
font-size: 11pt;
|
||||
}}
|
||||
|
||||
/* ── Input fields ────────────────────────────────────────── */
|
||||
QLineEdit {{
|
||||
background-color: {t.bg_secondary};
|
||||
border: 1px solid {t.border};
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: 1px solid {t.border_focus};
|
||||
}}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────── */
|
||||
QPushButton {{
|
||||
background-color: {t.accent};
|
||||
color: {t.accent_text};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {t.accent_hover};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {t.accent_hover};
|
||||
}}
|
||||
QPushButton#secondaryBtn {{
|
||||
background-color: {t.bg_secondary};
|
||||
color: {t.text_primary};
|
||||
font-weight: normal;
|
||||
}}
|
||||
QPushButton#secondaryBtn:hover {{
|
||||
background-color: {t.bg_hover};
|
||||
}}
|
||||
QPushButton#toolBtn {{
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}}
|
||||
QPushButton#toolBtn:hover {{
|
||||
background-color: {t.bg_hover};
|
||||
}}
|
||||
|
||||
/* ── Lists ───────────────────────────────────────────────── */
|
||||
QListWidget {{
|
||||
background-color: {t.bg_tertiary};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: {t.bg_selected};
|
||||
border-left: 3px solid {t.accent};
|
||||
}}
|
||||
QListWidget::item:hover {{
|
||||
background-color: {t.bg_hover};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── Text areas ──────────────────────────────────────────── */
|
||||
QTextEdit, QTextBrowser {{
|
||||
background-color: {t.bg_primary};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────────────────────── */
|
||||
QScrollBar:vertical {{
|
||||
background: transparent;
|
||||
width: 8px;
|
||||
margin: 0;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {t.scrollbar};
|
||||
border-radius: 4px;
|
||||
min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{
|
||||
background: {t.text_muted};
|
||||
}}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
||||
height: 0;
|
||||
}}
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
|
||||
background: transparent;
|
||||
}}
|
||||
QScrollBar:horizontal {{
|
||||
background: transparent;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {t.scrollbar};
|
||||
border-radius: 4px;
|
||||
min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{
|
||||
background: {t.text_muted};
|
||||
}}
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||
width: 0;
|
||||
}}
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
/* ── Title label ─────────────────────────────────────────── */
|
||||
QLabel#title {{
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: {t.accent};
|
||||
}}
|
||||
|
||||
/* ── Sidebar panel ───────────────────────────────────────── */
|
||||
#sidebarPanel {{
|
||||
background-color: {t.bg_tertiary};
|
||||
}}
|
||||
|
||||
/* ── Splitter ────────────────────────────────────────────── */
|
||||
QSplitter::handle {{
|
||||
background-color: {t.separator};
|
||||
width: 1px;
|
||||
}}
|
||||
|
||||
/* ── Checkbox ────────────────────────────────────────────── */
|
||||
QCheckBox {{
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── Menus ───────────────────────────────────────────────── */
|
||||
QMenu {{
|
||||
background-color: {t.bg_secondary};
|
||||
border: 1px solid {t.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}}
|
||||
QMenu::item {{
|
||||
padding: 6px 20px;
|
||||
color: {t.text_primary};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QMenu::item:selected {{
|
||||
background-color: {t.bg_hover};
|
||||
}}
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background: {t.separator};
|
||||
margin: 4px 8px;
|
||||
}}
|
||||
|
||||
/* ── Dialogs ─────────────────────────────────────────────── */
|
||||
QDialog {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── MessageBox ──────────────────────────────────────────── */
|
||||
QMessageBox {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
QMessageBox QLabel {{
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── InputDialog ─────────────────────────────────────────── */
|
||||
QInputDialog {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── ScrollArea ──────────────────────────────────────────── */
|
||||
QScrollArea {{
|
||||
background-color: {t.bg_primary};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
/* ── ToolTip ─────────────────────────────────────────────── */
|
||||
QToolTip {{
|
||||
background-color: {t.bg_secondary};
|
||||
color: {t.text_primary};
|
||||
border: 1px solid {t.border};
|
||||
padding: 4px 8px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
"""
|
||||
Reference in New Issue
Block a user