feat: implement ephemeral chat system with end-to-end encryption and room management features

This commit is contained in:
Ben Miller 2026-06-23 11:43:52 -05:00
parent e60b6843f3
commit 4e56f7110d
17 changed files with 722 additions and 53 deletions

1
.secret_key Normal file
View file

@ -0,0 +1 @@
be797741c02375fe05a33eb9091f4db6832a5e96c41e1a918609fe91f730b985

View file

@ -110,6 +110,9 @@ def admin_ban_ip():
return jsonify({"error": "no ip"}), 400 return jsonify({"error": "no ip"}), 400
banned_ips.add(ip_to_ban) 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) user_sessions.pop(username, None)
for room in room_users: for room in room_users:
@ -128,6 +131,9 @@ def admin_ban_username():
return jsonify({"error": "no username"}), 400 return jsonify({"error": "no username"}), 400
banned_usernames.add(username) banned_usernames.add(username)
from models.database import db_add_ban
db_add_ban("username", username)
user_sessions.pop(username, None) user_sessions.pop(username, None)
for room in room_users: for room in room_users:
@ -146,6 +152,9 @@ def admin_ban_raw_ip():
return jsonify({"error": "no ip"}), 400 return jsonify({"error": "no ip"}), 400
banned_ips.add(ip) banned_ips.add(ip)
from models.database import db_add_ban
db_add_ban("ip", ip)
return jsonify({"banned": ip}) return jsonify({"banned": ip})
@ -168,6 +177,8 @@ def admin_release_user():
user_sessions.pop(username, None) user_sessions.pop(username, None)
user_ips.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: for room in room_users:
room_users[room].pop(username, None) room_users[room].pop(username, None)
@ -209,9 +220,13 @@ def admin_nuke_room():
messages.pop(room, None) messages.pop(room, None)
room_users.pop(room, None) room_users.pop(room, None)
room_owners.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) video_state.pop(room, None)
if room in public_chat_rooms: if room in public_chat_rooms:
public_chat_rooms.remove(room) public_chat_rooms.remove(room)
db_remove_public_room(room)
return jsonify({"status": "nuked", "room": room}) return jsonify({"status": "nuked", "room": room})

View file

@ -1,15 +1,25 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, current_app
from datetime import datetime, timezone from datetime import datetime, timezone
import os import os
import secrets from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from models.state import user_sessions, user_ips, banned_ips, banned_usernames from models.state import user_sessions, user_ips, banned_ips, banned_usernames
auth_bp = Blueprint("auth", __name__) auth_bp = Blueprint("auth", __name__)
def generate_user_key(length=16): def generate_user_key(username):
return secrets.token_urlsafe(16) 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"]) @auth_bp.route("/register", methods=["POST"])
@ -20,19 +30,22 @@ def register():
if not username: if not username:
return jsonify({"error": "No username provided"}), 400 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 return jsonify({"error": "Username taken"}), 409
if user_ip in banned_ips: if user_ip in banned_ips:
return jsonify({"error": "IP banned"}), 403 return jsonify({"error": "IP banned"}), 403
if username in banned_usernames: if username in banned_usernames:
return jsonify({"error": "Username banned"}), 403 return jsonify({"error": "Username banned"}), 403
user_key = generate_user_key() user_key = generate_user_key(username)
user_sessions[username] = { user_sessions[username] = {
"key": user_key, "key": user_key,
"last_active": datetime.now(timezone.utc).isoformat(), "last_active": datetime.now(timezone.utc).isoformat(),
} }
user_ips[username] = user_ip 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}) return jsonify({"username": username, "key": user_key})
@ -43,8 +56,16 @@ def validate():
username = data.get("username") username = data.get("username")
key = data.get("key") key = data.get("key")
session = user_sessions.get(username) if not username or not key:
if session and session["key"] == key: return jsonify({"error": "Invalid credentials"}), 403
session["last_active"] = datetime.now(timezone.utc).isoformat()
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({"status": "valid"})
return jsonify({"error": "Invalid credentials"}), 403 return jsonify({"error": "Invalid credentials"}), 403

