From 8fee98834c6dd1d04649b8b814fb1fe1d784a98d Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:58:22 +0200 Subject: [PATCH] feat(chat): add encrypted invite contract --- docs/CHAT.md | 72 ++++++++++++++++++++++++++++++++++ docs/PROTOCOL.md | 13 +++++++ extension/bridge.js | 3 +- scripts/build-extension.cjs | 4 +- shared/invite-links.js | 77 +++++++++++++++++++++++++++++++++++++ shared/invite-links.test.js | 58 ++++++++++++++++++++++++++++ website/app.js | 51 ++++++++++++------------ website/build.cjs | 11 ++++++ website/join.html | 1 + website/template.html | 1 + 10 files changed, 261 insertions(+), 30 deletions(-) create mode 100644 docs/CHAT.md create mode 100644 shared/invite-links.js create mode 100644 shared/invite-links.test.js diff --git a/docs/CHAT.md b/docs/CHAT.md new file mode 100644 index 0000000..e6ad00b --- /dev/null +++ b/docs/CHAT.md @@ -0,0 +1,72 @@ +# Ephemeral end-to-end encrypted chat + +KoalaSync chat is an optional, live-only text channel rendered on the selected +streaming tab. It is not a messenger and does not add chat UI to the extension +popup. + +## Security boundary + +- The relay receives ciphertext only and never receives the chat secret. +- The relay stores no messages in RAM or on disk and sends no backlog. +- A late joiner sees only messages sent after joining. +- Room authentication is unchanged. The room password remains separate from the + chat secret and continues to be sent to the relay during `join_room`. +- Message timing and ciphertext length remain visible to the relay. Same-room + replay is accepted by the threat model. + +## Invite format and key lifecycle + +New invitations use a named fragment format: + +```text +#j2:r=&p=&k=[&u=] +``` + +The room creator generates 16 random bytes and encodes them as 22 unpadded +base64url characters. URL fragments are not sent in HTTP requests. The website +parses the fragment and passes structured fields through `bridge.js` to the +extension. `chatKey` must never be included in a relay event. + +Legacy `#join:` links remain supported and join normally without chat. Manual +room/password entry also joins without chat. The extension clears any prior chat +secret when joining without a key, switching rooms, or leaving. + +Deployment order is website first, extension second. An old extension ignores the +additional structured `chatKey` field and continues joining normally. + +## Cryptography + +- Secret: 16 cryptographically random bytes. +- KDF: HKDF-SHA256 with `roomId` as salt and a fixed KoalaSync chat info label. +- Encryption: AES-256-GCM using WebCrypto. +- IV: fresh random 12-byte value for every message, prepended to the ciphertext. +- AAD: `${roomId}|${senderId}`. +- The derived `CryptoKey` is cached once per active room. + +The relay stamps `id`, `senderId`, and `timestamp` on the ciphertext envelope. +Changing `senderId` causes AES-GCM authentication to fail because the receiver uses +the stamped value as AAD. + +## Client policy + +- Maximum plaintext length: 500 Unicode code points. +- Decrypted text is untrusted. Escape HTML before applying the supported limited + Markdown formatting. +- No read receipts and no typing indicators. +- The local message DOM is bounded; this is presentation state, not server history. + +## Overlay behavior + +- Default dock: right. +- Live modes: right, left, and detached. +- Detached mode is draggable and moderately resizable. Size and position are stored + per origin and clamped to the current viewport. +- The overlay uses a Shadow DOM and never changes host-page layout. +- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains + visible. +- It follows all eucalyptus, cyber, and graphite light/dark theme combinations. +- Without a key, the panel stays closed and a disabled chat control explains that a + current invite link is required. +- Without the relay `chat` capability, no chat control is shown. + +Peer removal and role management are outside chat scope. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ae01122..b5d8f43 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -11,6 +11,19 @@ the event names defined in `shared/constants.js`. object payload. - The relay caps incoming Socket.IO message size at 4 KB. +## Invite fragments + +Current invitations use named URL-fragment fields: + +```text +#j2:r=&p=&k=[&u=] +``` + +The fragment is parsed by the website and forwarded to the extension as structured +fields. `k` is client-only and must never appear in a relay payload. Legacy +`#join::[:1:]` fragments remain valid but provide no chat +secret. + ## Connection Handshake The Socket.IO handshake must include: diff --git a/extension/bridge.js b/extension/bridge.js index d4584cb..38a26f6 100644 --- a/extension/bridge.js +++ b/extension/bridge.js @@ -11,11 +11,12 @@ document.documentElement.dataset.koalasyncInstalled = 'true'; // 2. Listen for Join Requests from the Website window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => { if (!e || !e.detail) return; - const { roomId, password, useCustomServer, serverUrl } = e.detail; + const { roomId, password, chatKey, useCustomServer, serverUrl } = e.detail; chrome.runtime.sendMessage({ type: 'WEB_JOIN_REQUEST', roomId, password, + chatKey, useCustomServer, serverUrl }).catch(() => {}); diff --git a/scripts/build-extension.cjs b/scripts/build-extension.cjs index b5d9ac6..c80b799 100644 --- a/scripts/build-extension.cjs +++ b/scripts/build-extension.cjs @@ -22,7 +22,7 @@ if (!fs.existsSync(extSharedDir)) { fs.mkdirSync(extSharedDir, { recursive: true }); } -const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md']; +const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'invite-links.js', 'README.md']; for (const file of sharedFiles) { const src = path.join(masterSharedDir, file); const dest = path.join(extSharedDir, file); @@ -31,7 +31,7 @@ for (const file of sharedFiles) { } fs.copyFileSync(src, dest); } -console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/'); +console.log('✓ shared runtime files synced to extension/shared/'); // Read the base manifest const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8')); diff --git a/shared/invite-links.js b/shared/invite-links.js new file mode 100644 index 0000000..d5c6955 --- /dev/null +++ b/shared/invite-links.js @@ -0,0 +1,77 @@ +(function exposeInviteLinks(root) { + const J2_PREFIX = '#j2:'; + const LEGACY_PREFIX = '#join:'; + + function parseJ2Hash(hash) { + if (typeof hash !== 'string' || !hash.startsWith(J2_PREFIX)) return null; + const params = new URLSearchParams(hash.slice(J2_PREFIX.length)); + const roomId = params.get('r') || ''; + const password = params.get('p') || ''; + const chatKey = params.get('k') || ''; + const serverUrl = params.get('u') || ''; + if (!roomId || !password || !chatKey) return null; + return { + format: 'j2', + roomId, + password, + chatKey, + useCustomServer: serverUrl.length > 0, + serverUrl + }; + } + + function parseLegacyHash(hash) { + if (typeof hash !== 'string' || !hash.startsWith(LEGACY_PREFIX)) return null; + const parts = hash.slice(LEGACY_PREFIX.length).split(':'); + const roomId = parts.shift() || ''; + let useCustomServer = false; + let serverUrl = ''; + + if (parts.length >= 3) { + const flag = parts.at(-2); + const rawUrl = parts.at(-1) || ''; + let decodedUrl = ''; + try { + decodedUrl = decodeURIComponent(rawUrl); + } catch (_) { + return null; + } + const custom = flag === '1' && /^(?:ws|wss):\/\//.test(decodedUrl); + const official = flag === '0' && rawUrl === ''; + if (custom || official) { + parts.splice(-2, 2); + useCustomServer = custom; + serverUrl = custom ? decodedUrl : ''; + } + } + + const password = parts.join(':'); + if (!roomId || !password) return null; + return { + format: 'legacy', + roomId, + password, + chatKey: '', + useCustomServer, + serverUrl + }; + } + + function parseInviteHash(hash) { + return parseJ2Hash(hash) || parseLegacyHash(hash); + } + + function buildJ2Hash({ roomId, password, chatKey, serverUrl = '' }) { + if (!roomId || !password || !chatKey) throw new TypeError('roomId, password, and chatKey are required'); + const params = new URLSearchParams({ r: roomId, p: password, k: chatKey }); + if (serverUrl) params.set('u', serverUrl); + return `${J2_PREFIX}${params.toString()}`; + } + + root.KoalaSyncInviteLinks = Object.freeze({ + J2_PREFIX, + LEGACY_PREFIX, + parseInviteHash, + buildJ2Hash + }); +})(globalThis); diff --git a/shared/invite-links.test.js b/shared/invite-links.test.js new file mode 100644 index 0000000..2e963aa --- /dev/null +++ b/shared/invite-links.test.js @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +beforeAll(async () => { + await import('./invite-links.js'); +}); + +describe('invite links', () => { + it('round-trips official and custom j2 links', () => { + const api = globalThis.KoalaSyncInviteLinks; + const official = api.buildJ2Hash({ + roomId: 'SAPPHIRE-DUCK-49', + password: '0X:UK3C', + chatKey: 'R5Ti1nxp0crfAFHf3gVncw' + }); + expect(api.parseInviteHash(official)).toEqual({ + format: 'j2', + roomId: 'SAPPHIRE-DUCK-49', + password: '0X:UK3C', + chatKey: 'R5Ti1nxp0crfAFHf3gVncw', + useCustomServer: false, + serverUrl: '' + }); + + const custom = api.buildJ2Hash({ + roomId: 'SILENT-EAGLE-90', + password: '30PXPD', + chatKey: 'R5Ti1nxp0crfAFHf3gVncw', + serverUrl: 'wss://sync.example.test/socket?region=eu' + }); + expect(api.parseInviteHash(custom)).toMatchObject({ + useCustomServer: true, + serverUrl: 'wss://sync.example.test/socket?region=eu' + }); + }); + + it('keeps legacy links compatible, including colons in passwords', () => { + const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash; + expect(parse('#join:ROOM-1:pass:with:colons')).toMatchObject({ + format: 'legacy', + password: 'pass:with:colons', + chatKey: '', + useCustomServer: false + }); + expect(parse('#join:ROOM-1:pass:with:colons:1:wss%3A%2F%2Frelay.example')).toMatchObject({ + password: 'pass:with:colons', + useCustomServer: true, + serverUrl: 'wss://relay.example' + }); + }); + + it('rejects incomplete or malformed invite hashes', () => { + const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash; + expect(parse('#j2:r=ROOM&p=PASS')).toBeNull(); + expect(parse('#join:ROOM')).toBeNull(); + expect(parse('#join:ROOM:PASS:1:%E0%A4%A')).toBeNull(); + expect(parse('#other:r=ROOM&p=PASS&k=KEY')).toBeNull(); + }); +}); diff --git a/website/app.js b/website/app.js index 3a5fa7e..faa2ac1 100644 --- a/website/app.js +++ b/website/app.js @@ -449,15 +449,11 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true'; - if (window.location.hash.startsWith('#join:')) { - const parts = window.location.hash.split(':'); - if (parts.length >= 3) { - const roomId = parts[1]; - const password = parts[2]; - const serverFlag = parts[3] || '0'; - const serverUrl = parts[4] ? decodeURIComponent(parts[4]) : ''; + const invite = globalThis.KoalaSyncInviteLinks?.parseInviteHash(window.location.hash); + if (invite) { + const { roomId, password, chatKey, useCustomServer, serverUrl } = invite; - if (isJoinPage) { + if (isJoinPage) { const displayRoom = document.getElementById('display-room-id'); const actions = document.getElementById('join-actions'); if (displayRoom) displayRoom.textContent = roomId; @@ -519,14 +515,15 @@ document.addEventListener('DOMContentLoaded', () => { detail: { roomId, password, - useCustomServer: serverFlag === '1', - serverUrl: serverUrl + chatKey, + useCustomServer, + serverUrl } })); }, 500); } } - } else { + } else { // Fallback banner for index.html if (!document.getElementById('koala-banner')) { const banner = document.createElement('div'); @@ -556,22 +553,22 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Global listener for Join Button - document.addEventListener('click', (e) => { - if (e.target && e.target.id === 'webJoinBtn') { - e.target.textContent = 'JOINING...'; - e.target.disabled = true; - window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', { - detail: { - roomId, - password, - useCustomServer: serverFlag === '1', - serverUrl: serverUrl - } - })); - } - }); - } + // Global listener for Join Button + document.addEventListener('click', (e) => { + if (e.target && e.target.id === 'webJoinBtn') { + e.target.textContent = 'JOINING...'; + e.target.disabled = true; + window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', { + detail: { + roomId, + password, + chatKey, + useCustomServer, + serverUrl + } + })); + } + }); } }, 600); // 600ms delay to ensure bridge.js has set the dataset }; diff --git a/website/build.cjs b/website/build.cjs index 4171418..a56b5ec 100644 --- a/website/build.cjs +++ b/website/build.cjs @@ -284,6 +284,12 @@ async function compile() { const appName = `app.${appHash}.min.js`; const appSRI = sha384(appMin); + const inviteLinksRaw = fs.readFileSync(path.join(websiteDir, '..', 'shared', 'invite-links.js'), 'utf8'); + const inviteLinksMin = await minifyJS(inviteLinksRaw); + const inviteLinksHash = sha8(inviteLinksMin); + const inviteLinksName = `invite-links.${inviteLinksHash}.min.js`; + const inviteLinksSRI = sha384(inviteLinksMin); + const langRaw = fs.readFileSync(path.join(websiteDir, 'lang-init.js'), 'utf8'); const langMin = await minifyJS(langRaw); const langHash = sha8(langMin); @@ -298,6 +304,7 @@ async function compile() { console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`); } console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`); + console.log(` Invite links: ${inviteLinksName} (${(inviteLinksMin.length/1024).toFixed(1)} KB)`); console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`); // ── 2. Clean stale minified output ── @@ -314,6 +321,7 @@ async function compile() { fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min); } fs.writeFileSync(path.join(wwwDir, appName), appMin); + fs.writeFileSync(path.join(wwwDir, inviteLinksName), inviteLinksMin); fs.writeFileSync(path.join(wwwDir, langName), langMin); // ── 3. Compile HTML templates ── @@ -652,6 +660,9 @@ async function compile() { html = html.replace(/(]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => { return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`; }); + html = html.replace(/(]*?\bsrc=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => { + return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" crossorigin="anonymous"`; + }); html = html.replace(/(]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => { return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`; }); diff --git a/website/join.html b/website/join.html index 6e8d09c..f85aa6f 100644 --- a/website/join.html +++ b/website/join.html @@ -203,6 +203,7 @@ + diff --git a/website/template.html b/website/template.html index 70e391c..93ec16c 100644 --- a/website/template.html +++ b/website/template.html @@ -1573,6 +1573,7 @@ +