# 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.