mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat(chat): add client encryption and key lifecycle
This commit is contained in:
+106
-9
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+54
-51
@@ -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);
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user