From ee0d68d8d330a65feed667418c31e89ea2e02879 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:10:32 +0200 Subject: [PATCH] feat(chat): add client encryption and key lifecycle --- extension/background.js | 115 ++++++++++++++++++++++++++++++--- extension/chat-crypto.js | 112 ++++++++++++++++++++++++++++++++ extension/chat-crypto.test.mjs | 64 ++++++++++++++++++ extension/chat-wire.js | 11 ++++ extension/chat-wire.test.mjs | 21 ++++++ extension/popup.js | 105 +++++++++++++++--------------- vitest.config.mjs | 4 +- 7 files changed, 371 insertions(+), 61 deletions(-) create mode 100644 extension/chat-crypto.js create mode 100644 extension/chat-crypto.test.mjs create mode 100644 extension/chat-wire.js create mode 100644 extension/chat-wire.test.mjs diff --git a/extension/background.js b/extension/background.js index aa1a32f..0c7155c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,6 +4,8 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; +import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; +import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -79,6 +81,7 @@ let hostPeerId = null; // peerId of the room host (creator / // Features the connected relay advertises in ROOM_DATA. Empty against an older // relay (no capabilities field) → host-control UI/behavior stays unavailable. let serverCapabilities = []; +let chatSecretGuard = ''; function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } // Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed // in heartbeats so the host's popup UI can show "Solo" instead of silently @@ -364,7 +367,7 @@ async function getSettings() { // (username) must NEVER come from storage.sync — syncing them across devices // both leaks them and resurrects dead rooms on reinstall (a fresh install // has empty local storage but sync survives in the user's Google account). - const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']); + const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']); let username = data.username; if (!username) { username = generateUsername(); @@ -372,11 +375,14 @@ async function getSettings() { } const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode); const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode); + const chatKey = validateChatSecret(data.chatKey); + chatSecretGuard = chatKey; return { serverUrl: data.serverUrl || '', useCustomServer: data.useCustomServer || false, roomId: data.roomId || '', password: data.password || '', + chatKey, username, sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode), mediaTitlePrivacyMode @@ -412,7 +418,7 @@ function emitEpisodeLobbyForCurrentPrivacy() { // removes legacy keys that older versions wrote to sync (and that would // otherwise be redistributed across devices and resurrected on reinstall). const LEGACY_SYNC_KEYS = [ - 'serverUrl', 'useCustomServer', 'roomId', 'password', 'username', + 'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode' @@ -558,7 +564,9 @@ async function leaveRoomAfterIdleGrace(reason) { episodeLobby: null, hcmDesynced: false }).catch(() => {}); - await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {}); + chatSecretGuard = ''; + clearChatKeyCache(); + await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog(reason, 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); updateBadgeStatus(); @@ -686,7 +694,7 @@ async function connect() { try { const payload = JSON.parse(msg.substring(2)); try { - handleServerEvent(payload[0], payload[1]); + await handleServerEvent(payload[0], payload[1]); } catch (handlerErr) { addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error'); } @@ -880,10 +888,14 @@ function scheduleReconnect() { function emit(event, data) { if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) { - const msg = `42${JSON.stringify([event, data])}`; try { + const msg = encodeSocketEvent(event, data, chatSecretGuard); socket.send(msg); } catch (e) { + if (e.message === 'Refusing to send chat secret to relay') { + addLog(e.message, 'error'); + return; + } // The socket can close between the readyState check and send() // (race with a server-side disconnect). Re-queue so the event is // retried on the next successful (re)connect instead of being lost. @@ -895,6 +907,17 @@ function emit(event, data) { } } +function emitLive(event, data) { + if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false; + try { + socket.send(encodeSocketEvent(event, data, chatSecretGuard)); + return true; + } catch (e) { + addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error'); + return false; + } +} + function queueEvent(event, data) { eventQueue.push({ event, data }); if (eventQueue.length > 50) { @@ -1000,7 +1023,7 @@ function stopPing() { } // --- Event Handlers --- -function handleServerEvent(event, data) { +async function handleServerEvent(event, data) { if (!data) { addLog(`Ignored server event ${event} due to empty payload`, 'warn'); return; @@ -1103,6 +1126,38 @@ function handleServerEvent(event, data) { case EVENTS.ROOM_LIST: chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {}); break; + case EVENTS.CHAT_MESSAGE: { + if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break; + const settings = await getSettings(); + if (!settings.chatKey) break; + try { + const text = await decryptChatMessage({ + ciphertext: data.ciphertext, + roomId: currentRoom.roomId, + senderId: data.senderId, + secret: settings.chatKey + }); + const senderPeer = currentRoom.peers?.find(candidate => + (typeof candidate === 'object' ? candidate.peerId : candidate) === data.senderId + ); + const tabId = Number(currentTabId); + if (Number.isInteger(tabId)) { + chrome.tabs.sendMessage(tabId, { + type: 'CHAT_MESSAGE', + message: { + id: data.id, + senderId: data.senderId, + username: typeof senderPeer === 'object' ? senderPeer.username : null, + timestamp: data.timestamp, + text + } + }).catch(() => {}); + } + } catch (_) { + addLog('Discarded chat message that failed authentication', 'warn'); + } + break; + } case EVENTS.ERROR: isConnecting = false; // If we get a server error before successfully joining a room, @@ -1851,6 +1906,7 @@ function leaveOldRoomIfSwitching(newRoomId) { hostPeerId = null; controllers = []; serverCapabilities = []; + clearChatKeyCache(); hcmDesynced = false; // Notify content.js/popup so they drop any guest-side HCM state from the // previous room (badge/dialog/desync) — H-2/H-3. @@ -1973,8 +2029,43 @@ async function handleAsyncMessage(message, sender, sendResponse) { amHost: amHost(), amController: amController(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), - coHostSupported: serverSupports(CAPABILITIES.CO_HOST) + coHostSupported: serverSupports(CAPABILITIES.CO_HOST), + chatSupported: serverSupports(CAPABILITIES.CHAT), + hasChatKey: !!(await getSettings()).chatKey }); + } else if (message.type === 'CHAT_SEND') { + const senderTabId = sender.tab?.id; + if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { + sendResponse({ status: 'invalid_tab' }); + return; + } + if (!serverSupports(CAPABILITIES.CHAT)) { + sendResponse({ status: 'unsupported' }); + return; + } + const settings = await getSettings(); + if (!settings.chatKey) { + sendResponse({ status: 'missing_key' }); + return; + } + try { + const ciphertext = await encryptChatMessage({ + text: message.text, + roomId: currentRoom.roomId, + senderId: peerId, + secret: settings.chatKey + }); + const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext)); + sendResponse({ status: sent ? 'ok' : 'disconnected' }); + } catch (err) { + sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' }); + } + } else if (message.type === 'CREATE_CHAT_KEY') { + const chatKey = generateChatSecret(); + chatSecretGuard = chatKey; + clearChatKeyCache(); + await chrome.storage.local.set({ chatKey }); + sendResponse({ status: 'ok', chatKey }); } else if (message.type === 'SET_CONTROL_MODE') { // Popup (host) toggles the room control mode. Server validates host authority // and broadcasts CONTROL_MODE back, which updates our local state + UI. @@ -2087,7 +2178,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { expectedAcksCount: 0, hcmDesynced: false }); - chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {}); + chatSecretGuard = ''; + clearChatKeyCache(); + chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); forceDisconnect(); @@ -2103,8 +2196,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { emit(EVENTS.GET_ROOMS, {}); sendResponse({ status: 'ok' }); } else if (message.type === 'WEB_JOIN_REQUEST') { - const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message; + const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message; const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : ''; + const chatKey = validateChatSecret(rawChatKey); if (!roomId) { const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' }; chrome.runtime.sendMessage(errMsg).catch(() => {}); @@ -2118,9 +2212,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { chrome.storage.local.set({ roomId, password, + chatKey, useCustomServer: !!useCustomServer, serverUrl: serverUrl || '' }, async () => { + chatSecretGuard = chatKey; + clearChatKeyCache(); const settings = await getSettings(); const desiredUrl = resolveServerUrl(settings); diff --git a/extension/chat-crypto.js b/extension/chat-crypto.js new file mode 100644 index 0000000..c06443d --- /dev/null +++ b/extension/chat-crypto.js @@ -0,0 +1,112 @@ +const CHAT_INFO = 'KoalaSync E2E Chat v1'; +const SECRET_BYTES = 16; +const IV_BYTES = 12; +const MIN_ENCRYPTED_BYTES = 29; +const MAX_ENCRYPTED_BYTES = 2028; + +let cachedKey = null; +let cachedRoomId = ''; +let cachedSecret = ''; + +function bytesToBase64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function base64UrlToBytes(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null; + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); + try { + const binary = globalThis.atob(padded); + const bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); + return bytesToBase64Url(bytes) === value ? bytes : null; + } catch (_) { + return null; + } +} + +export function generateChatSecret(cryptoImpl = globalThis.crypto) { + const bytes = new Uint8Array(SECRET_BYTES); + cryptoImpl.getRandomValues(bytes); + return bytesToBase64Url(bytes); +} + +export function validateChatSecret(value) { + const bytes = base64UrlToBytes(value); + return bytes?.length === SECRET_BYTES ? value : ''; +} + +export function countCodePoints(value) { + return [...String(value ?? '')].length; +} + +export function normalizeOutgoingChatText(value) { + if (typeof value !== 'string') throw new TypeError('Chat message must be text'); + const text = value.trim(); + if (!text) throw new TypeError('Chat message must not be empty'); + if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points'); + return text; +} + +export function clearChatKeyCache() { + cachedKey = null; + cachedRoomId = ''; + cachedSecret = ''; +} + +export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) { + const validSecret = validateChatSecret(secret); + if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required'); + if (cachedKey && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKey; + + const encoder = new globalThis.TextEncoder(); + const material = await cryptoImpl.subtle.importKey( + 'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey'] + ); + cachedKey = await cryptoImpl.subtle.deriveKey({ + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode(roomId), + info: encoder.encode(CHAT_INFO) + }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + cachedRoomId = roomId; + cachedSecret = validSecret; + return cachedKey; +} + +export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) { + const plaintext = normalizeOutgoingChatText(text); + if (!senderId) throw new TypeError('senderId is required'); + const key = await deriveChatKey(roomId, secret, cryptoImpl); + const encoder = new globalThis.TextEncoder(); + const iv = new Uint8Array(IV_BYTES); + cryptoImpl.getRandomValues(iv); + const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({ + name: 'AES-GCM', + iv, + additionalData: encoder.encode(`${roomId}|${senderId}`) + }, key, encoder.encode(plaintext))); + const envelope = new Uint8Array(iv.length + encrypted.length); + envelope.set(iv); + envelope.set(encrypted, iv.length); + return bytesToBase64Url(envelope); +} + +export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) { + if (!senderId) throw new TypeError('senderId is required'); + const bytes = base64UrlToBytes(ciphertext); + if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) { + throw new TypeError('Invalid chat ciphertext'); + } + const key = await deriveChatKey(roomId, secret, cryptoImpl); + const encoder = new globalThis.TextEncoder(); + const plaintext = await cryptoImpl.subtle.decrypt({ + name: 'AES-GCM', + iv: bytes.slice(0, IV_BYTES), + additionalData: encoder.encode(`${roomId}|${senderId}`) + }, key, bytes.slice(IV_BYTES)); + const text = new globalThis.TextDecoder().decode(plaintext); + if (!text || countCodePoints(text) > 500) throw new RangeError('Decrypted chat message exceeds client limits'); + return text; +} diff --git a/extension/chat-crypto.test.mjs b/extension/chat-crypto.test.mjs new file mode 100644 index 0000000..e5c6caa --- /dev/null +++ b/extension/chat-crypto.test.mjs @@ -0,0 +1,64 @@ +import { webcrypto } from 'node:crypto'; +import { Buffer } from 'node:buffer'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearChatKeyCache, + countCodePoints, + decryptChatMessage, + deriveChatKey, + encryptChatMessage, + generateChatSecret, + normalizeOutgoingChatText, + validateChatSecret +} from './chat-crypto.js'; + +afterEach(clearChatKeyCache); + +describe('chat crypto', () => { + it('generates canonical 128-bit secrets', () => { + const secret = generateChatSecret(webcrypto); + expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(validateChatSecret(secret)).toBe(secret); + expect(validateChatSecret(`${secret}=`)).toBe(''); + }); + + it('round-trips Unicode with a cached room key and fresh IVs', async () => { + const secret = generateChatSecret(webcrypto); + const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto); + const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto); + expect(secondKey).toBe(firstKey); + + const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret }; + const first = await encryptChatMessage(input, webcrypto); + const second = await encryptChatMessage(input, webcrypto); + expect(second).not.toBe(first); + await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text); + }); + + it('rejects relabeling, cross-room replay, and the wrong secret', async () => { + const secret = generateChatSecret(webcrypto); + const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto); + clearChatKeyCache(); + await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow(); + clearChatKeyCache(); + await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow(); + clearChatKeyCache(); + await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow(); + }); + + it('enforces 500 Unicode code points before encryption', () => { + expect(countCodePoints('😀'.repeat(500))).toBe(500); + expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500)); + expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError); + expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError); + }); + + it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => { + const secret = generateChatSecret(webcrypto); + const ciphertext = await encryptChatMessage({ + text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret + }, webcrypto); + const bytes = Buffer.from(ciphertext, 'base64url'); + expect(bytes).toHaveLength(2028); + }); +}); diff --git a/extension/chat-wire.js b/extension/chat-wire.js new file mode 100644 index 0000000..94b13d1 --- /dev/null +++ b/extension/chat-wire.js @@ -0,0 +1,11 @@ +export function buildChatRelayPayload(ciphertext) { + return { ciphertext }; +} + +export function encodeSocketEvent(event, data, forbiddenSecret = '') { + const payload = JSON.stringify([event, data]); + if (forbiddenSecret && payload.includes(forbiddenSecret)) { + throw new Error('Refusing to send chat secret to relay'); + } + return `42${payload}`; +} diff --git a/extension/chat-wire.test.mjs b/extension/chat-wire.test.mjs new file mode 100644 index 0000000..508d40a --- /dev/null +++ b/extension/chat-wire.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; + +describe('chat wire boundary', () => { + const secret = 'R5Ti1nxp0crfAFHf3gVncw'; + + it('sends only ciphertext for chat messages', () => { + expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' }); + }); + + it('rejects the secret anywhere in any outgoing relay event', () => { + for (const [event, payload] of [ + ['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }], + ['peer_status', { status: 'heartbeat', nested: { secret } }], + ['chat_message', { ciphertext: `prefix-${secret}-suffix` }] + ]) { + expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret'); + } + expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret); + }); +}); diff --git a/extension/popup.js b/extension/popup.js index c7af580..006add2 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,7 +3,14 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; +import './shared/invite-links.js'; +let pendingInviteRoomId = ''; +let pendingInviteChatKey = ''; + +function normalizeChatKey(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : ''; +} const elements = { tabs: document.querySelectorAll('.tabs .tab-btn'), @@ -324,7 +331,7 @@ function setRoomRefreshCooldown() { async function init() { // Local-only by design — settings and room credentials never come from // storage.sync (only onboardingComplete + dismissedHints live there). - const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']); + const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']); let activeLang = localData.locale; if (!activeLang) { @@ -380,7 +387,7 @@ async function init() { } toggleUIState(!!localData.roomId); - updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl); + updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey); refreshLogs(); refreshHistory(); @@ -552,15 +559,20 @@ if (elements.hostControlToggle) { }); } -function updateUI(roomId, password, useCustomServer = false, serverUrl = '') { +function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') { const inRoom = !!roomId; toggleUIState(inRoom); if (inRoom) { - let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`; - if (useCustomServer) { - const encodedUrl = encodeURIComponent(serverUrl || ''); - invite += `:1:${encodedUrl}`; - } + const validChatKey = normalizeChatKey(chatKey); + let invite = validChatKey + ? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({ + roomId, + password, + chatKey: validChatKey, + serverUrl: useCustomServer ? serverUrl : '' + })}` + : `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`; + if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`; elements.inviteLink.value = invite; if (window.justCreatedRoom) { @@ -1289,41 +1301,21 @@ function updateRoomList(rooms) { function checkInviteLink() { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { const tab = tabs[0]; - if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) { + if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) { try { - const rawHash = tab.url.split('#join:')[1]; - if (!rawHash) return; - const parts = rawHash.split(':'); - if (parts.length >= 2) { - const roomId = parts.shift(); - let useCustomServer = false; - let serverUrl = ''; + const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash); + if (!invite) return; + elements.roomId.value = invite.roomId; + elements.password.value = invite.password; + pendingInviteRoomId = invite.roomId; + pendingInviteChatKey = normalizeChatKey(invite.chatKey); - const last = parts[parts.length - 1]; - const secondToLast = parts[parts.length - 2]; - const decodedLast = decodeURIComponent(last || ''); - const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://')); - const isOfficial = secondToLast === '0' && last === ''; + elements.serverUrl.value = invite.serverUrl; + setServerMode(invite.useCustomServer); + chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer }); - if (parts.length >= 3 && (isCustom || isOfficial)) { - serverUrl = decodeURIComponent(parts.pop()); - useCustomServer = parts.pop() === '1'; - } - - const password = parts.join(':'); - - elements.roomId.value = roomId; - elements.password.value = password; - - if (serverUrl || useCustomServer) { - elements.serverUrl.value = serverUrl; - setServerMode(useCustomServer); - chrome.storage.local.set({ serverUrl, useCustomServer }); - } - - elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)'; - setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000); - } + elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)'; + setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000); } catch (_e) { // Malformed invite link, ignore } @@ -1468,15 +1460,15 @@ if (elements.langSelector) { lastKnownPeers = res.peers || []; if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers); - const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']); - updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); + const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']); + updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey); await populateTabs(res.peers, res.targetTabId); if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); } else { applyConnectionStatus('disconnected'); - const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']); - updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); + const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']); + updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey); await populateTabs(); } }); @@ -1605,6 +1597,10 @@ function showError(msg) { // --- Action Handlers --- elements.roomId.addEventListener('input', () => { elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, ''); + if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) { + pendingInviteRoomId = ''; + pendingInviteChatKey = ''; + } }); elements.joinBtn.addEventListener('click', async () => { @@ -1676,17 +1672,24 @@ elements.joinBtn.addEventListener('click', async () => { self.crypto.getRandomValues(array); password = Array.from(array, byte => chars[byte % chars.length]).join(''); elements.password.value = password; - window.justCreatedRoom = true; } + let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : ''; + if (isCreating) { + const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve)); + chatKey = normalizeChatKey(created?.chatKey); + } + if (isCreating) window.justCreatedRoom = true; + pendingInviteRoomId = ''; + pendingInviteChatKey = ''; - await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom }); + await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom }); elements.roomId.value = roomId; // Tell background to connect chrome.runtime.sendMessage({ type: 'CONNECT' }); // UI Feedback: Immediately switch state for better responsiveness - updateUI(roomId, password, useCustom, serverUrl); + updateUI(roomId, password, useCustom, serverUrl, chatKey); }); elements.leaveBtn.addEventListener('click', async () => { @@ -1694,7 +1697,7 @@ elements.leaveBtn.addEventListener('click', async () => { isProcessingConnection = true; clearConnectionErrorTimer(); chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' }); - await chrome.storage.local.set({ roomId: '', password: '' }); + await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }); elements.roomId.value = ''; elements.password.value = ''; lastKnownPeers = []; @@ -2167,15 +2170,15 @@ chrome.runtime.onMessage.addListener((msg) => { if (msg.success) { // Final confirmation of join from background - chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => { - updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); + chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => { + updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey); const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]'); if (syncTabBtn) syncTabBtn.click(); showSelectVideoHint(); }); } else { // Join failed: reset UI state - chrome.storage.local.set({ roomId: '', password: '' }); + chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }); elements.roomId.value = ''; elements.password.value = ''; updateUI(null, null); diff --git a/vitest.config.mjs b/vitest.config.mjs index e323a49..6d58fdf 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -8,7 +8,9 @@ export default defineConfig({ 'server/**/*.test.js', 'server/**/*.test.mjs', 'shared/**/*.test.js', - 'shared/**/*.test.mjs' + 'shared/**/*.test.mjs', + 'extension/**/*.test.js', + 'extension/**/*.test.mjs' ], coverage: { provider: 'v8',