View file

@ -53,6 +53,13 @@ def chat_room(room):
"audio": audio, "audio": audio,
"timestamp": datetime.now(timezone.utc).isoformat(), "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) messages.setdefault(room, []).append(message)
if len(messages[room]) > 100: if len(messages[room]) > 100:
messages[room] = messages[room][-100:] messages[room] = messages[room][-100:]
@ -98,6 +105,8 @@ def room_user_list(room):
room_users.setdefault(room, {}) room_users.setdefault(room, {})
if room not in room_owners and username not in room_users[room]: if room not in room_owners and username not in room_users[room]:
room_owners[room] = username # First user becomes the owner 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() room_users[room][username] = datetime.now(timezone.utc).isoformat()
@ -122,3 +131,67 @@ def kick_user(room):
kicked_users.setdefault(room, set()).add(target) kicked_users.setdefault(room, set()).add(target)
room_users.get(room, {}).pop(target, None) room_users.get(room, {}).pop(target, None)
return jsonify({"kicked": target}) 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)

View file

@ -25,6 +25,8 @@ def get_or_create_rooms():
return jsonify({"error": "Room already exists"}), 400 return jsonify({"error": "Room already exists"}), 400
public_chat_rooms.append(room_name) 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}) return jsonify({"name": room_name})
active_only = request.args.get("activeOnly") == "true" active_only = request.args.get("activeOnly") == "true"

View file

@ -13,6 +13,8 @@ def watchparty_video_control(room):
# Ensure it's tracked as a public room # Ensure it's tracked as a public room
if room not in public_chat_rooms: if room not in public_chat_rooms:
public_chat_rooms.append(room) public_chat_rooms.append(room)
from models.database import db_add_public_room
db_add_public_room(room)
# Ensure video_state storage is initialized # Ensure video_state storage is initialized
video_state.setdefault(room, {}) video_state.setdefault(room, {})

View file

@ -1,6 +1,24 @@
import os import os
import secrets
class Config: class Config:
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME") ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") 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

View file

@ -40,6 +40,9 @@ def cleanup_loop():
): ):
if room in public_chat_rooms: if room in public_chat_rooms:
public_chat_rooms.remove(room) 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) messages.pop(room, None)
room_users.pop(room, None) room_users.pop(room, None)
kicked_users.pop(room, None) kicked_users.pop(room, None)

125
models/database.py Normal file
View 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}")

View file

@ -115,3 +115,7 @@ topical_chat_rooms = [
"Home Automation", "Home Automation",
"Sustainable Living", "Sustainable Living",
] ]
import sys
from models.database import load_persisted_state
load_persisted_state(sys.modules[__name__])

View file

