diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b5d8f43..4a18209 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -82,13 +82,47 @@ Payload: "hostPeerId": "string or null", "controlMode": "everyone | host-only", "controllers": ["peerId"], - "capabilities": ["host-control", "co-host"] + "capabilities": ["host-control", "co-host", "chat"] } ``` `room_data` is sent to the joining socket. It is not the general broadcast used for every later room update. +## Ephemeral encrypted chat + +Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients +must not infer support from another field. + +### `chat_message` + +Client to relay: + +```json +{ "ciphertext": "" } +``` + +`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte +authentication tag. The relay validates only canonical base64url and byte bounds. +It cannot inspect plaintext. + +Relay to every current room peer, including the sender: + +```json +{ + "id": "", + "senderId": "", + "timestamp": 1710000000000, + "ciphertext": "" +} +``` + +Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded. +The relay keeps no message collection and `room_data` contains no chat history. +Messages are limited to 10 per socket per 10 seconds in addition to the global event +budget. There are no typing, read-receipt, history, or chat-specific peer-management +events. + ## Room Leave ### `leave_room` (client -> server) @@ -378,5 +412,6 @@ If sender and target are still in the same room, the relay emits: - `host-control` - `co-host` +- `chat` Clients should treat a missing or unknown capabilities list as unsupported. diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 5c1186c..32ae992 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; import http from 'node:http'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -70,6 +71,38 @@ try { assert.equal(capEv, 'room_data'); assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'), 'ROOM_DATA advertises the host-control capability'); + assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability'); + assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history'); + close(); + resetConnectionRate(); + + // --- Encrypted chat is a live-only canonical relay --- + const chatRoom = 'chat-'+Date.now(); + const chat1 = await c(), chat2 = await c(); + await j(chat1, chatRoom, 'alice'); await j(chat2, chatRoom, 'bob'); + chat1._m.length = chat2._m.length = 0; + const ciphertext = Buffer.alloc(64, 7).toString('base64url'); + s(chat1, 'chat_message', { + ciphertext, + id: 'spoofed-id', senderId: 'mallory', timestamp: 1, text: 'plaintext' + }); + const [chat1Event, chat1Data] = await a(chat1); + const [chat2Event, chat2Data] = await a(chat2); + assert.equal(chat1Event, 'chat_message', 'sender receives canonical chat envelope'); + assert.equal(chat2Event, 'chat_message', 'peer receives canonical chat envelope'); + assert.deepEqual(chat2Data, chat1Data, 'all peers receive the same canonical envelope'); + assert.equal(chat1Data.senderId, 'alice', 'relay stamps senderId'); + assert.equal(chat1Data.ciphertext, ciphertext, 'relay preserves ciphertext'); + assert.equal(chat1Data.text, undefined, 'relay drops plaintext fields'); + assert.notEqual(chat1Data.id, 'spoofed-id', 'relay replaces client IDs'); + assert.ok(Number.isFinite(chat1Data.timestamp) && chat1Data.timestamp > 1, 'relay stamps timestamp'); + + const late = await c(); + s(late, 'join_room', { roomId: chatRoom, peerId: 'late', protocolVersion: '1.0.0' }); + const [lateEvent, lateRoomData] = await a(late); + assert.equal(lateEvent, 'room_data'); + assert.equal(lateRoomData.chatHistory, undefined, 'late joiner gets no chat backlog'); + assert.equal(late._m.some(raw => raw.includes('chat_message')), false, 'late joiner receives no old message'); close(); resetConnectionRate(); diff --git a/server/chat.js b/server/chat.js new file mode 100644 index 0000000..6792d24 --- /dev/null +++ b/server/chat.js @@ -0,0 +1,24 @@ +import { randomUUID } from 'node:crypto'; + +export const CHAT_CIPHERTEXT_MAX_BYTES = 2028; +export const CHAT_CIPHERTEXT_MIN_BYTES = 29; + +export function normalizeChatCiphertext(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null; + const bytes = Buffer.from(value, 'base64url'); + if (bytes.length < CHAT_CIPHERTEXT_MIN_BYTES || bytes.length > CHAT_CIPHERTEXT_MAX_BYTES) return null; + if (bytes.toString('base64url') !== value) return null; + return value; +} + +export function createChatEnvelope(data, senderId, now = Date.now, createId = randomUUID) { + if (!data || typeof data !== 'object' || typeof senderId !== 'string' || !senderId) return null; + const ciphertext = normalizeChatCiphertext(data.ciphertext); + if (!ciphertext) return null; + return { + id: createId(), + senderId, + timestamp: now(), + ciphertext + }; +} diff --git a/server/chat.test.mjs b/server/chat.test.mjs new file mode 100644 index 0000000..51907fb --- /dev/null +++ b/server/chat.test.mjs @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { Buffer } from 'node:buffer'; +import { + CHAT_CIPHERTEXT_MAX_BYTES, + createChatEnvelope, + normalizeChatCiphertext +} from './chat.js'; + +const encoded = (length) => Buffer.alloc(length, 7).toString('base64url'); + +describe('encrypted chat relay envelopes', () => { + it('accepts canonical base64url within the encrypted message bounds', () => { + expect(normalizeChatCiphertext(encoded(29))).toBe(encoded(29)); + expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES))).toBe(encoded(CHAT_CIPHERTEXT_MAX_BYTES)); + }); + + it('rejects malformed, padded, too-small, and oversized payloads', () => { + expect(normalizeChatCiphertext('