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 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})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
12
routes.py
12
routes.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
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, {
|
addMessage(room, {
|
||||||
sender: m.from_me ? state.username : otherUser,
|
sender: m.from_me ? state.username : otherUser,
|
||||||
text: plain,
|
text: plain,
|
||||||
ts: m.ts,
|
ts: m.ts,
|
||||||
sent: m.from_me
|
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) {
|
||||||
|
// 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 {
|
try {
|
||||||
text = await SexyChato.decrypt(state.cryptoKey, data.ciphertext, data.nonce);
|
text = await SexyChato.decrypt(decryptKey, data.ciphertext, data.nonce);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
text = "[Encrypted Message - Click to login/derive key]";
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue