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.
- package/README.md +16 -2
- package/README.pt-BR.md +16 -2
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +40 -1
- package/package.json +4 -3
- package/src/client/ChatController.js +499 -145
- package/src/client/UI.js +135 -3
- package/src/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/protocol/messages.js +66 -17
- package/src/protocol/validators.js +46 -2
- package/src/server/SessionManager.js +101 -15
- package/src/server/WebSocketServer.js +227 -53
package/src/protocol/messages.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
return null;
|
|
232
|
+
if (session.rooms.size === 1 && session.rooms.has(newRoom)) {
|
|
233
|
+
return null; // already exactly there
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
|
|
200
|
-
|
|
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: ${
|
|
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
|
-
|
|
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) {
|