Add detailed README with full security audit

This commit is contained in:
3nd3r 2026-04-12 12:31:27 -05:00
parent c514c5fb73
commit 1c17a9bcf0
1 changed files with 592 additions and 0 deletions

592
README.md Normal file
View File

@ -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=<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.