diff --git a/.secret_key b/.secret_key new file mode 100644 index 0000000..b0bdc9f --- /dev/null +++ b/.secret_key @@ -0,0 +1 @@ +be797741c02375fe05a33eb9091f4db6832a5e96c41e1a918609fe91f730b985 \ No newline at end of file diff --git a/blueprints/admin.py b/blueprints/admin.py index 6b1adfc..45fe8fc 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -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}) diff --git a/blueprints/auth.py b/blueprints/auth.py index bb40ba4..01a9d08 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -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,19 +30,22 @@ 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 + diff --git a/blueprints/chat.py b/blueprints/chat.py index 62819ea..a0a7f32 100644 --- a/blueprints/chat.py +++ b/blueprints/chat.py @@ -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//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) + diff --git a/blueprints/rooms.py b/blueprints/rooms.py index a7e5fb3..60d2f99 100644 --- a/blueprints/rooms.py +++ b/blueprints/rooms.py @@ -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" diff --git a/blueprints/watchparty.py b/blueprints/watchparty.py index b44bb51..7a98500 100644 --- a/blueprints/watchparty.py +++ b/blueprints/watchparty.py @@ -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, {}) diff --git a/config.py b/config.py index 1f339fc..ce855ff 100644 --- a/config.py +++ b/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 diff --git a/models/cleanup.py b/models/cleanup.py index 8307448..51116bf 100644 --- a/models/cleanup.py +++ b/models/cleanup.py @@ -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) diff --git a/models/database.py b/models/database.py new file mode 100644 index 0000000..fa049d7 --- /dev/null +++ b/models/database.py @@ -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}") diff --git a/models/state.py b/models/state.py index 9aa8a2b..fbee4f3 100644 --- a/models/state.py +++ b/models/state.py @@ -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__]) diff --git a/static/assets/js/chat.js b/static/assets/js/chat.js index c0df3c0..48404af 100644 --- a/static/assets/js/chat.js +++ b/static/assets/js/chat.js @@ -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 = ` -
- - Transient.chat - - Room: ${escapeHtml(room)} -
- `; + function updateRoomTitle() { + const e2ee = window.isCryptoEnabled && window.isCryptoEnabled(); + document.getElementById("room-title").innerHTML = ` +
+ + Transient.chat + + Room: ${escapeHtml(room)} + ${e2ee ? 'πŸ”’ E2EE Active' : ''} +
+ `; + } + + 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 = `No active keys. Messages you send will be plaintext.`; + 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 = `${escapeHtml(msg.username)}: ${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 = `${escapeHtml(msg.username)}: ${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(); }); \ No newline at end of file diff --git a/static/assets/js/crypto.js b/static/assets/js/crypto.js new file mode 100644 index 0000000..e2a79a6 --- /dev/null +++ b/static/assets/js/crypto.js @@ -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); + }; +})(); diff --git a/static/chat.html b/static/chat.html index 63465a4..ba3be9c 100644 --- a/static/chat.html +++ b/static/chat.html @@ -61,11 +61,16 @@ πŸ”’ External Content: Off
-
+
+ πŸ”’ Encryption Keyring +
+ +

Users in room

+ diff --git a/static/faq.html b/static/faq.html index c9728f7..08279a4 100644 --- a/static/faq.html +++ b/static/faq.html @@ -40,8 +40,16 @@

Is anything stored permanently?

- 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. +

+ +

Is my chat secure / private?

+

+ Yes! We support Zero-Knowledge End-to-End Encryption (E2EE). + You can enable encryption for any room by clicking + Add Passphrase 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 never 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.

What is a Watchroom?

@@ -65,7 +73,7 @@

Yes. Room owners can kick users. Admins can ban users by IP.

Where can I find the source code?

-

https://github.com/benjimanmiller/transient.chat

+

https://forgejo.benjimanmiller.com/ben/transient.chat



← Back to Home diff --git a/static/index.html b/static/index.html index 1e8b5cd..2b65887 100644 --- a/static/index.html +++ b/static/index.html @@ -35,6 +35,7 @@ Want to sync up a video? Try a Watchroom and chat while watching YouTube together.

Voice Messages: Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.

+ End-to-End Encryption: Encrypt your chat rooms locally with passphrases. Keys remain strictly in your browser and are never sent to the server.

No tracking β€’ No storage β€’ 100% ephemeral

If you're tired of feeds, filters, or forever β€” you're in the right place. @@ -53,8 +54,8 @@

Don't trust me? I don't blame you. Review the source and host your own server!
- - https://github.com/benjimanmiller/transient.chat + + https://forgejo.benjimanmiller.com/ben/transient.chat

diff --git a/static/watchparty_chat.html b/static/watchparty_chat.html index 1cf9334..ad10f39 100644 --- a/static/watchparty_chat.html +++ b/static/watchparty_chat.html @@ -97,12 +97,17 @@ > πŸ”’ External Content: Off -

+
+ πŸ”’ Encryption Keyring +
+ +

Users in room

+ diff --git a/transient_chat.db b/transient_chat.db new file mode 100644 index 0000000..16dd33f Binary files /dev/null and b/transient_chat.db differ