aprhodite/README.md

593 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<username>`
Retrieve encrypted PM history with another user. Requires `Authorization: Bearer <token>`.
**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:<lowercase_username>"`.
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 560574
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 607612
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 453455
```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 113118
`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.