ciphermesh 2.10.0 → 2.12.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/CHANGELOG.md +142 -0
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PROTOCOL.md +556 -0
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +2 -2
- package/src/client/ChatController.js +461 -60
- package/src/crypto/SenderKey.js +201 -10
- package/src/p2p/P2PChatController.js +2 -0
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +50 -4
- package/src/protocol/validators.js +73 -1
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +94 -4
- package/src/shared/constants.js +35 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ROOM_CHALLENGE_TTL_MS,
|
|
12
12
|
ROOM_AUTH_MAX_FAILS,
|
|
13
13
|
ROOM_AUTH_FAIL_WINDOW_MS,
|
|
14
|
+
SERVER_CAPABILITIES,
|
|
14
15
|
} from '../shared/constants.js';
|
|
15
16
|
import {
|
|
16
17
|
MSG,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
parseMessage,
|
|
33
34
|
validateJoin,
|
|
34
35
|
validateEncryptedMessage,
|
|
36
|
+
validateGroupMessage,
|
|
35
37
|
validateKeyUpdate,
|
|
36
38
|
validateChangeRoom,
|
|
37
39
|
validateJoinRoom,
|
|
@@ -230,6 +232,10 @@ export class SecureWSServer {
|
|
|
230
232
|
this.#handleEncryptedMessage(ws, msg);
|
|
231
233
|
break;
|
|
232
234
|
|
|
235
|
+
case MSG.GROUP_MESSAGE:
|
|
236
|
+
this.#handleGroupMessage(ws, msg);
|
|
237
|
+
break;
|
|
238
|
+
|
|
233
239
|
case MSG.KEY_UPDATE:
|
|
234
240
|
this.#handleKeyUpdate(ws, msg);
|
|
235
241
|
break;
|
|
@@ -303,6 +309,7 @@ export class SecureWSServer {
|
|
|
303
309
|
msg.publicKey,
|
|
304
310
|
room,
|
|
305
311
|
validation.pqPublicKey,
|
|
312
|
+
validation.capabilities,
|
|
306
313
|
);
|
|
307
314
|
ws.sessionId = sessionId;
|
|
308
315
|
ws.hasJoined = true;
|
|
@@ -313,7 +320,7 @@ export class SecureWSServer {
|
|
|
313
320
|
|
|
314
321
|
// Send ACK with peer list (room-scoped)
|
|
315
322
|
const peers = this.#sessionManager.getRoomPeers(room, sessionId);
|
|
316
|
-
const joinAck = createJoinAck(sessionId, peers, queued.length, room);
|
|
323
|
+
const joinAck = createJoinAck(sessionId, peers, queued.length, room, SERVER_CAPABILITIES);
|
|
317
324
|
const ownerSid = this.#sessionManager.getRoomOwner(room);
|
|
318
325
|
if (ownerSid) {
|
|
319
326
|
const ownerSession = this.#sessionManager.getSession(ownerSid);
|
|
@@ -340,6 +347,7 @@ export class SecureWSServer {
|
|
|
340
347
|
nickname: validation.nickname,
|
|
341
348
|
publicKey: msg.publicKey,
|
|
342
349
|
...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
|
|
350
|
+
...(validation.capabilities.length ? { caps: [...validation.capabilities] } : {}),
|
|
343
351
|
}),
|
|
344
352
|
sessionId,
|
|
345
353
|
);
|
|
@@ -374,6 +382,63 @@ export class SecureWSServer {
|
|
|
374
382
|
this.#messageRouter.route(ws.sessionId, msg);
|
|
375
383
|
}
|
|
376
384
|
|
|
385
|
+
// One ciphertext in, one copy to each member of the room. The relay reads the
|
|
386
|
+
// room and nothing else: no `to` to route by, and no `from` to stamp on the
|
|
387
|
+
// way out — a room-addressed envelope must not become the one place the relay
|
|
388
|
+
// asserts who is speaking.
|
|
389
|
+
#handleGroupMessage(ws, msg) {
|
|
390
|
+
if (!ws.hasJoined || !ws.sessionId) {
|
|
391
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (this.#sessionManager.isMuted(ws.sessionId)) {
|
|
396
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are muted')));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const validation = validateGroupMessage(msg);
|
|
401
|
+
if (!validation.valid) {
|
|
402
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// A member may only address a room it is in. Without this, one connection
|
|
407
|
+
// could inject into every room on the hub at once — the per-peer path has no
|
|
408
|
+
// equivalent, because it needs a sessionId it could only have been told.
|
|
409
|
+
if (!this.#sessionManager.isInRoom(ws.sessionId, validation.room)) {
|
|
410
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Not in that room')));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Same per-sender budget as the unicast path. One frame in now costs the
|
|
415
|
+
// relay N frames out instead of one, so the limit that used to be applied
|
|
416
|
+
// N times per line is applied once — charging less here than there would
|
|
417
|
+
// turn the saving into an amplifier.
|
|
418
|
+
if (!this.#messageRouter.allowFrom(ws.sessionId)) {
|
|
419
|
+
ws.send(JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many messages per second')));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Deliberately NOT queued for absent members, unlike the unicast path.
|
|
424
|
+
//
|
|
425
|
+
// A member who was away could not read it anyway: a sender key handed over
|
|
426
|
+
// on their return serialises the chain at its *current* counter, so anything
|
|
427
|
+
// sent while they were gone stays shut — the forward secrecy the ratchet
|
|
428
|
+
// buys, pinned in chat-controller.test.js. Queueing would therefore store
|
|
429
|
+
// ciphertext on the relay that provably nobody can open: all of the cost and
|
|
430
|
+
// the liability of holding it, and none of the delivery.
|
|
431
|
+
//
|
|
432
|
+
// The unicast queue survives because an envelope addressed to a peer is
|
|
433
|
+
// still openable when they come back with the same key. That is not true
|
|
434
|
+
// here, and it is the difference that decides it.
|
|
435
|
+
delete msg.from;
|
|
436
|
+
this.#sessionManager.broadcastToRoom(validation.room, msg, ws.sessionId);
|
|
437
|
+
// Never log the sender or the room membership this reveals — only that a
|
|
438
|
+
// fan-out happened. Correlating would defeat what sealed sender buys.
|
|
439
|
+
log.debug('group message fanned out');
|
|
440
|
+
}
|
|
441
|
+
|
|
377
442
|
#handleKeyUpdate(ws, msg) {
|
|
378
443
|
if (!ws.hasJoined || !ws.sessionId) {
|
|
379
444
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
@@ -711,6 +776,27 @@ export class SecureWSServer {
|
|
|
711
776
|
this.#finishRoomSwitch(ws, session, result);
|
|
712
777
|
}
|
|
713
778
|
|
|
779
|
+
// One session stopped being in one room. Every way out of a room ends here:
|
|
780
|
+
// leaving, switching, disconnecting, and — since this was the gap — being
|
|
781
|
+
// kicked or banned.
|
|
782
|
+
//
|
|
783
|
+
// Kick and ban used to announce only `peer_kicked`, which carries a nickname
|
|
784
|
+
// and no session. Clients had nothing to remove a member *by*, so the removed
|
|
785
|
+
// peer stayed in every remaining roster. Harmless-looking while the relay path
|
|
786
|
+
// seals one envelope per peer; not harmless at all once a room encrypts once
|
|
787
|
+
// to a shared chain, because "who is still in this room" is then the input to
|
|
788
|
+
// when that chain must be rotated away from someone.
|
|
789
|
+
#emitRoomDeparture(room, sessionId, nickname) {
|
|
790
|
+
if (!room || !sessionId) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
this.#sessionManager.broadcastToRoom(
|
|
794
|
+
room,
|
|
795
|
+
createPeerLeft(sessionId, nickname || 'Unknown', room),
|
|
796
|
+
sessionId,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
714
800
|
// Shared tail of a successful room switch: notify every old room and send
|
|
715
801
|
// the ROOM_CHANGED (with the private flag) to the mover.
|
|
716
802
|
#finishRoomSwitch(ws, session, result) {
|
|
@@ -837,11 +923,14 @@ export class SecureWSServer {
|
|
|
837
923
|
if (result) {
|
|
838
924
|
const targetSession = this.#sessionManager.getSession(targetSessionId);
|
|
839
925
|
|
|
840
|
-
// Notify old room
|
|
926
|
+
// Notify old room. The kick goes first so a client can mark the session
|
|
927
|
+
// before the peer_left lands and report it as a kick rather than a
|
|
928
|
+
// departure; both travel the same socket, so the order holds.
|
|
841
929
|
this.#sessionManager.broadcastToRoom(
|
|
842
930
|
room,
|
|
843
|
-
createPeerKicked(validation.targetNickname, validation.reason),
|
|
931
|
+
createPeerKicked(validation.targetNickname, validation.reason, targetSessionId),
|
|
844
932
|
);
|
|
933
|
+
this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
|
|
845
934
|
|
|
846
935
|
// Notify target with room change + kick reason
|
|
847
936
|
const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
|
|
@@ -953,8 +1042,9 @@ export class SecureWSServer {
|
|
|
953
1042
|
if (result) {
|
|
954
1043
|
this.#sessionManager.broadcastToRoom(
|
|
955
1044
|
room,
|
|
956
|
-
createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
|
|
1045
|
+
createPeerKicked(validation.targetNickname, validation.reason || 'banned', targetSessionId),
|
|
957
1046
|
);
|
|
1047
|
+
this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
|
|
958
1048
|
|
|
959
1049
|
const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
|
|
960
1050
|
targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
|
package/src/shared/constants.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
export const PROTOCOL_VERSION = 2; // v2: sealed sender (encrypted_message carries `sealed`, no `from`)
|
|
2
2
|
|
|
3
|
+
// ── Capability negotiation ─────────────────────────────────────
|
|
4
|
+
// PROTOCOL_VERSION is an exact-equality gate, so it cannot express "newer, but
|
|
5
|
+
// still able to talk to you". Capabilities fill that gap: a client lists what it
|
|
6
|
+
// can do in JOIN, the relay hands the list on verbatim with the peer list, and a
|
|
7
|
+
// feature turns on only when every member of the room advertises it. Absent or
|
|
8
|
+
// empty means an older peer, which is the safe default rather than an error.
|
|
9
|
+
export const CAP = {
|
|
10
|
+
// Group encryption on the relay path — one ciphertext for the whole room
|
|
11
|
+
// instead of one sealed envelope per peer. See
|
|
12
|
+
// docs/design/sender-keys-on-relay.md.
|
|
13
|
+
//
|
|
14
|
+
// From a client it means "I can *receive* a group message": I accept a sender
|
|
15
|
+
// key over the pairwise channel and can decrypt what that chain produces.
|
|
16
|
+
// From the relay it means "I can fan a room-addressed message out". Receive
|
|
17
|
+
// and fan-out land a release before anyone sends, so that by the time a sender
|
|
18
|
+
// exists, every advertised room can already read it.
|
|
19
|
+
SENDER_KEYS: 'sk1',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// What this client advertises. SENDER_KEYS is honest here: the receive path
|
|
23
|
+
// exists. Nothing sends group messages yet.
|
|
24
|
+
export const OWN_CAPABILITIES = [CAP.SENDER_KEYS];
|
|
25
|
+
|
|
26
|
+
// What the relay advertises, in join_ack. A client cannot promise this on the
|
|
27
|
+
// relay's behalf — the fan-out is the relay's job — so a sender has to check the
|
|
28
|
+
// room *and* the hub it is sitting on before switching paths.
|
|
29
|
+
export const SERVER_CAPABILITIES = [CAP.SENDER_KEYS];
|
|
30
|
+
|
|
31
|
+
// Bounds. This list arrives from a public hub, so it is attacker-controlled.
|
|
32
|
+
export const MAX_CAPABILITIES = 16;
|
|
33
|
+
export const MAX_CAPABILITY_LENGTH = 24;
|
|
34
|
+
|
|
35
|
+
// Ed25519 detached signature on a group message (src/crypto/SenderKey.js).
|
|
36
|
+
export const SIGNATURE_SIZE = 64;
|
|
37
|
+
|
|
3
38
|
// Network
|
|
4
39
|
export const SERVER_PORT = 3600;
|
|
5
40
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|