forked from ComputerTech/aprhodite
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:
parent
a0a96addb6
commit
cdfbb666b9
21
app.py
21
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})
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
12
routes.py
12
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue