feat: implement ephemeral chat system with end-to-end encryption and room management features
This commit is contained in:
parent
e60b6843f3
commit
4e56f7110d
17 changed files with 722 additions and 53 deletions
1
.secret_key
Normal file
1
.secret_key
Normal file
|
|
@ -0,0 +1 @@
|
|||
be797741c02375fe05a33eb9091f4db6832a5e96c41e1a918609fe91f730b985
|
||||
|
|
@ -110,6 +110,9 @@ def admin_ban_ip():
|
|||
return jsonify({"error": "no ip"}), 400
|
||||
|
||||
banned_ips.add(ip_to_ban)
|
||||
from models.database import db_add_ban
|
||||
db_add_ban("ip", ip_to_ban)
|
||||
|
||||
user_sessions.pop(username, None)
|
||||
|
||||
for room in room_users:
|
||||
|
|
@ -128,6 +131,9 @@ def admin_ban_username():
|
|||
return jsonify({"error": "no username"}), 400
|
||||
|
||||
banned_usernames.add(username)
|
||||
from models.database import db_add_ban
|
||||
db_add_ban("username", username)
|
||||
|
||||
user_sessions.pop(username, None)
|
||||
|
||||
for room in room_users:
|
||||
|
|
@ -146,6 +152,9 @@ def admin_ban_raw_ip():
|
|||
return jsonify({"error": "no ip"}), 400
|
||||
|
||||
banned_ips.add(ip)
|
||||
from models.database import db_add_ban
|
||||
db_add_ban("ip", ip)
|
||||
|
||||
return jsonify({"banned": ip})
|
||||
|
||||
|
||||
|
|
@ -168,6 +177,8 @@ def admin_release_user():
|
|||
|
||||
user_sessions.pop(username, None)
|
||||
user_ips.pop(username, None)
|
||||
from models.database import db_delete_user_ip
|
||||
db_delete_user_ip(username)
|
||||
|
||||
for room in room_users:
|
||||
room_users[room].pop(username, None)
|
||||
|
|
@ -209,9 +220,13 @@ def admin_nuke_room():
|
|||
messages.pop(room, None)
|
||||
room_users.pop(room, None)
|
||||
room_owners.pop(room, None)
|
||||
from models.database import db_delete_room_owner, db_remove_public_room
|
||||
db_delete_room_owner(room)
|
||||
|
||||
video_state.pop(room, None)
|
||||
|
||||
if room in public_chat_rooms:
|
||||
public_chat_rooms.remove(room)
|
||||
db_remove_public_room(room)
|
||||
|
||||
return jsonify({"status": "nuked", "room": room})
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import secrets
|
||||
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
|
||||
|
||||
from models.state import user_sessions, user_ips, banned_ips, banned_usernames
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def generate_user_key(length=16):
|
||||
return secrets.token_urlsafe(16)
|
||||
def generate_user_key(username):
|
||||
serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
|
||||
return serializer.dumps({"u": username})
|
||||
|
||||
|
||||
def verify_user_key(username, token, max_age=30*24*60*60): # 30 days
|
||||
serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
|
||||
try:
|
||||
data = serializer.loads(token, max_age=max_age)
|
||||
return data.get("u") == username
|
||||
except (SignatureExpired, BadSignature):
|
||||
return False
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
|
|
@ -20,20 +30,23 @@ def register():
|
|||
|
||||
if not username:
|
||||
return jsonify({"error": "No username provided"}), 400
|
||||
if username in user_sessions:
|
||||
if username in user_ips: # Check global user_ips to see if name is taken
|
||||
return jsonify({"error": "Username taken"}), 409
|
||||
if user_ip in banned_ips:
|
||||
return jsonify({"error": "IP banned"}), 403
|
||||
if username in banned_usernames:
|
||||
return jsonify({"error": "Username banned"}), 403
|
||||
|
||||
user_key = generate_user_key()
|
||||
user_key = generate_user_key(username)
|
||||
user_sessions[username] = {
|
||||
"key": user_key,
|
||||
"last_active": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
user_ips[username] = user_ip
|
||||
|
||||
from models.database import db_set_user_ip
|
||||
db_set_user_ip(username, user_ip)
|
||||
|
||||
return jsonify({"username": username, "key": user_key})
|
||||
|
||||
|
||||
|
|
@ -43,8 +56,16 @@ def validate():
|
|||
username = data.get("username")
|
||||
key = data.get("key")
|
||||
|
||||
session = user_sessions.get(username)
|
||||
if session and session["key"] == key:
|
||||
session["last_active"] = datetime.now(timezone.utc).isoformat()
|
||||
if not username or not key:
|
||||
return jsonify({"error": "Invalid credentials"}), 403
|
||||
|
||||
if verify_user_key(username, key):
|
||||
# Restore active user session in-memory if lost on restart
|
||||
user_sessions[username] = {
|
||||
"key": key,
|
||||
"last_active": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
return jsonify({"status": "valid"})
|
||||
|
||||
return jsonify({"error": "Invalid credentials"}), 403
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ def chat_room(room):
|
|||
"audio": audio,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if "encrypted" in data:
|
||||
message["encrypted"] = data["encrypted"]
|
||||
if "iv" in data:
|
||||
message["iv"] = data["iv"]
|
||||
if "keyId" in data:
|
||||
message["keyId"] = data["keyId"]
|
||||
|
||||
messages.setdefault(room, []).append(message)
|
||||
if len(messages[room]) > 100:
|
||||
messages[room] = messages[room][-100:]
|
||||
|
|
@ -98,6 +105,8 @@ def room_user_list(room):
|
|||
room_users.setdefault(room, {})
|
||||
if room not in room_owners and username not in room_users[room]:
|
||||
room_owners[room] = username # First user becomes the owner
|
||||
from models.database import db_set_room_owner
|
||||
db_set_room_owner(room, username)
|
||||
|
||||
room_users[room][username] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
|
@ -122,3 +131,67 @@ def kick_user(room):
|
|||
kicked_users.setdefault(room, set()).add(target)
|
||||
room_users.get(room, {}).pop(target, None)
|
||||
return jsonify({"kicked": target})
|
||||
|
||||
|
||||
@chat_bp.route("/chat/<room>/poll", methods=["POST"])
|
||||
def poll_room(room):
|
||||
room = room.strip()
|
||||
data = request.json or {}
|
||||
username = data.get("username")
|
||||
key = data.get("key")
|
||||
since = data.get("since")
|
||||
|
||||
from blueprints.auth import verify_user_key
|
||||
if not username or not key or not verify_user_key(username, key):
|
||||
return jsonify({"error": "Invalid credentials"}), 403
|
||||
|
||||
# Check if room is temporarily locked (nuked)
|
||||
if room in nuked_rooms:
|
||||
if time.time() < nuked_rooms[room]:
|
||||
return jsonify({"error": "Room is unavailable"}), 403
|
||||
else:
|
||||
nuked_rooms.pop(room, None)
|
||||
|
||||
if username in kicked_users.get(room, set()):
|
||||
return jsonify({"error": "Kicked from room"}), 403
|
||||
|
||||
# Update user presence
|
||||
room_users.setdefault(room, {})
|
||||
if room not in room_owners and username not in room_users[room]:
|
||||
room_owners[room] = username
|
||||
from models.database import db_set_room_owner
|
||||
db_set_room_owner(room, username)
|
||||
|
||||
room_users[room][username] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Restore active user session in-memory if lost on restart
|
||||
from models.state import user_sessions
|
||||
user_sessions[username] = {
|
||||
"key": key,
|
||||
"last_active": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
# Fetch messages
|
||||
room_messages = messages.setdefault(room, [])
|
||||
filtered_messages = room_messages
|
||||
if since:
|
||||
try:
|
||||
since_time = datetime.fromisoformat(since)
|
||||
filtered_messages = [
|
||||
m
|
||||
for m in room_messages
|
||||
if datetime.fromisoformat(m["timestamp"]) > since_time
|
||||
]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
users_list = room_users.get(room, {})
|
||||
response = {
|
||||
"messages": filtered_messages,
|
||||
"users": list(users_list.keys())
|
||||
}
|
||||
if room in room_owners:
|
||||
response["owner"] = room_owners[room]
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ def get_or_create_rooms():
|
|||
return jsonify({"error": "Room already exists"}), 400
|
||||
|
||||
public_chat_rooms.append(room_name)
|
||||
from models.database import db_add_public_room
|
||||
db_add_public_room(room_name)
|
||||
return jsonify({"name": room_name})
|
||||
|
||||
active_only = request.args.get("activeOnly") == "true"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ def watchparty_video_control(room):
|
|||
# Ensure it's tracked as a public room
|
||||
if room not in public_chat_rooms:
|
||||
public_chat_rooms.append(room)
|
||||
from models.database import db_add_public_room
|
||||
db_add_public_room(room)
|
||||
|
||||
# Ensure video_state storage is initialized
|
||||
video_state.setdefault(room, {})
|
||||
|
|
|
|||
18
config.py
18
config.py
|
|
@ -1,6 +1,24 @@
|
|||
import os
|
||||
import secrets
|
||||
|
||||
|
||||
class Config:
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
secret_file = os.path.join(os.path.dirname(__file__), ".secret_key")
|
||||
if os.path.exists(secret_file):
|
||||
try:
|
||||
with open(secret_file, "r") as f:
|
||||
SECRET_KEY = f.read().strip()
|
||||
except Exception:
|
||||
SECRET_KEY = "fallback-secret-key-please-change"
|
||||
else:
|
||||
SECRET_KEY = secrets.token_hex(32)
|
||||
try:
|
||||
with open(secret_file, "w") as f:
|
||||
f.write(SECRET_KEY)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ def cleanup_loop():
|
|||
):
|
||||
if room in public_chat_rooms:
|
||||
public_chat_rooms.remove(room)
|
||||
from models.database import db_remove_public_room, db_delete_room_owner
|
||||
db_remove_public_room(room)
|
||||
db_delete_room_owner(room)
|
||||
messages.pop(room, None)
|
||||
room_users.pop(room, None)
|
||||
kicked_users.pop(room, None)
|
||||
|
|
|
|||
125
models/database.py
Normal file
125
models/database.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "transient_chat.db")
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT,
|
||||
value TEXT UNIQUE
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS room_owners (
|
||||
room TEXT PRIMARY KEY,
|
||||
owner TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_ips (
|
||||
username TEXT PRIMARY KEY,
|
||||
ip TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS public_rooms (
|
||||
room TEXT PRIMARY KEY
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def load_persisted_state(state_module):
|
||||
"""Loads database state into the memory-based collections of state_module."""
|
||||
init_db()
|
||||
with get_db_connection() as conn:
|
||||
# Load bans
|
||||
for row in conn.execute("SELECT type, value FROM bans"):
|
||||
if row["type"] == "ip":
|
||||
state_module.banned_ips.add(row["value"])
|
||||
elif row["type"] == "username":
|
||||
state_module.banned_usernames.add(row["value"])
|
||||
|
||||
# Load room owners
|
||||
for row in conn.execute("SELECT room, owner FROM room_owners"):
|
||||
state_module.room_owners[row["room"]] = row["owner"]
|
||||
|
||||
# Load user IPs
|
||||
for row in conn.execute("SELECT username, ip FROM user_ips"):
|
||||
state_module.user_ips[row["username"]] = row["ip"]
|
||||
|
||||
# Load public rooms
|
||||
for row in conn.execute("SELECT room FROM public_rooms"):
|
||||
if row["room"] not in state_module.public_chat_rooms:
|
||||
state_module.public_chat_rooms.append(row["room"])
|
||||
|
||||
def db_add_ban(ban_type, value):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO bans (type, value) VALUES (?, ?)", (ban_type, value))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_add_ban: {e}")
|
||||
|
||||
def db_remove_ban(ban_type, value):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("DELETE FROM bans WHERE type = ? AND value = ?", (ban_type, value))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_remove_ban: {e}")
|
||||
|
||||
def db_set_room_owner(room, owner):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO room_owners (room, owner) VALUES (?, ?)", (room, owner))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_set_room_owner: {e}")
|
||||
|
||||
def db_delete_room_owner(room):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("DELETE FROM room_owners WHERE room = ?", (room,))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_delete_room_owner: {e}")
|
||||
|
||||
def db_set_user_ip(username, ip):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("INSERT OR REPLACE INTO user_ips (username, ip) VALUES (?, ?)", (username, ip))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_set_user_ip: {e}")
|
||||
|
||||
def db_delete_user_ip(username):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("DELETE FROM user_ips WHERE username = ?", (username,))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_delete_user_ip: {e}")
|
||||
|
||||
def db_add_public_room(room):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO public_rooms (room) VALUES (?)", (room,))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_add_public_room: {e}")
|
||||
|
||||
def db_remove_public_room(room):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
conn.execute("DELETE FROM public_rooms WHERE room = ?", (room,))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"DB Error db_remove_public_room: {e}")
|
||||
|
|
@ -115,3 +115,7 @@ topical_chat_rooms = [
|
|||
"Home Automation",
|
||||
"Sustainable Living",
|
||||
]
|
||||
|
||||
import sys
|
||||
from models.database import load_persisted_state
|
||||
load_persisted_state(sys.modules[__name__])
|
||||
|
|
|
|||
|
|
@ -36,6 +36,39 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize E2EE Keyring from sessionStorage
|
||||
const keyringStorageKey = `keyring_${room}`;
|
||||
let storedKeys = [];
|
||||
try {
|
||||
const raw = sessionStorage.getItem(keyringStorageKey);
|
||||
if (raw) {
|
||||
storedKeys = JSON.parse(raw);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Initialize E2EE with stored keys
|
||||
for (const phrase of storedKeys) {
|
||||
await window.addKeyToRing(phrase, room);
|
||||
}
|
||||
|
||||
// Check URL hash for new key
|
||||
const hash = window.location.hash;
|
||||
if (hash && hash.startsWith('#key=')) {
|
||||
const passphrase = decodeURIComponent(hash.substring(5));
|
||||
if (passphrase) {
|
||||
if (!storedKeys.includes(passphrase)) {
|
||||
storedKeys.push(passphrase);
|
||||
sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys));
|
||||
}
|
||||
const keyId = await window.addKeyToRing(passphrase, room);
|
||||
if (keyId) {
|
||||
window.setActiveKey(keyId);
|
||||
}
|
||||
// Clear URL hash securely
|
||||
history.replaceState(null, null, window.location.pathname + window.location.search);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 Security: Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
if (!text) return text;
|
||||
|
|
@ -47,14 +80,20 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
document.getElementById("room-title").innerHTML = `
|
||||
<div class="title-row">
|
||||
<a href="server_list.html">
|
||||
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
|
||||
<a/>
|
||||
Room: ${escapeHtml(room)}
|
||||
</div>
|
||||
`;
|
||||
function updateRoomTitle() {
|
||||
const e2ee = window.isCryptoEnabled && window.isCryptoEnabled();
|
||||
document.getElementById("room-title").innerHTML = `
|
||||
<div class="title-row">
|
||||
<a href="server_list.html">
|
||||
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
|
||||
</a>
|
||||
Room: ${escapeHtml(room)}
|
||||
${e2ee ? '<span style="color: #4caf50; font-size: 0.8em; margin-left: 10px;">🔒 E2EE Active</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateRoomTitle();
|
||||
|
||||
const messagesDiv = document.getElementById("messages");
|
||||
const form = document.getElementById("chat-form");
|
||||
|
|
@ -142,6 +181,104 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
updateExternalToggleBtn();
|
||||
});
|
||||
|
||||
// Manage sidebar keyring list UI
|
||||
const keyringListDiv = document.getElementById("keyring-list");
|
||||
const addKeyBtn = document.getElementById("add-key-btn");
|
||||
|
||||
function renderKeyring() {
|
||||
if (!keyringListDiv) return;
|
||||
keyringListDiv.innerHTML = "";
|
||||
|
||||
const list = window.getKeyring();
|
||||
if (list.length === 0) {
|
||||
keyringListDiv.innerHTML = `<em style="color: #888; font-size: 0.95em;">No active keys. Messages you send will be plaintext.</em>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = window.getActiveKeyId();
|
||||
|
||||
list.forEach(item => {
|
||||
const row = document.createElement("div");
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
row.style.justifyContent = "space-between";
|
||||
row.style.marginBottom = "5px";
|
||||
row.style.padding = "4px";
|
||||
row.style.border = "1px solid #444";
|
||||
row.style.borderRadius = "3px";
|
||||
row.style.background = activeId === item.keyId ? "rgba(76, 175, 80, 0.15)" : "transparent";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.style.display = "flex";
|
||||
label.style.alignItems = "center";
|
||||
label.style.cursor = "pointer";
|
||||
label.style.flex = "1";
|
||||
label.style.overflow = "hidden";
|
||||
label.style.textOverflow = "ellipsis";
|
||||
label.style.whiteSpace = "nowrap";
|
||||
|
||||
const radio = document.createElement("input");
|
||||
radio.type = "radio";
|
||||
radio.name = "active-key";
|
||||
radio.checked = activeId === item.keyId;
|
||||
radio.style.marginRight = "5px";
|
||||
radio.addEventListener("change", () => {
|
||||
window.setActiveKey(item.keyId);
|
||||
renderKeyring();
|
||||
updateRoomTitle();
|
||||
});
|
||||
|
||||
label.appendChild(radio);
|
||||
|
||||
const textSpan = document.createElement("span");
|
||||
textSpan.textContent = `🔑 ${item.passphrase.substring(0, 3)}*** (${item.keyId})`;
|
||||
textSpan.title = `Passphrase: ${item.passphrase} (Key ID: ${item.keyId})`;
|
||||
label.appendChild(textSpan);
|
||||
|
||||
row.appendChild(label);
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.textContent = "❌";
|
||||
delBtn.style.background = "none";
|
||||
delBtn.style.border = "none";
|
||||
delBtn.style.color = "#ff5722";
|
||||
delBtn.style.cursor = "pointer";
|
||||
delBtn.style.padding = "0 5px";
|
||||
delBtn.title = "Remove key";
|
||||
delBtn.onclick = () => {
|
||||
window.removeKeyFromRing(item.keyId);
|
||||
storedKeys = storedKeys.filter(k => k !== item.passphrase);
|
||||
sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys));
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
row.appendChild(delBtn);
|
||||
keyringListDiv.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
if (addKeyBtn) {
|
||||
addKeyBtn.onclick = async () => {
|
||||
const phrase = prompt("Enter a passphrase to add to your E2EE keyring for this room:");
|
||||
if (phrase) {
|
||||
const trimmed = phrase.trim();
|
||||
if (trimmed) {
|
||||
if (!storedKeys.includes(trimmed)) {
|
||||
storedKeys.push(trimmed);
|
||||
sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys));
|
||||
}
|
||||
const keyId = await window.addKeyToRing(trimmed, room);
|
||||
if (keyId) {
|
||||
window.setActiveKey(keyId);
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
renderKeyring();
|
||||
|
||||
autoplayToggleBtn.addEventListener("click", () => {
|
||||
autoplayEnabled = !autoplayEnabled;
|
||||
localStorage.setItem("autoplayEnabled", autoplayEnabled);
|
||||
|
|
@ -179,13 +316,33 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
const blob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
reader.onloadend = async () => {
|
||||
const base64Audio = reader.result;
|
||||
let payload = { username, text: "🎤 Voice Message", audio: base64Audio };
|
||||
|
||||
if (window.isCryptoEnabled && window.isCryptoEnabled()) {
|
||||
try {
|
||||
const clearPayload = { text: "🎤 Voice Message", audio: base64Audio };
|
||||
const encrypted = await window.encryptData(JSON.stringify(clearPayload));
|
||||
payload = {
|
||||
username: username,
|
||||
text: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
keyId: encrypted.keyId,
|
||||
encrypted: true
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Encryption error:", err);
|
||||
alert("Failed to encrypt audio.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Send immediately
|
||||
fetch(`/chat/${encodeURIComponent(room)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, text: "🎤 Voice Message", audio: base64Audio })
|
||||
body: JSON.stringify(payload)
|
||||
}).then(() => refresh());
|
||||
};
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
|
|
@ -385,10 +542,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`);
|
||||
const data = await res.json();
|
||||
|
||||
function renderUsers(data) {
|
||||
const currentList = data.users || [];
|
||||
const isOwner = data.owner === username;
|
||||
usersDiv.innerHTML = "";
|
||||
|
|
@ -418,7 +572,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ owner: username, target: user }),
|
||||
});
|
||||
await loadUsers();
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
div.appendChild(btn);
|
||||
|
|
@ -428,11 +582,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
const url = `/chat/${encodeURIComponent(room)}` + (lastTimestamp ? `?since=${encodeURIComponent(lastTimestamp)}` : "");
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
async function renderMessages(data) {
|
||||
if (!Array.isArray(data)) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
|
@ -445,9 +595,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
return msgTime >= oneHourAgo;
|
||||
});
|
||||
|
||||
newMessages.forEach(msg => {
|
||||
for (const msg of newMessages) {
|
||||
// Skip if already rendered
|
||||
if (seenMessageTimestamps.has(msg.timestamp)) return;
|
||||
if (seenMessageTimestamps.has(msg.timestamp)) continue;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("message");
|
||||
|
|
@ -457,16 +607,71 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
if (msg.username === username) {
|
||||
div.classList.add("my-message");
|
||||
}
|
||||
div.innerHTML = `<strong>${escapeHtml(msg.username)}:</strong> ${linkify(msg.text)}`;
|
||||
|
||||
if (msg.audio) {
|
||||
let isDecryptionFailed = false;
|
||||
let textToRender = msg.text;
|
||||
let audioToRender = msg.audio;
|
||||
|
||||
if (msg.encrypted) {
|
||||
if (window.isCryptoEnabled && window.isCryptoEnabled() && window.getKeyringIds().includes(msg.keyId)) {
|
||||
try {
|
||||
const decryptedJson = await window.decryptData(msg.text, msg.iv, msg.keyId);
|
||||
const parsed = JSON.parse(decryptedJson);
|
||||
textToRender = parsed.text;
|
||||
audioToRender = parsed.audio;
|
||||
} catch (err) {
|
||||
textToRender = "⚠️ Failed to decrypt message (bad key)";
|
||||
audioToRender = null;
|
||||
}
|
||||
} else {
|
||||
textToRender = `🔒 Message encrypted (Key ID: ${msg.keyId}).`;
|
||||
audioToRender = null;
|
||||
isDecryptionFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
div.innerHTML = `<strong>${escapeHtml(msg.username)}:</strong> ${linkify(textToRender)}`;
|
||||
|
||||
if (isDecryptionFailed) {
|
||||
const addKeyLink = document.createElement("a");
|
||||
addKeyLink.href = "#";
|
||||
addKeyLink.textContent = " Enter passphrase to decrypt";
|
||||
addKeyLink.style.color = "#2196f3";
|
||||
addKeyLink.style.textDecoration = "underline";
|
||||
addKeyLink.style.marginLeft = "5px";
|
||||
addKeyLink.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
const phrase = prompt(`Enter the passphrase for Key ID ${msg.keyId}:`);
|
||||
if (phrase) {
|
||||
const trimmed = phrase.trim();
|
||||
if (trimmed) {
|
||||
if (!storedKeys.includes(trimmed)) {
|
||||
storedKeys.push(trimmed);
|
||||
sessionStorage.setItem(keyringStorageKey, JSON.stringify(storedKeys));
|
||||
}
|
||||
const derivedId = await window.addKeyToRing(trimmed, room);
|
||||
if (derivedId) {
|
||||
if (derivedId === msg.keyId) {
|
||||
window.setActiveKey(derivedId);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(`Incorrect passphrase. The entered passphrase generated Key ID: ${derivedId}, but this message requires Key ID: ${msg.keyId}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
div.appendChild(addKeyLink);
|
||||
}
|
||||
|
||||
if (audioToRender) {
|
||||
const audioContainer = document.createElement("div");
|
||||
audioContainer.className = "audio-container";
|
||||
const audio = document.createElement("audio");
|
||||
audio.controls = true;
|
||||
audio.className = "voice-message-audio";
|
||||
|
||||
audio.src = msg.audio;
|
||||
audio.src = audioToRender;
|
||||
|
||||
// Autoplay if enabled, not first load, and not my own message
|
||||
if (autoplayEnabled && !firstLoad && msg.username !== username) {
|
||||
|
|
@ -479,7 +684,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
}
|
||||
|
||||
messagesDiv.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
|
|
@ -496,15 +701,35 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", e => {
|
||||
form.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
let payload = { username, text };
|
||||
|
||||
if (window.isCryptoEnabled && window.isCryptoEnabled()) {
|
||||
try {
|
||||
const clearPayload = { text: text, audio: null };
|
||||
const encrypted = await window.encryptData(JSON.stringify(clearPayload));
|
||||
payload = {
|
||||
username: username,
|
||||
text: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
keyId: encrypted.keyId,
|
||||
encrypted: true
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Encryption error:", err);
|
||||
alert("Failed to encrypt message.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(`/chat/${encodeURIComponent(room)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, text })
|
||||
body: JSON.stringify(payload)
|
||||
}).then(() => {
|
||||
input.value = "";
|
||||
refresh(); // force fetch after post to ensure sync
|
||||
|
|
@ -516,11 +741,14 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// 🔒 Check if user is still valid
|
||||
const res = await fetch('/validate', {
|
||||
const res = await fetch(`/chat/${encodeURIComponent(room)}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, key: userKey })
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
key: userKey,
|
||||
since: lastTimestamp
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
@ -530,9 +758,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
await refreshUserPresence();
|
||||
await loadMessages();
|
||||
await loadUsers();
|
||||
const data = await res.json();
|
||||
renderUsers(data);
|
||||
await renderMessages(data.messages);
|
||||
cleanExpiredMessages();
|
||||
} catch (e) {
|
||||
console.error("Refresh error:", e);
|
||||
|
|
@ -542,7 +770,14 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||
}
|
||||
}
|
||||
|
||||
// First full load, then enable polling
|
||||
await refresh();
|
||||
setInterval(refresh, 1000);
|
||||
// Start smart poll loop instead of setInterval
|
||||
let pollTimeoutId = null;
|
||||
async function pollLoop() {
|
||||
await refresh();
|
||||
const interval = windowFocused ? 1000 : 4000;
|
||||
pollTimeoutId = setTimeout(pollLoop, interval);
|
||||
}
|
||||
|
||||
// Initialize E2EE and begin polling
|
||||
await pollLoop();
|
||||
});
|
||||
151
static/assets/js/crypto.js
Normal file
151
static/assets/js/crypto.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
(function() {
|
||||
let keyring = {}; // { keyId: { passphrase, key } }
|
||||
let activeKeyId = null;
|
||||
|
||||
// Helper: string to array buffer
|
||||
function strToBuf(str) {
|
||||
return new TextEncoder().encode(str);
|
||||
}
|
||||
|
||||
// Helper: array buffer to string
|
||||
function bufToStr(buf) {
|
||||
return new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
// Helper: array buffer to hex string
|
||||
function bufToHex(buf) {
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Helper: hex string to array buffer
|
||||
function hexToBuf(hex) {
|
||||
if (!hex) return new Uint8Array(0).buffer;
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
// Short hash of passphrase to identify the key (non-reversible identifier)
|
||||
async function computeKeyId(passphrase) {
|
||||
const msgUint8 = strToBuf(passphrase);
|
||||
const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 8);
|
||||
}
|
||||
|
||||
// Derive PBKDF2 key from passphrase
|
||||
async function deriveKey(passphrase, roomName) {
|
||||
const passwordBuffer = strToBuf(passphrase);
|
||||
const saltBuffer = strToBuf("transient.chat.salt." + roomName);
|
||||
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
|
||||
return await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: saltBuffer,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
baseKey,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
// Add a passphrase to the keyring
|
||||
window.addKeyToRing = async function(passphrase, roomName) {
|
||||
try {
|
||||
const keyId = await computeKeyId(passphrase);
|
||||
const cryptoKey = await deriveKey(passphrase, roomName);
|
||||
keyring[keyId] = {
|
||||
passphrase: passphrase,
|
||||
key: cryptoKey
|
||||
};
|
||||
if (!activeKeyId) {
|
||||
activeKeyId = keyId;
|
||||
}
|
||||
return keyId;
|
||||
} catch (e) {
|
||||
console.error("Failed to add key to ring:", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
window.removeKeyFromRing = function(keyId) {
|
||||
delete keyring[keyId];
|
||||
if (activeKeyId === keyId) {
|
||||
const remaining = Object.keys(keyring);
|
||||
activeKeyId = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
};
|
||||
|
||||
window.setActiveKey = function(keyId) {
|
||||
if (keyring[keyId]) {
|
||||
activeKeyId = keyId;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
window.getActiveKeyId = function() {
|
||||
return activeKeyId;
|
||||
};
|
||||
|
||||
window.getKeyringIds = function() {
|
||||
return Object.keys(keyring);
|
||||
};
|
||||
|
||||
window.getKeyring = function() {
|
||||
// Return passive list of { keyId, passphrase }
|
||||
const list = [];
|
||||
for (const [id, data] of Object.entries(keyring)) {
|
||||
list.push({ keyId: id, passphrase: data.passphrase });
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
window.isCryptoEnabled = function() {
|
||||
return activeKeyId !== null;
|
||||
};
|
||||
|
||||
window.encryptData = async function(plaintext, keyId = activeKeyId) {
|
||||
if (!keyId || !keyring[keyId]) {
|
||||
throw new Error("Key not found in keyring");
|
||||
}
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: iv },
|
||||
keyring[keyId].key,
|
||||
strToBuf(plaintext)
|
||||
);
|
||||
return {
|
||||
ciphertext: bufToHex(encrypted),
|
||||
iv: bufToHex(iv),
|
||||
keyId: keyId
|
||||
};
|
||||
};
|
||||
|
||||
window.decryptData = async function(ciphertextHex, ivHex, keyId) {
|
||||
if (!keyId || !keyring[keyId]) {
|
||||
throw new Error("Key not found in keyring");
|
||||
}
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: hexToBuf(ivHex) },
|
||||
keyring[keyId].key,
|
||||
hexToBuf(ciphertextHex)
|
||||
);
|
||||
return bufToStr(decrypted);
|
||||
};
|
||||
})();
|
||||
|
|
@ -61,11 +61,16 @@
|
|||
🔒 External Content: Off
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
<div id="encryption-container" style="margin-bottom: 15px; border: 1px solid #444; padding: 10px; border-radius: 5px; background: rgba(33, 150, 243, 0.1); text-align: left;">
|
||||
<strong style="display: block; margin-bottom: 5px; font-size: 0.9em;">🔒 Encryption Keyring</strong>
|
||||
<div id="keyring-list" style="margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;"></div>
|
||||
<button id="add-key-btn" style="width: 100%; background: #2196f3; color: white; border: none; padding: 5px; border-radius: 3px; cursor: pointer; font-size: 0.85em;">+ Add Passphrase</button>
|
||||
</div>
|
||||
<h3>Users in room</h3>
|
||||
<div id="user-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/crypto.js"></script>
|
||||
<script src="/assets/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -40,8 +40,16 @@
|
|||
|
||||
<h2>Is anything stored permanently?</h2>
|
||||
<p>
|
||||
No databases, no disk writes. Everything exists in memory and vanishes
|
||||
when inactive.
|
||||
No databases or disk files are used for chat messages or media—everything exists temporarily in memory and vanishes within an hour. We persist only basic session configuration, ban records, and room owner mappings using a lightweight local database so they survive server updates.
|
||||
</p>
|
||||
|
||||
<h2>Is my chat secure / private?</h2>
|
||||
<p>
|
||||
Yes! We support <strong>Zero-Knowledge End-to-End Encryption (E2EE)</strong>.
|
||||
You can enable encryption for any room by clicking <strong>+ Add Passphrase</strong> in the sidebar and entering a passphrase.
|
||||
Encryption keys are derived locally in your browser using PBKDF2 and AES-GCM.
|
||||
The passphrases reside strictly in your browser's session memory and are <em>never</em> sent to the server.
|
||||
Anyone without the correct passphrase will only see a placeholder indicating the message is encrypted, with a quick link to add the passphrase to their keyring if they know it.
|
||||
</p>
|
||||
|
||||
<h2>What is a Watchroom?</h2>
|
||||
|
|
@ -65,7 +73,7 @@
|
|||
<p>Yes. Room owners can kick users. Admins can ban users by IP.</p>
|
||||
|
||||
<h2>Where can I find the source code?</h2>
|
||||
<p><a href="https://github.com/benjimanmiller/transient.chat">https://github.com/benjimanmiller/transient.chat</a></p>
|
||||
<p><a href="https://forgejo.benjimanmiller.com/ben/transient.chat">https://forgejo.benjimanmiller.com/ben/transient.chat</a></p>
|
||||
|
||||
<br /><br />
|
||||
<a href="index.html">← Back to Home</a>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
Want to sync up a video? Try a <strong>Watchroom</strong> and chat while
|
||||
watching YouTube together.<br /><br />
|
||||
<strong>Voice Messages:</strong> Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.<br /><br />
|
||||
<strong>End-to-End Encryption:</strong> Encrypt your chat rooms locally with passphrases. Keys remain strictly in your browser and are never sent to the server.<br /><br />
|
||||
<strong>No tracking • No storage • 100% ephemeral</strong><br /><br />
|
||||
If you're tired of feeds, filters, or forever — you're in the right
|
||||
place.
|
||||
|
|
@ -53,8 +54,8 @@
|
|||
<p>
|
||||
Don't trust me? I don't blame you. Review the source and host your own
|
||||
server!<br />
|
||||
<a href="https://github.com/benjimanmiller/transient.chat">
|
||||
https://github.com/benjimanmiller/transient.chat
|
||||
<a href="https://forgejo.benjimanmiller.com/ben/transient.chat">
|
||||
https://forgejo.benjimanmiller.com/ben/transient.chat
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -97,12 +97,17 @@
|
|||
>
|
||||
🔒 External Content: Off
|
||||
</button>
|
||||
<br /><br />
|
||||
<div id="encryption-container" style="margin-bottom: 15px; border: 1px solid #444; padding: 10px; border-radius: 5px; background: rgba(33, 150, 243, 0.1); text-align: left;">
|
||||
<strong style="display: block; margin-bottom: 5px; font-size: 0.9em;">🔒 Encryption Keyring</strong>
|
||||
<div id="keyring-list" style="margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;"></div>
|
||||
<button id="add-key-btn" style="width: 100%; background: #2196f3; color: white; border: none; padding: 5px; border-radius: 3px; cursor: pointer; font-size: 0.85em;">+ Add Passphrase</button>
|
||||
</div>
|
||||
<h3>Users in room</h3>
|
||||
<div id="user-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/crypto.js"></script>
|
||||
<script src="/assets/js/chat.js"></script>
|
||||
<script src="/assets/js/watchparty_chat.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
BIN
transient_chat.db
Normal file
BIN
transient_chat.db
Normal file
Binary file not shown.
Loading…
Reference in a new issue