@ -36,6 +36,39 @@ document.addEventListener("DOMContentLoaded", async () => {
return; 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 // 🔒 Security: Escape HTML to prevent XSS
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return text; if (!text) return text;
@ -47,14 +80,20 @@ document.addEventListener("DOMContentLoaded", async () => {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
document.getElementById("room-title").innerHTML = ` function updateRoomTitle() {
<div class="title-row"> const e2ee = window.isCryptoEnabled && window.isCryptoEnabled();
<a href="server_list.html"> document.getElementById("room-title").innerHTML = `
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/> <div class="title-row">
<a/> <a href="server_list.html">
Room: ${escapeHtml(room)} <img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
</div> </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 messagesDiv = document.getElementById("messages");
const form = document.getElementById("chat-form"); const form = document.getElementById("chat-form");
@ -142,6 +181,104 @@ document.addEventListener("DOMContentLoaded", async () => {
updateExternalToggleBtn(); 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", () => { autoplayToggleBtn.addEventListener("click", () => {
autoplayEnabled = !autoplayEnabled; autoplayEnabled = !autoplayEnabled;
localStorage.setItem("autoplayEnabled", autoplayEnabled); localStorage.setItem("autoplayEnabled", autoplayEnabled);
@ -179,13 +316,33 @@ document.addEventListener("DOMContentLoaded", async () => {
const blob = new Blob(audioChunks, { type: 'audio/webm' }); const blob = new Blob(audioChunks, { type: 'audio/webm' });
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
reader.onloadend = () => { reader.onloadend = async () => {
const base64Audio = reader.result; 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 // Send immediately
fetch(`/chat/${encodeURIComponent(room)}`, { fetch(`/chat/${encodeURIComponent(room)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, text: "🎤 Voice Message", audio: base64Audio }) body: JSON.stringify(payload)
}).then(() => refresh()); }).then(() => refresh());
}; };
stream.getTracks().forEach(t => t.stop()); stream.getTracks().forEach(t => t.stop());
@ -385,10 +542,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
async function loadUsers() { function renderUsers(data) {
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`);
const data = await res.json();
const currentList = data.users || []; const currentList = data.users || [];
const isOwner = data.owner === username; const isOwner = data.owner === username;
usersDiv.innerHTML = ""; usersDiv.innerHTML = "";
@ -418,7 +572,7 @@ document.addEventListener("DOMContentLoaded", async () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ owner: username, target: user }), body: JSON.stringify({ owner: username, target: user }),
}); });
await loadUsers(); refresh();
} }
}; };
div.appendChild(btn); div.appendChild(btn);
@ -428,11 +582,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
} }
async function loadMessages() { async function renderMessages(data) {
const url = `/chat/${encodeURIComponent(room)}` + (lastTimestamp ? `?since=${encodeURIComponent(lastTimestamp)}` : "");
const res = await fetch(url);
const data = await res.json();
if (!Array.isArray(data)) return; if (!Array.isArray(data)) return;
const now = Date.now(); const now = Date.now();
@ -445,9 +595,9 @@ document.addEventListener("DOMContentLoaded", async () => {
return msgTime >= oneHourAgo; return msgTime >= oneHourAgo;
}); });
newMessages.forEach(msg => { for (const msg of newMessages) {
// Skip if already rendered // Skip if already rendered
if (seenMessageTimestamps.has(msg.timestamp)) return; if (seenMessageTimestamps.has(msg.timestamp)) continue;
const div = document.createElement("div"); const div = document.createElement("div");
div.classList.add("message"); div.classList.add("message");
@ -457,16 +607,71 @@ document.addEventListener("DOMContentLoaded", async () => {
if (msg.username === username) { if (msg.username === username) {
div.classList.add("my-message"); 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"); const audioContainer = document.createElement("div");
audioContainer.className = "audio-container"; audioContainer.className = "audio-container";
const audio = document.createElement("audio"); const audio = document.createElement("audio");
audio.controls = true; audio.controls = true;
audio.className = "voice-message-audio"; audio.className = "voice-message-audio";
audio.src = msg.audio; audio.src = audioToRender;
// Autoplay if enabled, not first load, and not my own message // Autoplay if enabled, not first load, and not my own message
if (autoplayEnabled && !firstLoad && msg.username !== username) { if (autoplayEnabled && !firstLoad && msg.username !== username) {
@ -479,7 +684,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
messagesDiv.appendChild(div); messagesDiv.appendChild(div);
}); }
if (newMessages.length > 0) { if (newMessages.length > 0) {
messagesDiv.scrollTop = messagesDiv.scrollHeight; messagesDiv.scrollTop = messagesDiv.scrollHeight;
@ -496,15 +701,35 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
} }
form.addEventListener("submit", e => { form.addEventListener("submit", async e => {
e.preventDefault(); e.preventDefault();
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; 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)}`, { fetch(`/chat/${encodeURIComponent(room)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, text }) body: JSON.stringify(payload)
}).then(() => { }).then(() => {
input.value = ""; input.value = "";
refresh(); // force fetch after post to ensure sync refresh(); // force fetch after post to ensure sync
@ -516,11 +741,14 @@ document.addEventListener("DOMContentLoaded", async () => {
isRefreshing = true; isRefreshing = true;
try { try {
// 🔒 Check if user is still valid const res = await fetch(`/chat/${encodeURIComponent(room)}/poll`, {
const res = await fetch('/validate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, key: userKey }) body: JSON.stringify({
username: username,
key: userKey,
since: lastTimestamp
})
}); });
if (!res.ok) { if (!res.ok) {
@ -530,9 +758,9 @@ document.addEventListener("DOMContentLoaded", async () => {
return; return;
} }
await refreshUserPresence(); const data = await res.json();
await loadMessages(); renderUsers(data);
await loadUsers(); await renderMessages(data.messages);
cleanExpiredMessages(); cleanExpiredMessages();
} catch (e) { } catch (e) {
console.error("Refresh error:", e); console.error("Refresh error:", e);
@ -542,7 +770,14 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
} }
// First full load, then enable polling // Start smart poll loop instead of setInterval
await refresh(); let pollTimeoutId = null;
setInterval(refresh, 1000); 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
View 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);
};
})();

View file

@ -61,11 +61,16 @@
🔒 External Content: Off 🔒 External Content: Off
</button> </button>
<br /> <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> <h3>Users in room</h3>
<div id="user-list"></div> <div id="user-list"></div>
</div> </div>
</div> </div>
<script src="/assets/js/crypto.js"></script>
<script src="/assets/js/chat.js"></script> <script src="/assets/js/chat.js"></script>
</body> </body>
</html> </html>

View file

@ -40,8 +40,16 @@
<h2>Is anything stored permanently?</h2> <h2>Is anything stored permanently?</h2>
<p> <p>
No databases, no disk writes. Everything exists in memory and vanishes 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.
when inactive. </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> </p>
<h2>What is a Watchroom?</h2> <h2>What is a Watchroom?</h2>
@ -65,7 +73,7 @@
<p>Yes. Room owners can kick users. Admins can ban users by IP.</p> <p>Yes. Room owners can kick users. Admins can ban users by IP.</p>
<h2>Where can I find the source code?</h2> <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 /> <br /><br />
<a href="index.html">← Back to Home</a> <a href="index.html">← Back to Home</a>

View file

@ -35,6 +35,7 @@
Want to sync up a video? Try a <strong>Watchroom</strong> and chat while Want to sync up a video? Try a <strong>Watchroom</strong> and chat while
watching YouTube together.<br /><br /> 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>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 /> <strong>No tracking • No storage • 100% ephemeral</strong><br /><br />
If you're tired of feeds, filters, or forever — you're in the right If you're tired of feeds, filters, or forever — you're in the right
place. place.
@ -53,8 +54,8 @@
<p> <p>
Don't trust me? I don't blame you. Review the source and host your own Don't trust me? I don't blame you. Review the source and host your own
server!<br /> server!<br />
<a href="https://github.com/benjimanmiller/transient.chat"> <a href="https://forgejo.benjimanmiller.com/ben/transient.chat">
https://github.com/benjimanmiller/transient.chat https://forgejo.benjimanmiller.com/ben/transient.chat
</a> </a>
</p> </p>
</div> </div>

View file

@ -97,12 +97,17 @@
> >
🔒 External Content: Off 🔒 External Content: Off
</button> </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> <h3>Users in room</h3>
<div id="user-list"></div> <div id="user-list"></div>
</div> </div>
</div> </div>
<script src="/assets/js/crypto.js"></script>
<script src="/assets/js/chat.js"></script> <script src="/assets/js/chat.js"></script>
<script src="/assets/js/watchparty_chat.js"></script> <script src="/assets/js/watchparty_chat.js"></script>
</body> </body>

BIN
transient_chat.db Normal file

Binary file not shown.