ciphermesh 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,8 +12,14 @@ export const MSG = {
12
12
  PEER_KEY_UPDATED: 'peer_key_updated',
13
13
  CHANGE_ROOM: 'change_room',
14
14
  ROOM_CHANGED: 'room_changed',
15
+ JOIN_ROOM: 'join_room',
16
+ ROOM_JOINED: 'room_joined',
17
+ LEAVE_ROOM: 'leave_room',
18
+ ROOM_LEFT: 'room_left',
15
19
  LIST_ROOMS: 'list_rooms',
16
20
  ROOM_LIST: 'room_list',
21
+ ROOM_CHALLENGE: 'room_challenge',
22
+ ROOM_AUTH: 'room_auth',
17
23
  KICK_PEER: 'kick_peer',
18
24
  MUTE_PEER: 'mute_peer',
19
25
  BAN_PEER: 'ban_peer',
@@ -30,6 +36,8 @@ export const ERR = {
30
36
  PEER_NOT_FOUND: 'PEER_NOT_FOUND',
31
37
  RATE_LIMITED: 'RATE_LIMITED',
32
38
  PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
39
+ ROOM_AUTH_FAILED: 'ROOM_AUTH_FAILED',
40
+ ROOM_EXISTS: 'ROOM_EXISTS',
33
41
  };
34
42
 
35
43
  // ── Factory helpers ────────────────────────────────────────────
@@ -49,12 +57,22 @@ export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general
49
57
  return ack;
50
58
  }
51
59
 
