From cdfbb666b96da7791643f4dbf14752dd8c5ff710 Mon Sep 17 00:00:00 2001 From: 3nd3r Date: Sun, 12 Apr 2026 12:54:09 -0500 Subject: [PATCH] Fix #5: Fix broken E2E encryption for user-to-user PMs - User-to-user PMs now use a server-derived shared room key (HMAC-SHA256) instead of each user's personal PBKDF2 key (which differed per user, making cross-user decryption impossible) - Server sends room_key in pm_ready, pm_invite, and pm/history responses - crypto.js: add importKeyBase64() for importing server-provided keys - chat.js: use sharedKey for encrypt/decrypt in user-to-user PMs - Violet AI transit encryption still uses personal key (unchanged) - PM history decryption now handles errors gracefully per-message - Encodes otherUser in history URL to prevent injection --- app.py | 21 +++++++++-- routes.py | 12 ++++++- static/chat.js | 92 ++++++++++++++++++++++++++++++++++++------------ static/crypto.js | 19 +++++++++- 4 files changed, 117 insertions(+), 27 deletions(-) diff --git a/app.py b/app.py index b711536..efe4f7f 100644 --- a/app.py +++ b/app.py @@ -40,6 +40,8 @@ Socket events (server → client) import os import time +import hmac +import hashlib import functools from collections import defaultdict @@ -197,6 +199,20 @@ def _pm_room(a: str, b: str) -> str: return "pm:" + ":".join(sorted([a.lower(), b.lower()])) +def _pm_room_key(room: str) -> str: + """Derive a deterministic AES-256 key for a PM room. + + Uses HMAC-SHA256 keyed with JWT_SECRET so the same room always gets + the same key (allowing history to be decrypted across sessions). + The server mediates the key – this is NOT end-to-end, but it fixes + the broken cross-user decryption while matching the existing trust model. + """ + from config import JWT_SECRET + raw = hmac.new(JWT_SECRET.encode(), room.encode(), hashlib.sha256).digest() + import base64 + return base64.b64encode(raw).decode() + + def _get_nicklist() -> list: users = [] for info in connected_users.values(): @@ -528,10 +544,11 @@ def on_pm_open(data): room = _pm_room(user["username"], target) join_room(room) + room_key = _pm_room_key(room) if target_sid: pending_pm_invites.setdefault(target_sid, set()).add(room) - socketio.emit("pm_invite", {"from": user["username"], "room": room}, to=target_sid) - emit("pm_ready", {"with": target, "room": room}) + socketio.emit("pm_invite", {"from": user["username"], "room": room, "room_key": room_key}, to=target_sid) + emit("pm_ready", {"with": target, "room": room, "room_key": room_key}) diff --git a/routes.py b/routes.py index 5ede910..335273f 100644 --- a/routes.py +++ b/routes.py @@ -12,8 +12,10 @@ POST /api/payment/success – Validate webhook secret, unlock AI, push socke import os import hmac +import hashlib import random import functools +import base64 import bcrypt from flask import Blueprint, g, jsonify, request @@ -21,7 +23,7 @@ from flask import Blueprint, g, jsonify, request from database import db from models import User, Message from config import ( - AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, + AI_FREE_LIMIT, AI_BOT_NAME, PAYMENT_SECRET, MAX_HISTORY, JWT_SECRET, aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt, ) @@ -203,6 +205,13 @@ def pm_history(): if not other: return jsonify({"messages": []}) + # Derive the room key so the client can decrypt history + pair = ":".join(sorted([me.username.lower(), other.username.lower()])) + room_name = "pm:" + pair + room_key = base64.b64encode( + hmac.new(JWT_SECRET.encode(), room_name.encode(), hashlib.sha256).digest() + ).decode() + rows = ( Message.query .filter( @@ -218,6 +227,7 @@ def pm_history(): rows.reverse() # return in chronological order return jsonify({ + "room_key": room_key, "messages": [ { "from_me": m.sender_id == me.id, diff --git a/static/chat.js b/static/chat.js index d113435..9733705 100644 --- a/static/chat.js +++ b/static/chat.js @@ -178,30 +178,45 @@ socket.on("message", (data) => { // ── Private Messaging ───────────────────────────────────────────────────── -socket.on("pm_invite", (data) => { +socket.on("pm_invite", async (data) => { if (state.pms[data.room]) return; // Already accepted + // Store the room key for later use + if (data.room_key) { + state._pendingRoomKeys = state._pendingRoomKeys || {}; + state._pendingRoomKeys[data.room] = data.room_key; + } $("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`; pmModal.classList.remove("hidden"); $("pm-accept-btn").onclick = () => { socket.emit("pm_accept", { room: data.room }); - openPMTab(data.from, data.room); + openPMTab(data.from, data.room, data.room_key); pmModal.classList.add("hidden"); }; $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); }); socket.on("pm_ready", (data) => { - openPMTab(data.with, data.room); + openPMTab(data.with, data.room, data.room_key); }); -async function openPMTab(otherUser, room) { +async function openPMTab(otherUser, room, roomKeyB64) { if (state.pms[room]) { switchTab(room); return; } - state.pms[room] = { username: otherUser, messages: [] }; + state.pms[room] = { username: otherUser, messages: [], sharedKey: null }; + + // Import the server-provided room key for user-to-user PMs + const isViolet = otherUser.toLowerCase() === "violet"; + if (!isViolet && roomKeyB64) { + try { + state.pms[room].sharedKey = await SexyChato.importKeyBase64(roomKeyB64); + } catch (err) { + console.error("Failed to import room key", err); + } + } // Create Tab let title = `👤 ${otherUser}`; @@ -231,19 +246,41 @@ async function openPMTab(otherUser, room) { // Load History if registered if (state.isRegistered && state.cryptoKey) { try { - const resp = await fetch(`/api/pm/history?with=${otherUser}`, { + const resp = await fetch(`/api/pm/history?with=${encodeURIComponent(otherUser)}`, { headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` } }); - const data = await resp.json(); - if (data.messages) { - for (const m of data.messages) { - const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce); - addMessage(room, { - sender: m.from_me ? state.username : otherUser, - text: plain, - ts: m.ts, - sent: m.from_me - }); + const histData = await resp.json(); + + // Use room_key from history response if we don't have one yet + if (!isViolet && histData.room_key && !state.pms[room].sharedKey) { + try { + state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key); + } catch (err) { + console.error("Failed to import history room key", err); + } + } + + // Pick the right decryption key: room key for users, personal key for Violet + const decryptKey = isViolet ? state.cryptoKey : (state.pms[room].sharedKey || state.cryptoKey); + + if (histData.messages) { + for (const m of histData.messages) { + try { + const plain = await SexyChato.decrypt(decryptKey, m.ciphertext, m.nonce); + addMessage(room, { + sender: m.from_me ? state.username : otherUser, + text: plain, + ts: m.ts, + sent: m.from_me + }); + } catch (err) { + addMessage(room, { + sender: m.from_me ? state.username : otherUser, + text: "[Could not decrypt message]", + ts: m.ts, + sent: m.from_me + }); + } } } } catch (err) { @@ -255,11 +292,18 @@ async function openPMTab(otherUser, room) { socket.on("pm_message", async (data) => { if (state.ignoredUsers.has(data.from)) return; let text = data.text; - if (data.ciphertext && state.cryptoKey) { - try { - text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); - } catch (err) { - text = "[Encrypted Message - Click to login/derive key]"; + if (data.ciphertext) { + // Pick the right key: room shared key for user-to-user, personal key for Violet + const pm = state.pms[data.room]; + const decryptKey = pm?.sharedKey || state.cryptoKey; + if (decryptKey) { + try { + text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce); + } catch (err) { + text = "[Encrypted Message - Could not decrypt]"; + } + } else { + text = "[Encrypted Message - No key available]"; } } @@ -364,8 +408,10 @@ messageForm.addEventListener("submit", async (e) => { }); } } else if (state.isRegistered && state.cryptoKey) { - // E2E PM Flow - const encrypted = await SexyChato.encrypt(state.cryptoKey, text); + // User-to-user encrypted PM: use the shared room key if available + const pm = state.pms[state.currentRoom]; + const encryptKey = pm?.sharedKey || state.cryptoKey; + const encrypted = await SexyChato.encrypt(encryptKey, text); socket.emit("pm_message", { room: state.currentRoom, ciphertext: encrypted.ciphertext, diff --git a/static/crypto.js b/static/crypto.js index 9a2c080..5ecb1c7 100644 --- a/static/crypto.js +++ b/static/crypto.js @@ -126,6 +126,23 @@ const SexyChato = (() => { return bufToBase64(raw); } + /** + * Import a base64-encoded raw AES-GCM key (e.g. server-provided room key). + * + * @param {string} b64 base64-encoded 256-bit key + * @returns {Promise} + */ + async function importKeyBase64(b64) { + const buf = base64ToBuf(b64); + return crypto.subtle.importKey( + "raw", + buf, + { name: "AES-GCM", length: KEY_LENGTH_BITS }, + true, + ["encrypt", "decrypt"], + ); + } + // ── Public API ───────────────────────────────────────────────────────────── - return { deriveKey, encrypt, decrypt, exportKeyBase64 }; + return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 }; })();