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 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})

View File

@ -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,

View File

@ -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,

View File

@ -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<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 ─────────────────────────────────────────────────────────────
return { deriveKey, encrypt, decrypt, exportKeyBase64 };
return { deriveKey, encrypt, decrypt, exportKeyBase64, importKeyBase64 };
})();