feat(chat): add encrypted invite contract

This commit is contained in:
KoalaDev
2026-07-15 06:58:22 +02:00
parent 771da1cf82
commit 8fee98834c
10 changed files with 261 additions and 30 deletions
+72
View File
@@ -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=<roomId>&p=<password>&k=<base64url-secret>[&u=<relayUrl>]
```
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.
+13
View File
@@ -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=<roomId>&p=<password>&k=<base64url-chat-secret>[&u=<relayUrl>]
```
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:<roomId>:<password>[:1:<relayUrl>]` fragments remain valid but provide no chat
secret.
## Connection Handshake
The Socket.IO handshake must include:
+2 -1
View File
@@ -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(() => {});
+2 -2
View File
@@ -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'));
+77
View File
@@ -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);
+58
View File
@@ -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();
});
});
+9 -12
View File
@@ -449,13 +449,9 @@ 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) {
const displayRoom = document.getElementById('display-room-id');
@@ -519,8 +515,9 @@ document.addEventListener('DOMContentLoaded', () => {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
chatKey,
useCustomServer,
serverUrl
}
}));
}, 500);
@@ -565,14 +562,14 @@ document.addEventListener('DOMContentLoaded', () => {
detail: {
roomId,
password,
useCustomServer: serverFlag === '1',
serverUrl: serverUrl
chatKey,
useCustomServer,
serverUrl
}
}));
}
});
}
}
}, 600); // 600ms delay to ensure bridge.js has set the dataset
};
+11
View File
@@ -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(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
});
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" crossorigin="anonymous"`;
});
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
});
+1
View File
@@ -203,6 +203,7 @@
</div>
</footer>
<script src="invite-links.min.js"></script>
<script src="app.min.js"></script>
</body>
</html>
+1
View File
@@ -1573,6 +1573,7 @@
</div>
</footer>
<script src="{{ASSET_PATH}}invite-links.min.js"></script>
<script src="{{ASSET_PATH}}app.min.js"></script>
</body>
</html>