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
This commit is contained in:
3nd3r 2026-04-12 12:54:09 -05:00
parent a0a96addb6
commit cdfbb666b9
4 changed files with 117 additions and 27 deletions

21
app.py
View File

@ -40,6 +40,8 @@ Socket events (server → client)
import os import os
import time import time
import hmac
import hashlib
import functools import functools
from collections import defaultdict from collections import defaultdict
@ -197,6 +199,20 @@ def _pm_room(a: str, b: str) -> str:
return "pm:" + ":".join(sorted([a.lower(), b.lower()])) 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: def _get_nicklist() -> list:
users = [] users = []
for info in connected_users.values(): for info in connected_users.values():
@ -528,10 +544,11 @@ def on_pm_open(data):
room = _pm_room(user["username"], target) room = _pm_room(user["username"], target)
join_room(room) join_room(room)
room_key = _pm_room_key(room)
if target_sid: if target_sid:
pending_pm_invites.setdefault(target_sid, set()).add(room) pending_pm_invites.setdefault(target_sid, set()).add(room)
socketio.emit("pm_invite", {"from": user["username"], "room": room}, to=target_sid) socketio.emit("pm_invite", {"from": user["username"], "room": room, "room_key": room_key}, to=target_sid)
emit("pm_ready", {"with": target, "room": room}) emit("pm_ready", {"with": target, "room": room, "room_key": room_key})

View File

@ -12,8 +12,10 @@ POST /api/payment/success Validate webhook secret, unlock AI, push socke
import os import os
import hmac import hmac
import hashlib
import random import random
import functools import functools
import base64
import bcrypt import bcrypt
from flask import Blueprint, g, jsonify, request from flask import Blueprint, g, jsonify, request
@ -21,7 +23,7 @@ from flask import Blueprint, g, jsonify, request
from database import db from database import db
from models import User, Message from models import User, Message
from config import ( 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, aesgcm_encrypt, aesgcm_decrypt, issue_jwt, verify_jwt,
) )
@ -203,6 +205,13 @@ def pm_history():
if not other: if not other:
return jsonify({"messages": []}) 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 = ( rows = (
Message.query Message.query
.filter( .filter(
@ -218,6 +227,7 @@ def pm_history():
rows.reverse() # return in chronological order rows.reverse() # return in chronological order
return jsonify({ return jsonify({
"room_key": room_key,
"messages": [ "messages": [
{ {
"from_me": m.sender_id == me.id, "from_me": m.sender_id == me.id,

View File

@ -178,30 +178,45 @@ socket.on("message", (data) => {
// ── Private Messaging ───────────────────────────────────────────────────── // ── Private Messaging ─────────────────────────────────────────────────────
socket.on("pm_invite", (data) => { socket.on("pm_invite", async (data) => {
if (state.pms[data.room]) return; // Already accepted 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.`; $("pm-modal-title").innerText = `${data.from} wants to whisper with you privately.`;
pmModal.classList.remove("hidden"); pmModal.classList.remove("hidden");
$("pm-accept-btn").onclick = () => { $("pm-accept-btn").onclick = () => {
socket.emit("pm_accept", { room: data.room }); socket.emit("pm_accept", { room: data.room });
openPMTab(data.from, data.room); openPMTab(data.from, data.room, data.room_key);
pmModal.classList.add("hidden"); pmModal.classList.add("hidden");
}; };
$("pm-decline-btn").onclick = () => pmModal.classList.add("hidden"); $("pm-decline-btn").onclick = () => pmModal.classList.add("hidden");
}); });
socket.on("pm_ready", (data) => { 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]) { if (state.pms[room]) {
switchTab(room); switchTab(room);
return; 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 // Create Tab
let title = `👤 ${otherUser}`; let title = `👤 ${otherUser}`;
@ -231,19 +246,41 @@ async function openPMTab(otherUser, room) {
// Load History if registered // Load History if registered
if (state.isRegistered && state.cryptoKey) { if (state.isRegistered && state.cryptoKey) {
try { 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")}` } headers: { "Authorization": `Bearer ${localStorage.getItem("sexychat_token")}` }
}); });
const data = await resp.json(); const histData = await resp.json();
if (data.messages) {
for (const m of data.messages) { // Use room_key from history response if we don't have one yet
const plain = await SexyChato.decrypt(state.cryptoKey, m.ciphertext, m.nonce); if (!isViolet && histData.room_key && !state.pms[room].sharedKey) {
addMessage(room, { try {
sender: m.from_me ? state.username : otherUser, state.pms[room].sharedKey = await SexyChato.importKeyBase64(histData.room_key);
text: plain, } catch (err) {
ts: m.ts, console.error("Failed to import history room key", err);
sent: m.from_me }
}); }
// 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) { } catch (err) {
@ -255,11 +292,18 @@ async function openPMTab(otherUser, room) {
socket.on("pm_message", async (data) => { socket.on("pm_message", async (data) => {
if (state.ignoredUsers.has(data.from)) return; if (state.ignoredUsers.has(data.from)) return;
let text = data.text; let text = data.text;
if (data.ciphertext && state.cryptoKey) { if (data.ciphertext) {
try { // Pick the right key: room shared key for user-to-user, personal key for Violet
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce); const pm = state.pms[data.room];
} catch (err) { const decryptKey = pm?.sharedKey || state.cryptoKey;
text = "[Encrypted Message - Click to login/derive key]"; 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) { } else if (state.isRegistered && state.cryptoKey) {
// E2E PM Flow // User-to-user encrypted PM: use the shared room key if available
const encrypted = await SexyChato.encrypt(state.cryptoKey, text); const pm = state.pms[state.currentRoom];
const encryptKey = pm?.sharedKey || state.cryptoKey;
const encrypted = await SexyChato.encrypt(encryptKey, text);
socket.emit("pm_message", { socket.emit("pm_message", {
room: state.currentRoom, room: state.currentRoom,
ciphertext: encrypted.ciphertext, ciphertext: encrypted.ciphertext,

View File

@ -126,6 +126,23 @@ const SexyChato = (() => {
return bufToBase64(raw); 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<CryptoKey>}
*/
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 ───────────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
return { deriveKey, encrypt, decrypt, exportKeyBase64 }; return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 };
})(); })();