From 1c17a9bcf037d3c1e1b78984decb1cc57dc0c518 Mon Sep 17 00:00:00 2001 From: 3nd3r Date: Sun, 12 Apr 2026 12:31:27 -0500 Subject: [PATCH] Add detailed README with full security audit --- README.md | 592 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f74858 --- /dev/null +++ b/README.md @@ -0,0 +1,592 @@ +# Aphrodite — SexyChat + +A real-time encrypted chat application built with Flask-SocketIO, featuring a public lobby, end-to-end encrypted private messaging, an AI companion ("Violet") powered by Ollama, moderation tools, and a payment-gated premium tier. + +--- + +## Table of Contents + +- [Features](#features) +- [Architecture Overview](#architecture-overview) +- [Tech Stack](#tech-stack) +- [Project Structure](#project-structure) +- [Configuration](#configuration) +- [Installation & Setup](#installation--setup) +- [Running the Server](#running-the-server) +- [Socket Protocol](#socket-protocol) +- [REST API Endpoints](#rest-api-endpoints) +- [Encryption Design](#encryption-design) +- [Moderation System](#moderation-system) +- [Violet AI Companion](#violet-ai-companion) +- [Payment & Premium Access](#payment--premium-access) +- [Database Schema](#database-schema) +- [Known Security Issues & Bugs](#known-security-issues--bugs) + +--- + +## Features + +- **Public Lobby** — Ephemeral group chat room. Messages are never persisted; they live only in connected clients' browsers. +- **User Registration & Login** — Accounts with bcrypt-hashed passwords, JWT session tokens, and moderator-gated verification. +- **End-to-End Encrypted Private Messages** — AES-GCM-256 encryption using PBKDF2-derived keys. The server stores only ciphertext and nonces. +- **Violet AI Companion** — A flirtatious AI hostess backed by a local Ollama instance. Messages are transit-encrypted (decrypted server-side for inference, re-encrypted before storage and delivery). +- **Freemium AI Access** — Free users get a configurable number of AI messages before hitting a paywall. Paying users get unlimited access. +- **Moderation Tools** — Kick, ban, kickban, mute/unmute, and manual account verification. Moderator access is granted via a shared admin password at login. +- **Ignore System** — Registered users can ignore/unignore other users. Ignored users' messages are hidden client-side, and PM invitations from ignored users are silently blocked. +- **Responsive UI** — Glassmorphism design with a deep purple/neon magenta theme. Mobile-friendly with a collapsible nicklist sidebar. +- **Process Manager** — `start.py` wraps Gunicorn with daemon management (start, stop, restart, status, debug). + +--- + +## Architecture Overview + +``` +┌──────────────┐ WebSocket / HTTP ┌─────────────────────┐ +│ Browser │ ◄──────────────────────────────►│ Flask-SocketIO │ +│ (chat.js) │ │ (app.py) │ +│ (crypto.js) │ │ │ +└──────────────┘ │ ┌───────────────┐ │ + │ │ REST Blueprint │ │ + │ │ (routes.py) │ │ + │ └───────┬───────┘ │ + │ │ │ + │ ┌───────▼───────┐ │ + │ │ SQLAlchemy │ │ + │ │ (database.py) │ │ + │ │ (models.py) │ │ + │ └───────┬───────┘ │ + │ │ │ + │ ┌───────▼───────┐ │ + │ │ SQLite / PG │ │ + │ └───────────────┘ │ + │ │ + │ ┌───────────────┐ │ + │ │ Ollama (AI) │ │ + │ └───────────────┘ │ + └─────────────────────┘ +``` + +**Key design decisions:** + +- **Single-process, single-worker** — Eventlet async mode with one Gunicorn worker. All in-memory state (connected users, bans, mutes) lives in the process. +- **AI inference queue** — A dedicated eventlet greenlet serialises Ollama requests one at a time, broadcasting `violet_typing` indicators while busy. +- **Lobby is ephemeral** — No lobby messages are ever written to the database. +- **PMs are persisted for registered users** — Encrypted messages are stored in the `messages` table. History is capped at 500 messages per conversation pair. + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Backend framework | Flask 3.x | +| Real-time transport | Flask-SocketIO 5.x (eventlet async) | +| WSGI server | Gunicorn 21.x with eventlet worker | +| Database ORM | Flask-SQLAlchemy 3.x | +| Migrations | Flask-Migrate 4.x (Alembic) | +| Database | SQLite (default) or PostgreSQL (via `DATABASE_URL`) | +| Password hashing | bcrypt 4.x | +| JWT auth | PyJWT 2.x | +| Server-side crypto | cryptography 42.x (AES-GCM) | +| Client-side crypto | Web Crypto API (SubtleCrypto) | +| AI inference | Ollama (local, HTTP API) | +| Frontend | Vanilla JS, Socket.IO 4 client, custom CSS | + +--- + +## Project Structure + +``` +aprhodite/ +├── app.py # Flask-SocketIO app factory, socket event handlers, AI queue +├── database.py # SQLAlchemy & Flask-Migrate initialisation, DB seeding +├── models.py # ORM models: User, Message, UserIgnore +├── routes.py # REST API blueprint (auth, PM history, AI message, payment) +├── start.py # Gunicorn process manager (start/stop/restart/status/debug) +├── index.html # Single-page frontend (join screen, chat, modals) +├── requirements.txt # Python dependencies +├── config.json # Optional config file (alternative to env vars) +└── static/ + ├── chat.js # Frontend logic (socket events, PM/AI flows, UI) + ├── crypto.js # AES-GCM-256 encryption wrapper (PBKDF2 key derivation) + ├── socket.io.min.js # Socket.IO v4 client library + └── style.css # Glassmorphism theme (deep purple / neon magenta) +``` + +--- + +## Configuration + +Configuration is loaded with the following precedence: **Environment Variable → `config.json` → Default Value**. + +| Key | Default | Description | +|-----|---------|-------------| +| `SECRET_KEY` | Random UUID hex | Flask session secret | +| `JWT_SECRET` | Random UUID hex | HMAC signing key for JWTs | +| `ADMIN_PASSWORD` | `admin1234` | Shared moderator password | +| `DATABASE_URL` | `sqlite:///sexchat.db` | SQLAlchemy database URI | +| `AI_FREE_LIMIT` | `3` | Free AI messages before paywall | +| `OLLAMA_URL` | `http://localhost:11434` | Ollama API base URL | +| `VIOLET_MODEL` | `sam860/dolphin3-llama3.2:3b` | Ollama model tag for Violet | +| `PAYMENT_SECRET` | `change-me-payment-secret` | Webhook validation secret | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `5000` | Bind port | +| `SOCKETIO_MESSAGE_QUEUE` | `None` | Redis URL for multi-process Socket.IO | +| `REDIS_URL` | `None` | Fallback for `SOCKETIO_MESSAGE_QUEUE` | + +Create a `config.json` in the project root: + +```json +{ + "SECRET_KEY": "your-secret-key", + "JWT_SECRET": "your-jwt-secret", + "ADMIN_PASSWORD": "a-strong-moderator-password", + "DATABASE_URL": "postgresql://user:pass@localhost/sexchat", + "PAYMENT_SECRET": "your-stripe-webhook-secret", + "OLLAMA_URL": "http://localhost:11434", + "VIOLET_MODEL": "sam860/dolphin3-llama3.2:3b", + "AI_FREE_LIMIT": 3 +} +``` + +--- + +## Installation & Setup + +### Prerequisites + +- Python 3.10+ +- pip +- Ollama (optional — required only for live AI responses) + +### Install + +```bash +git clone https://git.computertech.dev/lord3nd3r/aprhodite.git +cd aprhodite +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### Database + +On first run, `database.py` calls `db.create_all()` and seeds the Violet bot user. No manual migration is needed for a fresh start. For schema changes: + +```bash +flask db migrate -m "description" +flask db upgrade +``` + +### Ollama (for live AI) + +```bash +# Install Ollama (https://ollama.com) +ollama pull sam860/dolphin3-llama3.2:3b +ollama serve # default :11434 +``` + +If Ollama is unavailable, `routes.py` falls back to canned responses; `app.py` returns a placeholder message. + +--- + +## Running the Server + +### Process Manager (recommended for production) + +```bash +python start.py start # Start as background daemon +python start.py stop # Graceful shutdown +python start.py restart # Stop + start +python start.py status # Check if running +python start.py debug # Foreground with debug logging +``` + +### Direct Gunicorn + +```bash +gunicorn --worker-class eventlet -w 1 --bind 0.0.0.0:5000 start:application +``` + +### Development (Flask built-in server) + +```python +from app import create_app, socketio +app = create_app() +socketio.run(app, host="0.0.0.0", port=5000, debug=True) +``` + +--- + +## Socket Protocol + +### Client → Server Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `join` | `{ mode, username, password?, email?, mod_password? }` | Authenticate and enter the lobby. `mode` is `guest`, `login`, `register`, or `restore`. | +| `message` | `{ text }` | Send a message to the lobby. | +| `pm_open` | `{ target }` | Request a private message room with `target` user. | +| `pm_accept` | `{ room }` | Accept an incoming PM invitation. | +| `pm_message` | `{ room, text? }` or `{ room, ciphertext, nonce, transit_key? }` | Send a PM. Include `transit_key` when messaging Violet. | +| `ai_message` | `{ ciphertext, nonce, transit_key }` | *Deprecated.* Use `pm_message` to Violet instead. | +| `mod_kick` | `{ target }` | Kick a user (admin only). | +| `mod_ban` | `{ target }` | Ban a user by username + IP (admin only). | +| `mod_kickban` | `{ target }` | Kick and ban simultaneously (admin only). | +| `mod_mute` | `{ target }` | Toggle mute on a user (admin only). | +| `mod_verify` | `{ target }` | Verify a registered user's account (admin only). | +| `user_ignore` | `{ target }` | Add user to ignore list (registered only). | +| `user_unignore` | `{ target }` | Remove user from ignore list (registered only). | + +### Server → Client Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `joined` | `{ username, is_admin, is_registered, has_ai_access, ai_messages_used, token?, ignored_list? }` | Successful join confirmation. | +| `nicklist` | `{ users: [{ username, is_admin, is_registered, is_verified, is_ai? }] }` | Updated user list. | +| `message` | `{ username, text, is_admin, is_registered, ts }` | Lobby message. | +| `system` | `{ msg, ts }` | System announcement (joins, parts, mod actions). | +| `error` | `{ msg }` | Error message. | +| `kicked` | `{ msg }` | Notification that the user has been kicked/banned. | +| `pm_invite` | `{ from, room }` | Incoming PM invitation. | +| `pm_ready` | `{ with, room }` | PM room is ready (sent to initiator). | +| `pm_message` | `{ from, text?, ciphertext?, nonce?, room, ts }` | Private message. | +| `violet_typing` | `{ busy: bool, room? }` | Violet AI typing indicator. | +| `ai_response` | `{ ciphertext, nonce, ai_messages_used, has_ai_access }` or `{ error }` | AI response (legacy event). | +| `ai_unlock` | `{ msg }` | AI access unlocked after payment. | +| `ignore_status` | `{ target, ignored: bool }` | Confirmation of ignore/unignore action. | + +--- + +## REST API Endpoints + +All endpoints are under the `/api` prefix. + +### `POST /api/auth/register` + +Create a new account. + +**Body:** `{ "username": "...", "password": "...", "email": "..." }` +**Response:** `201 { "token": "jwt...", "user": { id, username, has_ai_access, ai_messages_used } }` + +### `POST /api/auth/login` + +Authenticate and receive a JWT. + +**Body:** `{ "username": "...", "password": "..." }` +**Response:** `200 { "token": "jwt...", "user": { ... } }` + +### `GET /api/pm/history?with=` + +Retrieve encrypted PM history with another user. Requires `Authorization: Bearer `. + +**Response:** `200 { "messages": [{ from_me, ciphertext, nonce, ts }] }` + +### `POST /api/ai/message` + +Send an encrypted message to Violet via REST (alternative to socket). + +**Body:** `{ "ciphertext": "...", "nonce": "...", "transit_key": "..." }` +**Response:** `200 { "ciphertext": "...", "nonce": "...", "ai_messages_used": N, "has_ai_access": bool }` + +### `POST /api/payment/success` + +Payment webhook to unlock AI access. + +**Body:** `{ "secret": "..." }` +**Response:** `200 { "status": "ok", "has_ai_access": true }` + +--- + +## Encryption Design + +### User-to-User PMs + +1. **Key Derivation** — The client derives an AES-GCM-256 key from `(password, username)` using PBKDF2 with 100,000 iterations and SHA-256. The salt is `"sexychat:v1:"`. +2. **Encryption** — Messages are encrypted client-side with a random 12-byte nonce before being sent over the socket. +3. **Storage** — The server stores only the base64-encoded ciphertext and nonce. It never sees the plaintext or key. +4. **Decryption** — The recipient decrypts client-side using their own derived key. + +### Violet AI (Transit Encryption) + +1. The client encrypts the message with their derived key and also sends the key (`transit_key`) to the server over HTTPS/WSS. +2. The server decrypts the message (transit), passes plaintext to Ollama for inference, then re-encrypts the AI response with the same transit key. +3. The transit key is used only in memory and is never stored. + +--- + +## Moderation System + +Moderators authenticate by providing the shared `ADMIN_PASSWORD` alongside their normal login credentials. Mod powers include: + +| Action | Effect | +|--------|--------| +| **Kick** | Disconnects the target. They can rejoin immediately. | +| **Ban** | Adds username + IP to in-memory ban lists. Persists until server restart. | +| **Kickban** | Kick + ban in one action. | +| **Mute / Unmute** | Toggles mute. Muted users cannot send lobby messages. | +| **Verify** | Marks a registered account as verified, allowing them to log in. New registrations require moderator verification before the account can be used. | + +--- + +## Violet AI Companion + +Violet is an AI persona backed by a local Ollama model. She is configured as a "flirtatious and sophisticated nightclub hostess" via a system prompt. + +- **Model**: Configurable via `VIOLET_MODEL` (default: `sam860/dolphin3-llama3.2:3b`) +- **Inference**: Serialised through an eventlet queue — one request at a time to avoid overloading the local GPU/CPU +- **Typing indicator**: `violet_typing` events are broadcast while Ollama is processing +- **Fallback**: If Ollama fails, a graceful fallback message is returned +- **Free tier**: Configurable via `AI_FREE_LIMIT` (default: 3 messages) + +--- + +## Payment & Premium Access + +The current payment flow is a **stub** intended to be replaced with a real payment provider (e.g. Stripe): + +1. Client sends the `PAYMENT_SECRET` to `POST /api/payment/success`. +2. Server validates with constant-time comparison, flips `user.has_ai_access = True`. +3. An `ai_unlock` socket event is pushed to the user's active session. + +**For production**: Replace the secret-comparison logic with `stripe.Webhook.construct_event()` using the raw request body and `Stripe-Signature` header. + +--- + +## Database Schema + +### `users` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | Integer PK | Auto-increment | +| `username` | String(20) | Unique, indexed | +| `password_hash` | String(128) | bcrypt | +| `email` | String(255) | Unique, nullable | +| `has_ai_access` | Boolean | Premium flag | +| `ai_messages_used` | Integer | Free trial counter | +| `is_verified` | Boolean | Moderator-verified flag | +| `created_at` | DateTime | UTC timestamp | + +### `messages` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | Integer PK | Auto-increment | +| `sender_id` | FK → users.id | | +| `recipient_id` | FK → users.id | | +| `encrypted_content` | Text | Base64 AES-GCM ciphertext | +| `nonce` | String(64) | Base64 AES-GCM nonce/IV | +| `timestamp` | DateTime | Indexed with sender/recipient | + +### `user_ignores` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | Integer PK | Auto-increment | +| `ignorer_id` | FK → users.id | | +| `ignored_id` | FK → users.id | | +| `created_at` | DateTime | | + +Unique composite index on `(ignorer_id, ignored_id)`. + +--- + +## Known Security Issues & Bugs + +The following issues have been identified during code review. They are listed by severity. + +--- + +### P0 — Critical (fix immediately) + +#### 1. JWT Secret Mismatch Between `app.py` and `routes.py` + +**File:** `app.py` line 84, `routes.py` line 30 + +`app.py` generates a random `JWT_SECRET` via `uuid.uuid4().hex` on every server restart. `routes.py` independently hardcodes `"change-me-jwt-secret"` as its default. These are two separate values — JWTs issued by the socket layer (`app.py`) will fail validation in the REST API (`routes.py`) and vice versa. **Authentication is broken across the two entry points.** + +**Impact:** Users who log in via sockets cannot use the REST API, and vice versa. In the worst case, if `routes.py`'s default is left in production, its JWT secret is publicly known from the source code, allowing anyone to forge tokens. + +**Fix:** Use a single shared `JWT_SECRET` loaded once from configuration. Remove the duplicate constant in `routes.py` and import from `app.py` or a shared config module. + +--- + +#### 2. Unauthorised PM Room Join (`pm_accept`) + +**File:** `app.py` — `on_pm_accept` handler + +```python +@socketio.on("pm_accept") +def on_pm_accept(data): + join_room(data.get("room")) # no validation +``` + +Any client can emit `pm_accept` with an arbitrary room name (e.g. `pm:alice:bob`) and silently join that room, receiving all future messages. There is no check that the user was actually invited to this room. + +**Impact:** Complete compromise of private messaging confidentiality (for plaintext/guest PMs). For encrypted PMs, the attacker receives ciphertext but not keys — however, it still leaks metadata (who is talking, when, message sizes). + +**Fix:** Maintain a server-side mapping of pending invitations. Only allow `pm_accept` if the user's SID has a pending invite for that specific room. + +--- + +#### 3. Client-Exploitable Payment Endpoint + +**Files:** `routes.py` — `payment_success`, `chat.js` lines 560–574 + +The payment "verification" consists of the client sending a secret string to the server. The default `PAYMENT_SECRET` is `"change-me-payment-secret"` (known from source). Even if changed, the client-side code in `chat.js` hardcodes the secret and sends it directly: + +```javascript +const secret = "change-me-payment-webhook-secret"; +``` + +Any user with a valid JWT can call `POST /api/payment/success` with the secret and unlock premium AI access for free. + +**Impact:** Complete bypass of the payment system. Any registered user gets unlimited AI access without paying. + +**Fix:** Replace with a server-side Stripe/payment-provider webhook. The payment confirmation must originate from the payment provider's servers, not from the client. + +--- + +#### 4. PM Invite Broadcast When PMing Violet + +**File:** `app.py` — `on_pm_open` handler, lines 607–612 + +When a user opens a PM with Violet, `target_sid` is `None` (Violet has no socket connection). The code then executes: + +```python +socketio.emit("pm_invite", {"from": user["username"], "room": room}, to=None) +``` + +Emitting `to=None` broadcasts the event to **all connected clients**, informing everyone that the user is starting a private conversation with Violet. + +**Impact:** Privacy leak — all connected users learn who is chatting with Violet. + +**Fix:** Skip the `pm_invite` emit when the target is Violet. The initiator already receives `pm_ready`; no invite is needed for a bot. + +--- + +### P1 — High Severity + +#### 5. E2E Encryption Is Fundamentally Broken for User-to-User PMs + +**File:** `static/crypto.js` — `deriveKey` + +Keys are derived from `(password, username)` using PBKDF2. When User A sends a PM to User B: +- User A encrypts with a key derived from *A's password and A's username*. +- User B attempts to decrypt with a key derived from *B's password and B's username*. +- These are entirely different keys. **Decryption will always fail.** + +The only scenario where this works is when a user decrypts their own messages (e.g. PM history reload), since the key matches. But real-time cross-user PMs cannot be decrypted. + +**Impact:** User-to-user encrypted PMs are non-functional. Users will see decryption errors for every received message. + +**Fix:** Implement a proper key exchange protocol (e.g. ECDH / X25519) where both parties derive a shared secret, or use a server-mediated key-wrapping scheme. + +--- + +#### 6. Null DOM Element References Crash the Frontend + +**File:** `static/chat.js` + +- Line 547: `$("tab-ai-violet")` — references an element ID that does not exist in `index.html`. +- Line 44: `$("violet-trial-badge")` — also missing from the HTML. + +These return `null`, and any property access (e.g. `.onclick`, `.classList`) will throw `TypeError: Cannot read properties of null`. + +**Impact:** JavaScript execution halts at these points, potentially breaking logout, tab switching, or the entire chat UI on load. + +**Fix:** Add the missing elements to `index.html`, or add null guards before accessing these elements. + +--- + +#### 7. CORS Wildcard on WebSocket + +**File:** `app.py` line 381 + +```python +socketio.init_app(app, cors_allowed_origins="*", ...) +``` + +This allows any website on the internet to open WebSocket connections to the server. A malicious page could connect on behalf of a visiting user if cookies/tokens are available. + +**Impact:** Cross-site WebSocket hijacking. An attacker's page could impersonate users, send messages, or eavesdrop on the lobby. + +**Fix:** Restrict `cors_allowed_origins` to the actual domain(s) serving the frontend. + +--- + +### P2 — Medium Severity + +#### 8. Massive Code Duplication Between `app.py` and `routes.py` + +Both files independently define their own copies of: +- `_aesgcm_encrypt` / `_aesgcm_decrypt` +- `_issue_jwt` / `_verify_jwt` +- `_save_pm` / `_persist_message` +- Constants: `AI_FREE_LIMIT`, `AI_BOT_NAME`, `JWT_SECRET` + +They have already diverged — `routes.py` uses mock AI responses (`random.choice(AI_RESPONSES)`) while `app.py` calls Ollama. The JWT secrets are different (see issue #1). + +**Impact:** Bugs fixed in one file won't be fixed in the other. Behaviour differs depending on whether the user interacts via sockets or REST. Maintenance burden grows over time. + +**Fix:** Extract shared utilities (crypto, JWT, DB helpers, constants) into a common module (e.g. `utils.py` or `config.py`). Import from there in both `app.py` and `routes.py`. + +--- + +#### 9. Context Menu Breaks After First Use + +**File:** `static/chat.js` — `showContextMenu` function, lines 453–455 + +```javascript +const newMenu = contextMenu.cloneNode(true); +contextMenu.replaceWith(newMenu); +``` + +The function clones and replaces the context menu DOM node to clear event listeners. However, the module-level `contextMenu` variable still references the old (now-removed) node. On the second right-click, the code operates on a detached element. + +**Impact:** Context menu stops working after the first use. Users can only PM/ignore/mod-action once per page load. + +**Fix:** Update the variable reference after replacement, or use `removeEventListener` / event delegation instead of cloning. + +--- + +#### 10. All Runtime State Is In-Memory Only + +**File:** `app.py` lines 113–118 + +`connected_users`, `banned_usernames`, `banned_ips`, and `muted_users` are plain Python dicts/sets. All bans, mutes, and IP blocks are lost on every server restart. + +**Impact:** Banned users can return simply by waiting for a server restart. Moderator actions have no lasting effect. + +**Fix:** Persist bans and mutes to the database. Load them into memory on startup. + +--- + +### P3 — Low Severity + +#### 11. Deprecated `datetime.utcnow()` Usage + +**File:** `models.py` — all `default=datetime.utcnow` column definitions + +`datetime.utcnow()` is deprecated as of Python 3.12. It returns a naive datetime with no timezone info, which can cause subtle bugs with timezone-aware code. + +**Impact:** Deprecation warnings on Python 3.12+. Potential timezone-related bugs if the codebase later mixes aware and naive datetimes. + +**Fix:** Use `datetime.now(datetime.timezone.utc)` or `func.now()` (SQLAlchemy server default) instead. + +--- + +#### 12. Default Admin Password + +**File:** `app.py` line 85 + +```python +ADMIN_PASSWORD = _get_conf("ADMIN_PASSWORD", "admin1234") +``` + +If the operator does not set a custom admin password, anyone who reads the source code or guesses `admin1234` gains full moderator access (kick, ban, mute, verify users). + +**Impact:** Unauthorised moderator access in default-configured deployments. + +**Fix:** Require `ADMIN_PASSWORD` to be explicitly set in config. Refuse to start (or disable mod features) if it's left at the default value.