mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 20:39:10 +00:00
feat(chat): add stateless ciphertext relay
This commit is contained in:
+36
-1
@@ -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": "<unpadded-base64url>" }
|
||||
```
|
||||
|
||||
`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": "<server-generated UUID>",
|
||||
"senderId": "<server-stamped peer ID>",
|
||||
"timestamp": 1710000000000,
|
||||
"ciphertext": "<unpadded-base64url>"
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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('<script>')).toBeNull();
|
||||
expect(normalizeChatCiphertext(`${encoded(29)}=`)).toBeNull();
|
||||
expect(normalizeChatCiphertext(encoded(28))).toBeNull();
|
||||
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES + 1))).toBeNull();
|
||||
expect(normalizeChatCiphertext('A')).toBeNull();
|
||||
});
|
||||
|
||||
it('stamps authoritative metadata and keeps only ciphertext from the sender', () => {
|
||||
const ciphertext = encoded(64);
|
||||
const envelope = createChatEnvelope({
|
||||
ciphertext,
|
||||
id: 'spoofed',
|
||||
senderId: 'mallory',
|
||||
timestamp: 1,
|
||||
text: 'plaintext must not survive'
|
||||
}, 'alice', () => 1234, () => 'server-id');
|
||||
|
||||
expect(envelope).toEqual({
|
||||
id: 'server-id',
|
||||
senderId: 'alice',
|
||||
timestamp: 1234,
|
||||
ciphertext
|
||||
});
|
||||
expect(JSON.stringify(envelope)).not.toContain('plaintext');
|
||||
});
|
||||
});
|
||||
+27
-1
@@ -5,6 +5,7 @@ import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
chatMessageCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
recordAuthFailure,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkChatMessageRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
@@ -171,7 +174,7 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT];
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
@@ -844,8 +847,31 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.CHAT_MESSAGE, (data) => {
|
||||
try {
|
||||
if (!checkEventRate(socket.id) || !checkChatMessageRate(socket.id)) {
|
||||
log('SECURITY', `Encrypted chat rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room) return;
|
||||
|
||||
const envelope = createChatEnvelope(data, mapping.peerId);
|
||||
if (!envelope) return;
|
||||
room.lastActivity = Date.now();
|
||||
io.to(mapping.roomId).emit(EVENTS.CHAT_MESSAGE, envelope);
|
||||
} catch (err) {
|
||||
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
chatMessageCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
leaveRoomCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
|
||||
@@ -12,6 +12,8 @@ export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP
|
||||
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const CHAT_MESSAGE_RATE_LIMIT = 10; // max encrypted chat messages per socket per window
|
||||
export const CHAT_MESSAGE_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
|
||||
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||
@@ -20,6 +22,7 @@ export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const chatMessageCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
@@ -28,6 +31,7 @@ export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
chatMessages: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
@@ -104,6 +108,17 @@ export function checkEventRate(socketId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkChatMessageRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = chatMessageCounts.get(socketId) || { count: 0, resetTime: now + CHAT_MESSAGE_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CHAT_MESSAGE_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
chatMessageCounts.set(socketId, entry);
|
||||
if (entry.count <= CHAT_MESSAGE_RATE_LIMIT) return true;
|
||||
rateLimitDenied.chatMessages++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
||||
@@ -163,6 +178,11 @@ export function startRateLimitCleanup(io) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of chatMessageCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
chatMessageCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
leaveRoomCounts.delete(socketId);
|
||||
@@ -189,6 +209,7 @@ export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
chatMessageCounts.clear();
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
checkLeaveRoomRate,
|
||||
checkChatMessageRate,
|
||||
CHAT_MESSAGE_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||
chatMessageCounts,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
@@ -99,6 +103,32 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
const socketId = 'chat-socket';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.chatMessages = 0;
|
||||
});
|
||||
|
||||
afterEach(() => clearRateLimitMaps());
|
||||
|
||||
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
||||
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||
}
|
||||
expect(checkChatMessageRate(socketId)).toBe(false);
|
||||
expect(rateLimitDenied.chatMessages).toBe(1);
|
||||
});
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
for (let i = 0; i <= CHAT_MESSAGE_RATE_LIMIT; i++) checkChatMessageRate(socketId);
|
||||
const entry = chatMessageCounts.get(socketId);
|
||||
entry.resetTime = Date.now() - CHAT_MESSAGE_RATE_WINDOW_MS - 1;
|
||||
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
|
||||
+5
-1
@@ -57,6 +57,9 @@ export const EVENTS = {
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
||||
|
||||
// Ephemeral end-to-end encrypted chat
|
||||
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
|
||||
|
||||
// Ping / Latency
|
||||
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
@@ -77,7 +80,8 @@ export const CONTROL_MODES = {
|
||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat'
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
Reference in New Issue
Block a user