mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat(chat): add encrypted invite contract
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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(() => {});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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"`;
|
||||
});
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="invite-links.min.js"></script>
|
||||
<script src="app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user