mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat(chat): add stateless ciphertext relay
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user