ciphermesh 2.1.0 → 2.3.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,6 +12,10 @@ 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',
17
21
  ROOM_CHALLENGE: 'room_challenge',
@@ -41,8 +45,14 @@ function base(type) {
41
45
  return { type, version: PROTOCOL_VERSION, timestamp: Date.now() };
42
46
  }
43
47
 
44
- export function createJoin(nickname, publicKeyB64) {
45
- return { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
48
+ // pqPublicKey (v3, optional): ML-KEM-768 encapsulation key for the hybrid
49
+ // post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
50
+ export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null) {
51
+ const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
52
+ if (pqPublicKeyB64) {
53
+ msg.pqPublicKey = pqPublicKeyB64;
54
+ }
55
+ return msg;
46
56
  }
47
57
 
48
58
  export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general') {
@@ -53,12 +63,22 @@ export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general
53
63
  return ack;
54
64
  }
55
65
 
56
- export function createPeerJoined(peer) {
57
- return { ...base(MSG.PEER_JOINED), peer };
66
+ // `room` (multi-room, additive): which room this event refers to. A peer can
67
+ // leave room X while still sharing room Y with you. Old clients ignore it.
68
+ export function createPeerJoined(peer, room = null) {
69
+ const msg = { ...base(MSG.PEER_JOINED), peer };
70
+ if (room) {
71
+ msg.room = room;
72
+ }
73
+ return msg;
58
74
  }
59
75
 
60
- export function createPeerLeft(sessionId, nickname) {
61
- return { ...base(MSG.PEER_LEFT), sessionId, nickname };
76
+ export function createPeerLeft(sessionId, nickname, room = null) {
77
+ const msg = { ...base(MSG.PEER_LEFT), sessionId, nickname };
78
+ if (room) {
79
+ msg.room = room;
80
+ }
81
+ return msg;
62
82
  }
63
83
 
64
84
  export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
@@ -71,18 +91,20 @@ export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
71
91
  }
72
92
 
73
93
  export function createRatchetedMessage(from, to, payload) {
74
- return {
75
- ...base(MSG.ENCRYPTED_MESSAGE),
76
- from,
77
- to,
78
- payload: {
79
- ephemeralPublicKey: payload.ephemeralPublicKey.toString('base64'),
80
- counter: payload.counter,
81
- previousCounter: payload.previousCounter,
82
- ciphertext: payload.ciphertext.toString('base64'),
83
- nonce: payload.nonce.toString('base64'),
84
- },
94
+ const inner = {
95
+ ephemeralPublicKey: payload.ephemeralPublicKey.toString('base64'),
96
+ counter: payload.counter,
97
+ previousCounter: payload.previousCounter,
98
+ ciphertext: payload.ciphertext.toString('base64'),
99
+ nonce: payload.nonce.toString('base64'),
85
100
  };
101
+ // Hybrid PQ: the KEM ciphertext rides along until the peer has folded it in.
102
+ // It is sealed to the recipient like everything else — the relay sees only
103
+ // an opaque blob.
104
+ if (payload.pqCiphertext) {
105
+ inner.pqCiphertext = payload.pqCiphertext.toString('base64');
106
+ }
107
+ return { ...base(MSG.ENCRYPTED_MESSAGE), from, to, payload: inner };
86
108
  }
87
109
 
88
110
  // Sealed-sender envelope (protocol v2): the relay sees only the recipient and an
@@ -131,6 +153,33 @@ export function createRoomChanged(room, peers, isPrivate = false) {
131
153
  return msg;
132
154
  }
133
155
 
156
+ // ── Multi-room (IRC-style buffers) ─────────────────────────────
157
+ // join_room ADDS a membership (unlike change_room, which replaces them all).
158
+ // Same optional roomAuthPk as change_room for creating a private room.
159
+ export function createJoinRoom(room, roomAuthPkB64 = null) {
160
+ const msg = { ...base(MSG.JOIN_ROOM), room };
161
+ if (roomAuthPkB64) {
162
+ msg.roomAuthPk = roomAuthPkB64;
163
+ }
164
+ return msg;
165
+ }
166
+
167
+ export function createRoomJoined(room, peers, isPrivate = false) {
168
+ const msg = { ...base(MSG.ROOM_JOINED), room, peers };
169
+ if (isPrivate) {
170
+ msg.private = true;
171
+ }
172
+ return msg;
173
+ }
174
+
175
+ export function createLeaveRoom(room) {
176
+ return { ...base(MSG.LEAVE_ROOM), room };
177
+ }
178
+
179
+ export function createRoomLeft(room) {
180
+ return { ...base(MSG.ROOM_LEFT), room };
181
+ }
182
+
134
183
  // Server → client: the target room is private; prove password knowledge by
135
184
  // signing this nonce. The password itself never travels.
136
185
  export function createRoomChallenge(room, nonceB64) {
@@ -7,6 +7,7 @@ import {
7
7
  ROOM_AUTH_SIG_SIZE,
8
8
  ROOM_CHALLENGE_NONCE_SIZE,
9
9
  } from '../shared/constants.js';
10
+ import { PQ_PUBLIC_KEY_SIZE } from '../crypto/PQHybrid.js';
10
11
 
11
12
  // ── Helpers ────────────────────────────────────────────────────
12
13
  function isString(v) {
@@ -86,7 +87,11 @@ export function validateJoin(msg) {
86
87
  if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
87
88
  return { valid: false, error: 'Invalid public key' };
88
89
  }
89
- return { valid: true, nickname: nick };
90
+ // Optional ML-KEM-768 key (hybrid PQ). Absent = classical-only client.
91
+ if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
92
+ return { valid: false, error: 'Invalid post-quantum public key' };
93
+ }
94
+ return { valid: true, nickname: nick, pqPublicKey: msg.pqPublicKey || null };
90
95
  }
91
96
 
92
97
  // Sealed sender (protocol v2): the relay only ever sees the recipient and an
@@ -126,6 +131,21 @@ export function validateChangeRoom(msg) {
126
131
  return { valid: true, room: msg.room.toLowerCase(), roomAuthPk: msg.roomAuthPk || null };
127
132
  }
128
133
 
134
+ // join_room shares change_room's shape (room + optional verifier key).
135
+ export function validateJoinRoom(msg) {
136
+ return validateChangeRoom(msg);
137
+ }
138
+
139
+ export function validateLeaveRoom(msg) {
140
+ if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
141
+ return { valid: false, error: 'Invalid room name (1-30 chars)' };
142
+ }
143
+ if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
144
+ return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
145
+ }
146
+ return { valid: true, room: msg.room.toLowerCase() };
147
+ }
148
+
129
149
  export function validateRoomAuth(msg) {
130
150
  if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
131
151
  return { valid: false, error: 'Invalid room name (1-30 chars)' };
@@ -143,15 +163,30 @@ export function validateListRooms() {
143
163
  return { valid: true };
144
164
  }
145
165
 
166
+ // Optional multi-room context on moderation commands: which room the owner is
167
+ // acting on. Absent → the server falls back to the session's only room.
168
+ function optionalRoom(msg) {
169
+ if (msg.room === undefined) {
170
+ return { ok: true, room: null };
171
+ }
172
+ const v = validateLeaveRoom({ room: msg.room });
173
+ return v.valid ? { ok: true, room: v.room } : { ok: false, error: v.error };
174
+ }
175
+
146
176
  export function validateKickPeer(msg) {
147
177
  const nick = sanitizeNickname(msg.targetNickname);
148
178
  if (!nick) {
149
179
  return { valid: false, error: 'Invalid target nickname' };
150
180
  }
181
+ const roomCheck = optionalRoom(msg);
182
+ if (!roomCheck.ok) {
183
+ return { valid: false, error: roomCheck.error };
184
+ }
151
185
  return {
152
186
  valid: true,
153
187
  targetNickname: nick,
154
188
  reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
189
+ room: roomCheck.room,
155
190
  };
156
191
  }
157
192
 
@@ -163,7 +198,11 @@ export function validateMutePeer(msg) {
163
198
  if (!isNumber(msg.durationMs) || msg.durationMs <= 0) {
164
199
  return { valid: false, error: 'Invalid mute duration' };
165
200
  }
166
- return { valid: true, targetNickname: nick, durationMs: msg.durationMs };
201
+ const roomCheck = optionalRoom(msg);
202
+ if (!roomCheck.ok) {
203
+ return { valid: false, error: roomCheck.error };
204
+ }
205
+ return { valid: true, targetNickname: nick, durationMs: msg.durationMs, room: roomCheck.room };
167
206
  }
168
207
 
169
208
  export function validateBanPeer(msg) {
@@ -171,10 +210,15 @@ export function validateBanPeer(msg) {
171
210
  if (!nick) {
172
211
  return { valid: false, error: 'Invalid target nickname' };
173
212
  }
213
+ const roomCheck = optionalRoom(msg);
214
+ if (!roomCheck.ok) {
215
+ return { valid: false, error: roomCheck.error };
216
+ }
174
217
  return {
175
218
  valid: true,
176
219
  targetNickname: nick,
177
220
  reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
221
+ room: roomCheck.room,
178
222
  };
179
223
  }
180
224
 
@@ -4,7 +4,7 @@ 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>>
@@ -30,14 +30,15 @@ export class SessionManager {
30
30
  return this.#nicknames.has(nickname.toLowerCase());
31
31
  }
32
32
 
33
- addSession(ws, nickname, publicKey, room = 'general') {
33
+ addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null) {
34
34
  const sessionId = randomUUID();
35
35
  const session = {
36
36
  ws,
37
37
  nickname,
38
38
  publicKey,
39
+ pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
39
40
  connectedAt: Date.now(),
40
- room,
41
+ rooms: new Set(),
41
42
  };
42
43
 
43
44
  this.#sessions.set(sessionId, session);
@@ -54,7 +55,9 @@ export class SessionManager {
54
55
  return null;
55
56
  }
56
57
 
57
- this.#leaveRoom(sessionId);
58
+ for (const room of [...session.rooms]) {
59
+ this.#leaveRoom(sessionId, room);
60
+ }
58
61
  this.#nicknames.delete(session.nickname.toLowerCase());
59
62
  this.#sessions.delete(sessionId);
60
63
  this.#muteState.delete(sessionId);
@@ -93,19 +96,49 @@ export class SessionManager {
93
96
  const peers = [];
94
97
  for (const [id, session] of this.#sessions) {
95
98
  if (id !== excludeSessionId) {
96
- if (room && session.room !== room) {
99
+ if (room && !session.rooms.has(room)) {
97
100
  continue;
98
101
  }
99
102
  peers.push({
100
103
  sessionId: id,
101
104
  nickname: session.nickname,
102
105
  publicKey: session.publicKey,
106
+ ...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
103
107
  });
104
108
  }
105
109
  }
106
110
  return peers;
107
111
  }
108
112
 
113
+ /**
114
+ * Send a message once to every session that shares at least one room with
115
+ * the given session (deduplicated — a peer in two shared rooms gets one).
116
+ */
117
+ broadcastToPeersOf(sessionId, msg, excludeSessionId = sessionId) {
118
+ const session = this.#sessions.get(sessionId);
119
+ if (!session) {
120
+ return;
121
+ }
122
+ const data = JSON.stringify(msg);
123
+ const notified = new Set();
124
+ for (const room of session.rooms) {
125
+ const members = this.#rooms.get(room);
126
+ if (!members) {
127
+ continue;
128
+ }
129
+ for (const sid of members) {
130
+ if (sid === excludeSessionId || notified.has(sid)) {
131
+ continue;
132
+ }
133
+ notified.add(sid);
134
+ const peer = this.#sessions.get(sid);
135
+ if (peer && peer.ws.readyState === 1) {
136
+ peer.ws.send(data);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
109
142
  /**
110
143
  * Send a JSON message to all sessions except one.
111
144
  */
@@ -147,6 +180,7 @@ export class SessionManager {
147
180
  this.#rooms.set(room, new Set());
148
181
  }
149
182
  this.#rooms.get(room).add(sessionId);
183
+ this.#sessions.get(sessionId)?.rooms.add(room);
150
184
 
151
185
  // First person to create/join an empty non-general room becomes owner
152
186
  if (isNew && room !== 'general' && !this.#roomOwners.has(room)) {
@@ -155,13 +189,13 @@ export class SessionManager {
155
189
  }
156
190
  }
157
191
 
158
- #leaveRoom(sessionId) {
192
+ #leaveRoom(sessionId, room) {
159
193
  const session = this.#sessions.get(sessionId);
160
194
  if (!session) {
161
195
  return;
162
196
  }
163
197
 
164
- const room = session.room;
198
+ session.rooms.delete(room);
165
199
  const members = this.#rooms.get(room);
166
200
  if (members) {
167
201
  members.delete(sessionId);
@@ -185,23 +219,68 @@ export class SessionManager {
185
219
  }
186
220
  }
187
221
 
222
+ /**
223
+ * Legacy single-room semantics (protocol `change_room`): leave every
224
+ * current room and end up only in `newRoom`.
225
+ */
188
226
  switchRoom(sessionId, newRoom) {
189
227
  const session = this.#sessions.get(sessionId);
190
228
  if (!session) {
191
229
  return null;
192
230
  }
193
231
 
194
- const oldRoom = session.room;
195
- if (oldRoom === newRoom) {
196
- return null;
232
+ if (session.rooms.size === 1 && session.rooms.has(newRoom)) {
233
+ return null; // already exactly there
197
234
  }
198
235
 
199
- this.#leaveRoom(sessionId);
200
- session.room = newRoom;
236
+ const oldRooms = [...session.rooms].filter((r) => r !== newRoom);
237
+ for (const room of oldRooms) {
238
+ this.#leaveRoom(sessionId, room);
239
+ }
201
240
  this.#joinRoom(sessionId, newRoom);
202
241
 
203
- log.info(`${session.nickname} switched room: ${oldRoom} → ${newRoom}`);
204
- return { oldRoom, newRoom };
242
+ log.info(`${session.nickname} switched room: ${oldRooms.join(',') || '-'} → ${newRoom}`);
243
+ return { oldRoom: oldRooms[0] || null, oldRooms, newRoom };
244
+ }
245
+
246
+ /**
247
+ * Multi-room: join an ADDITIONAL room, keeping the current ones.
248
+ * Returns null when already a member.
249
+ */
250
+ joinAdditional(sessionId, room) {
251
+ const session = this.#sessions.get(sessionId);
252
+ if (!session || session.rooms.has(room)) {
253
+ return null;
254
+ }
255
+ this.#joinRoom(sessionId, room);
256
+ log.info(`${session.nickname} joined room ${room} (now in ${session.rooms.size})`);
257
+ return { room };
258
+ }
259
+
260
+ /**
261
+ * Multi-room: leave one room. Refuses to leave the last one (a session is
262
+ * always somewhere — that keeps peer visibility and moderation coherent).
263
+ */
264
+ leaveOneRoom(sessionId, room) {
265
+ const session = this.#sessions.get(sessionId);
266
+ if (!session || !session.rooms.has(room)) {
267
+ return null;
268
+ }
269
+ if (session.rooms.size === 1) {
270
+ return { lastRoom: true };
271
+ }
272
+ this.#leaveRoom(sessionId, room);
273
+ log.info(`${session.nickname} left room ${room} (now in ${session.rooms.size})`);
274
+ return { room };
275
+ }
276
+
277
+ getSessionRooms(sessionId) {
278
+ const session = this.#sessions.get(sessionId);
279
+ return session ? [...session.rooms] : [];
280
+ }
281
+
282
+ isInRoom(sessionId, room) {
283
+ return this.#sessions.get(sessionId)?.rooms.has(room) === true;
205
284
  }
206
285
 
207
286
  getRoomPeers(room, excludeSessionId) {
@@ -241,9 +320,16 @@ export class SessionManager {
241
320
  return this.#roomMeta.get(room)?.authPk || null;
242
321
  }
243
322
 
323
+ /**
324
+ * Legacy helper: a session's room when it has exactly one; null otherwise.
325
+ * Multi-room callers must pass the room explicitly instead.
326
+ */
244
327
  getSessionRoom(sessionId) {
245
328
  const session = this.#sessions.get(sessionId);
246
- return session?.room || null;
329
+ if (!session) {
330
+ return null;
331
+ }
332
+ return session.rooms.size === 1 ? [...session.rooms][0] : null;
247
333
  }
248
334
 
249
335
  updatePublicKey(sessionId, newPublicKey) {