52
- export function createPeerJoined(peer) {
53
- return { ...base(MSG.PEER_JOINED), peer };
60
+ // `room` (multi-room, additive): which room this event refers to. A peer can
61
+ // leave room X while still sharing room Y with you. Old clients ignore it.
62
+ export function createPeerJoined(peer, room = null) {
63
+ const msg = { ...base(MSG.PEER_JOINED), peer };
64
+ if (room) {
65
+ msg.room = room;
66
+ }
67
+ return msg;
54
68
  }
55
69
 
56
- export function createPeerLeft(sessionId, nickname) {
57
- return { ...base(MSG.PEER_LEFT), sessionId, nickname };
70
+ export function createPeerLeft(sessionId, nickname, room = null) {
71
+ const msg = { ...base(MSG.PEER_LEFT), sessionId, nickname };
72
+ if (room) {
73
+ msg.room = room;
74
+ }
75
+ return msg;
58
76
  }
59
77
 
60
78
  export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
@@ -108,12 +126,62 @@ export function createPong() {
108
126
  return base(MSG.PONG);
109
127
  }
110
128
 
111
- export function createChangeRoom(room) {
112
- return { ...base(MSG.CHANGE_ROOM), room };
129
+ // roomAuthPk (optional): Ed25519 verifier public key — present only when
130
+ // CREATING a private room; the server stores it in memory as the room's
131
+ // password verifier (see crypto/RoomKey.js).
132
+ export function createChangeRoom(room, roomAuthPkB64 = null) {
133
+ const msg = { ...base(MSG.CHANGE_ROOM), room };
134
+ if (roomAuthPkB64) {
135
+ msg.roomAuthPk = roomAuthPkB64;
136
+ }
137
+ return msg;
138
+ }
139
+
140
+ export function createRoomChanged(room, peers, isPrivate = false) {
141
+ const msg = { ...base(MSG.ROOM_CHANGED), room, peers };
142
+ if (isPrivate) {
143
+ msg.private = true;
144
+ }
145
+ return msg;
146
+ }
147
+
148
+ // ── Multi-room (IRC-style buffers) ─────────────────────────────
149
+ // join_room ADDS a membership (unlike change_room, which replaces them all).
150
+ // Same optional roomAuthPk as change_room for creating a private room.
151
+ export function createJoinRoom(room, roomAuthPkB64 = null) {
152
+ const msg = { ...base(MSG.JOIN_ROOM), room };
153
+ if (roomAuthPkB64) {
154
+ msg.roomAuthPk = roomAuthPkB64;
155
+ }
156
+ return msg;
157
+ }
158
+
159
+ export function createRoomJoined(room, peers, isPrivate = false) {
160
+ const msg = { ...base(MSG.ROOM_JOINED), room, peers };
161
+ if (isPrivate) {
162
+ msg.private = true;
163
+ }
164
+ return msg;
165
+ }
166
+
167
+ export function createLeaveRoom(room) {
168
+ return { ...base(MSG.LEAVE_ROOM), room };
169
+ }
170
+
171
+ export function createRoomLeft(room) {
172
+ return { ...base(MSG.ROOM_LEFT), room };
173
+ }
174
+
175
+ // Server → client: the target room is private; prove password knowledge by
176
+ // signing this nonce. The password itself never travels.
177
+ export function createRoomChallenge(room, nonceB64) {
178
+ return { ...base(MSG.ROOM_CHALLENGE), room, nonce: nonceB64 };
113
179
  }
114
180
 
115
- export function createRoomChanged(room, peers) {
116
- return { ...base(MSG.ROOM_CHANGED), room, peers };
181
+ // Client → server: Ed25519 signature over (room, nonce, sessionId) with the
182
+ // password-derived key (see crypto/RoomKey.js#signRoomChallenge).
183
+ export function createRoomAuth(room, nonceB64, signatureB64) {
184
+ return { ...base(MSG.ROOM_AUTH), room, nonce: nonceB64, signature: signatureB64 };
117
185
  }
118
186
 
119
187
  export function createListRooms() {
@@ -3,6 +3,9 @@ import {
3
3
  MAX_NICKNAME_LENGTH,
4
4
  MAX_PAYLOAD_SIZE,
5
5
  PUBLIC_KEY_SIZE,
6
+ ROOM_AUTH_PK_SIZE,
7
+ ROOM_AUTH_SIG_SIZE,
8
+ ROOM_CHALLENGE_NONCE_SIZE,
6
9
  } from '../shared/constants.js';
7
10
 
8
11
  // ── Helpers ────────────────────────────────────────────────────
@@ -109,6 +112,26 @@ export function validateKeyUpdate(msg) {
109
112
  }
110
113
 
111
114
  export function validateChangeRoom(msg) {
115
+ if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
116
+ return { valid: false, error: 'Invalid room name (1-30 chars)' };
117
+ }
118
+ if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
119
+ return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
120
+ }
121
+ // Optional: Ed25519 verifier public key, present only when creating a
122
+ // private room.
123
+ if (msg.roomAuthPk !== undefined && !isValidBase64(msg.roomAuthPk, ROOM_AUTH_PK_SIZE)) {
124
+ return { valid: false, error: 'Invalid room verifier key' };
125
+ }
126
+ return { valid: true, room: msg.room.toLowerCase(), roomAuthPk: msg.roomAuthPk || null };
127
+ }
128
+
129
+ // join_room shares change_room's shape (room + optional verifier key).
130
+ export function validateJoinRoom(msg) {
131
+ return validateChangeRoom(msg);
132
+ }
133
+
134
+ export function validateLeaveRoom(msg) {
112
135
  if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
113
136
  return { valid: false, error: 'Invalid room name (1-30 chars)' };
114
137
  }
@@ -118,19 +141,47 @@ export function validateChangeRoom(msg) {
118
141
  return { valid: true, room: msg.room.toLowerCase() };
119
142
  }
120
143
 
144
+ export function validateRoomAuth(msg) {
145
+ if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
146
+ return { valid: false, error: 'Invalid room name (1-30 chars)' };
147
+ }
148
+ if (!isValidBase64(msg.nonce, ROOM_CHALLENGE_NONCE_SIZE)) {
149
+ return { valid: false, error: 'Invalid challenge nonce' };
150
+ }
151
+ if (!isValidBase64(msg.signature, ROOM_AUTH_SIG_SIZE)) {
152
+ return { valid: false, error: 'Invalid challenge signature' };
153
+ }
154
+ return { valid: true, room: msg.room.toLowerCase(), nonce: msg.nonce, signature: msg.signature };
155
+ }
156
+
121
157
  export function validateListRooms() {
122
158
  return { valid: true };
123
159
  }
124
160
 
161
+ // Optional multi-room context on moderation commands: which room the owner is
162
+ // acting on. Absent → the server falls back to the session's only room.
163
+ function optionalRoom(msg) {
164
+ if (msg.room === undefined) {
165
+ return { ok: true, room: null };
166
+ }
167
+ const v = validateLeaveRoom({ room: msg.room });
168
+ return v.valid ? { ok: true, room: v.room } : { ok: false, error: v.error };
169
+ }
170
+
125
171
  export function validateKickPeer(msg) {
126
172
  const nick = sanitizeNickname(msg.targetNickname);
127
173
  if (!nick) {
128
174
  return { valid: false, error: 'Invalid target nickname' };
129
175
  }
176
+ const roomCheck = optionalRoom(msg);
177
+ if (!roomCheck.ok) {
178
+ return { valid: false, error: roomCheck.error };
179
+ }
130
180
  return {
131
181
  valid: true,
132
182
  targetNickname: nick,
133
183
  reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
184
+ room: roomCheck.room,
134
185
  };
135
186
  }
136
187
 
@@ -142,7 +193,11 @@ export function validateMutePeer(msg) {
142
193
  if (!isNumber(msg.durationMs) || msg.durationMs <= 0) {
143
194
  return { valid: false, error: 'Invalid mute duration' };
144
195
  }
145
- return { valid: true, targetNickname: nick, durationMs: msg.durationMs };
196
+ const roomCheck = optionalRoom(msg);
197
+ if (!roomCheck.ok) {
198
+ return { valid: false, error: roomCheck.error };
199
+ }
200
+ return { valid: true, targetNickname: nick, durationMs: msg.durationMs, room: roomCheck.room };
146
201
  }
147
202
 
148
203
  export function validateBanPeer(msg) {
@@ -150,10 +205,15 @@ export function validateBanPeer(msg) {
150
205
  if (!nick) {
151
206
  return { valid: false, error: 'Invalid target nickname' };
152
207
  }
208
+ const roomCheck = optionalRoom(msg);
209
+ if (!roomCheck.ok) {
210
+ return { valid: false, error: roomCheck.error };
211
+ }
153
212
  return {
154
213
  valid: true,
155
214
  targetNickname: nick,
156
215
  reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
216
+ room: roomCheck.room,
157
217
  };
158
218
  }
159
219
 
@@ -4,13 +4,14 @@ import { createLogger } from '../shared/logger.js';
4
4
  const log = createLogger('session');
5
5
 
6
6
  export class SessionManager {
7
- #sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, room }>
7
+ #sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, rooms: Set }>
8
8
  #nicknames; // Set<nickname> for quick dupe check
9
9
  #recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
10
10
  #rooms; // Map<roomName, Set<sessionId>>
11
11
  #roomOwners; // Map<roomName, sessionId>
12
12
  #muteState; // Map<sessionId, { until: timestamp }>
13
13
  #banList; // Map<roomName, Set<nickname_lower>>
14
+ #roomMeta; // Map<roomName, { authPk: base64 }> — private-room verifiers (memory only)
14
15
 
15
16
  constructor() {
16
17
  this.#sessions = new Map();
@@ -20,6 +21,7 @@ export class SessionManager {
20
21
  this.#roomOwners = new Map();
21
22
  this.#muteState = new Map();
22
23
  this.#banList = new Map();
24
+ this.#roomMeta = new Map();
23
25
  // Ensure default room exists
24
26
  this.#rooms.set('general', new Set());
25
27
  }
@@ -35,7 +37,7 @@ export class SessionManager {
35
37
  nickname,
36
38
  publicKey,
37
39
  connectedAt: Date.now(),
38
- room,
40
+ rooms: new Set(),
39
41
  };
40
42
 
41
43
  this.#sessions.set(sessionId, session);
@@ -52,7 +54,9 @@ export class SessionManager {
52
54
  return null;
53
55
  }
54
56
 
55
- this.#leaveRoom(sessionId);
57
+ for (const room of [...session.rooms]) {
58
+ this.#leaveRoom(sessionId, room);
59
+ }
56
60
  this.#nicknames.delete(session.nickname.toLowerCase());
57
61
  this.#sessions.delete(sessionId);
58
62
  this.#muteState.delete(sessionId);
@@ -91,7 +95,7 @@ export class SessionManager {
91
95
  const peers = [];
92
96
  for (const [id, session] of this.#sessions) {
93
97
  if (id !== excludeSessionId) {
94
- if (room && session.room !== room) {
98
+ if (room && !session.rooms.has(room)) {
95
99
  continue;
96
100
  }
97
101
  peers.push({
@@ -104,6 +108,35 @@ export class SessionManager {
104
108
  return peers;
105
109
  }
106
110
 
111
+ /**
112
+ * Send a message once to every session that shares at least one room with
113
+ * the given session (deduplicated — a peer in two shared rooms gets one).
114
+ */
115
+ broadcastToPeersOf(sessionId, msg, excludeSessionId = sessionId) {
116
+ const session = this.#sessions.get(sessionId);
117
+ if (!session) {
118
+ return;
119
+ }
120
+ const data = JSON.stringify(msg);
121
+ const notified = new Set();
122
+ for (const room of session.rooms) {
123
+ const members = this.#rooms.get(room);
124
+ if (!members) {
125
+ continue;
126
+ }
127
+ for (const sid of members) {
128
+ if (sid === excludeSessionId || notified.has(sid)) {
129
+ continue;
130
+ }
131
+ notified.add(sid);
132
+ const peer = this.#sessions.get(sid);
133
+ if (peer && peer.ws.readyState === 1) {
134
+ peer.ws.send(data);
135
+ }
136
+ }
137
+ }
138
+ }
139
+
107
140
  /**
108
141
  * Send a JSON message to all sessions except one.
109
142
  */
@@ -145,6 +178,7 @@ export class SessionManager {
145
178
  this.#rooms.set(room, new Set());
146
179
  }
147
180
  this.#rooms.get(room).add(sessionId);
181
+ this.#sessions.get(sessionId)?.rooms.add(room);
148
182
 
149
183
  // First person to create/join an empty non-general room becomes owner
150
184
  if (isNew && room !== 'general' && !this.#roomOwners.has(room)) {
@@ -153,13 +187,13 @@ export class SessionManager {
153
187
  }
154
188
  }
155
189
 
156
- #leaveRoom(sessionId) {
190
+ #leaveRoom(sessionId, room) {
157
191
  const session = this.#sessions.get(sessionId);
158
192
  if (!session) {
159
193
  return;
160
194
  }
161
195
 
162
- const room = session.room;
196
+ session.rooms.delete(room);
163
197
  const members = this.#rooms.get(room);
164
198
  if (members) {
165
199
  members.delete(sessionId);
@@ -167,9 +201,12 @@ export class SessionManager {
167
201
  if (members.size === 0 && room !== 'general') {
168
202
  // Empty non-general room — drop it and all associated moderation state
169
203
  // (otherwise a recreated room stays owner-less and unmoderatable).
204
+ // The private-room verifier dies here too: the room and its password
205
+ // exist only while someone is inside.
170
206
  this.#rooms.delete(room);
171
207
  this.#roomOwners.delete(room);
172
208
  this.#banList.delete(room);
209
+ this.#roomMeta.delete(room);
173
210
  } else if (this.#roomOwners.get(room) === sessionId) {
174
211
  // Owner left but room still has members — transfer ownership so the
175
212
  // room keeps a moderator instead of becoming owner-less.
@@ -180,23 +217,68 @@ export class SessionManager {
180
217
  }
181
218
  }
182
219
 
220
+ /**
221
+ * Legacy single-room semantics (protocol `change_room`): leave every
222
+ * current room and end up only in `newRoom`.
223
+ */
183
224
  switchRoom(sessionId, newRoom) {
184
225
  const session = this.#sessions.get(sessionId);
185
226
  if (!session) {
186
227
  return null;
187
228
  }
188
229
 
189
- const oldRoom = session.room;
190
- if (oldRoom === newRoom) {
191
- return null;
230
+ if (session.rooms.size === 1 && session.rooms.has(newRoom)) {
231
+ return null; // already exactly there
192
232
  }
193
233
 
194
- this.#leaveRoom(sessionId);
195
- session.room = newRoom;
234
+ const oldRooms = [...session.rooms].filter((r) => r !== newRoom);
235
+ for (const room of oldRooms) {
236
+ this.#leaveRoom(sessionId, room);
237
+ }
196
238
  this.#joinRoom(sessionId, newRoom);
197
239
 
198
- log.info(`${session.nickname} switched room: ${oldRoom} → ${newRoom}`);
199
- return { oldRoom, newRoom };
240
+ log.info(`${session.nickname} switched room: ${oldRooms.join(',') || '-'} → ${newRoom}`);
241
+ return { oldRoom: oldRooms[0] || null, oldRooms, newRoom };
242
+ }
243
+
244
+ /**
245
+ * Multi-room: join an ADDITIONAL room, keeping the current ones.
246
+ * Returns null when already a member.
247
+ */
248
+ joinAdditional(sessionId, room) {
249
+ const session = this.#sessions.get(sessionId);
250
+ if (!session || session.rooms.has(room)) {
251
+ return null;
252
+ }
253
+ this.#joinRoom(sessionId, room);
254
+ log.info(`${session.nickname} joined room ${room} (now in ${session.rooms.size})`);
255
+ return { room };
256
+ }
257
+
258
+ /**
259
+ * Multi-room: leave one room. Refuses to leave the last one (a session is
260
+ * always somewhere — that keeps peer visibility and moderation coherent).
261
+ */
262
+ leaveOneRoom(sessionId, room) {
263
+ const session = this.#sessions.get(sessionId);
264
+ if (!session || !session.rooms.has(room)) {
265
+ return null;
266
+ }
267
+ if (session.rooms.size === 1) {
268
+ return { lastRoom: true };
269
+ }
270
+ this.#leaveRoom(sessionId, room);
271
+ log.info(`${session.nickname} left room ${room} (now in ${session.rooms.size})`);
272
+ return { room };
273
+ }
274
+
275
+ getSessionRooms(sessionId) {
276
+ const session = this.#sessions.get(sessionId);
277
+ return session ? [...session.rooms] : [];
278
+ }
279
+
280
+ isInRoom(sessionId, room) {
281
+ return this.#sessions.get(sessionId)?.rooms.has(room) === true;
200
282
  }
201
283
 
202
284
  getRoomPeers(room, excludeSessionId) {
@@ -207,19 +289,45 @@ export class SessionManager {
207
289
  const rooms = [];
208
290
  for (const [name, members] of this.#rooms) {
209
291
  if (members.size > 0) {
210
- rooms.push({ name, memberCount: members.size });
292
+ rooms.push({ name, memberCount: members.size, private: this.#roomMeta.has(name) });
211
293
  }
212
294
  }
213
295
  // Always include 'general' even if empty
214
296
  if (!rooms.some((r) => r.name === 'general')) {
215
- rooms.unshift({ name: 'general', memberCount: 0 });
297
+ rooms.unshift({ name: 'general', memberCount: 0, private: false });
216
298
  }
217
299
  return rooms.sort((a, b) => a.name.localeCompare(b.name));
218
300
  }
219
301
 
302
+ // ── Private rooms ────────────────────────────────────────────
303
+
304
+ roomHasMembers(room) {
305
+ const members = this.#rooms.get(room);
306
+ return !!members && members.size > 0;
307
+ }
308
+
309
+ setRoomPrivate(room, authPkB64) {
310
+ this.#roomMeta.set(room, { authPk: authPkB64 });
311
+ }
312
+
313
+ isRoomPrivate(room) {
314
+ return this.#roomMeta.has(room);
315
+ }
316
+
317
+ getRoomAuthPk(room) {
318
+ return this.#roomMeta.get(room)?.authPk || null;
319
+ }
320
+
321
+ /**
322
+ * Legacy helper: a session's room when it has exactly one; null otherwise.
323
+ * Multi-room callers must pass the room explicitly instead.
324
+ */
220
325
  getSessionRoom(sessionId) {
221
326
  const session = this.#sessions.get(sessionId);
222
- return session?.room || null;
327
+ if (!session) {
328
+ return null;
329
+ }
330
+ return session.rooms.size === 1 ? [...session.rooms][0] : null;
223
331
  }
224
332
 
225
333
  updatePublicKey(sessionId, newPublicKey) {