ciphermesh 2.10.0 → 2.11.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 +85 -0
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PROTOCOL.md +486 -0
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +1 -1
- package/src/client/ChatController.js +231 -58
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +2 -0
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- 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 +66 -1
- package/src/shared/constants.js +35 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { MAX_CAPABILITIES, MAX_CAPABILITY_LENGTH, OWN_CAPABILITIES } from '../shared/constants.js';
|
|
2
|
+
|
|
3
|
+
// ── Capability negotiation ─────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `PROTOCOL_VERSION` is checked for exact equality, so it can only say "same" or
|
|
6
|
+
// "refuse to talk". It cannot express a client that is newer but still willing
|
|
7
|
+
// to speak the old way, which is precisely what rolling a protocol change
|
|
8
|
+
// through a public hub needs. Capabilities carry that: advertised in JOIN,
|
|
9
|
+
// relayed verbatim with the peer list, and read here.
|
|
10
|
+
//
|
|
11
|
+
// The rule for turning a feature on is deliberately the strict one — *every*
|
|
12
|
+
// member of the room must advertise it. One peer on an older build is enough to
|
|
13
|
+
// keep the whole room on the old path, which is the outcome that keeps a
|
|
14
|
+
// half-upgraded room readable.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defensive read of a capability list that arrived over the wire.
|
|
18
|
+
*
|
|
19
|
+
* The relay validates what it accepts, but a client should not depend on the
|
|
20
|
+
* relay having done so — this list is used to decide how to encrypt.
|
|
21
|
+
*
|
|
22
|
+
* @returns {string[]} the valid entries, or an empty list
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeCaps(caps) {
|
|
25
|
+
if (!Array.isArray(caps)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const clean = [];
|
|
29
|
+
for (const cap of caps.slice(0, MAX_CAPABILITIES)) {
|
|
30
|
+
if (typeof cap !== 'string' || cap.length === 0 || cap.length > MAX_CAPABILITY_LENGTH) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cap) || clean.includes(cap)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
clean.push(cap);
|
|
37
|
+
}
|
|
38
|
+
return clean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does a single peer record advertise `cap`? */
|
|
42
|
+
export function peerSupports(peer, cap) {
|
|
43
|
+
return normalizeCaps(peer?.caps).includes(cap);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Can the whole room use `cap`?
|
|
48
|
+
*
|
|
49
|
+
* True only when this client advertises it and so does every peer. An empty
|
|
50
|
+
* room is vacuously true — there is nobody who could fail to understand.
|
|
51
|
+
*
|
|
52
|
+
* A hostile relay can edit these lists, so it is worth being explicit about what
|
|
53
|
+
* that buys it. Stripping capabilities forces the room back onto the per-peer
|
|
54
|
+
* path, which is the status quo and reveals nothing new. Adding a capability a
|
|
55
|
+
* peer never claimed makes senders encrypt in a form that peer cannot read —
|
|
56
|
+
* denial of service, visible immediately as a room that stopped working, and
|
|
57
|
+
* never a way to read plaintext. The strict-consensus rule is what keeps the
|
|
58
|
+
* damage on that side of the line.
|
|
59
|
+
*
|
|
60
|
+
* @param {Iterable<{caps?: string[]}>} peers - the room's peers, excluding self
|
|
61
|
+
* @param {string} cap
|
|
62
|
+
* @param {string[]} [own] - what this build advertises
|
|
63
|
+
*/
|
|
64
|
+
export function roomSupports(peers, cap, own = OWN_CAPABILITIES) {
|
|
65
|
+
if (!normalizeCaps(own).includes(cap)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
for (const peer of peers) {
|
|
69
|
+
if (!peerSupports(peer, cap)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
package/src/protocol/messages.js
CHANGED
|
@@ -7,6 +7,7 @@ export const MSG = {
|
|
|
7
7
|
PEER_JOINED: 'peer_joined',
|
|
8
8
|
PEER_LEFT: 'peer_left',
|
|
9
9
|
ENCRYPTED_MESSAGE: 'encrypted_message',
|
|
10
|
+
GROUP_MESSAGE: 'group_message',
|
|
10
11
|
ERROR: 'error',
|
|
11
12
|
KEY_UPDATE: 'key_update',
|
|
12
13
|
PEER_KEY_UPDATED: 'peer_key_updated',
|
|
@@ -47,19 +48,31 @@ function base(type) {
|
|
|
47
48
|
|
|
48
49
|
// pqPublicKey (v3, optional): ML-KEM-768 encapsulation key for the hybrid
|
|
49
50
|
// post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
|
|
50
|
-
|
|
51
|
+
// caps (optional): what this client can do beyond the baseline protocol. Omitted
|
|
52
|
+
// when empty so the wire is byte-identical to a pre-capability client.
|
|
53
|
+
export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps = null) {
|
|
51
54
|
const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
52
55
|
if (pqPublicKeyB64) {
|
|
53
56
|
msg.pqPublicKey = pqPublicKeyB64;
|
|
54
57
|
}
|
|
58
|
+
if (Array.isArray(caps) && caps.length > 0) {
|
|
59
|
+
msg.caps = [...caps];
|
|
60
|
+
}
|
|
55
61
|
return msg;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
// serverCaps: what the relay itself can do, as opposed to what each peer can.
|
|
65
|
+
// A client cannot advertise on the relay's behalf, and some features (group
|
|
66
|
+
// fan-out) need the relay to play along. Omitted when empty so an ack from a
|
|
67
|
+
// pre-capability relay and one from a relay with nothing to offer look alike.
|
|
68
|
+
export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general', caps = null) {
|
|
59
69
|
const ack = { ...base(MSG.JOIN_ACK), sessionId, peers, room };
|
|
60
70
|
if (queuedCount > 0) {
|
|
61
71
|
ack.queuedCount = queuedCount;
|
|
62
72
|
}
|
|
73
|
+
if (Array.isArray(caps) && caps.length > 0) {
|
|
74
|
+
ack.serverCaps = [...caps];
|
|
75
|
+
}
|
|
63
76
|
return ack;
|
|
64
77
|
}
|
|
65
78
|
|
|
@@ -81,6 +94,30 @@ export function createPeerLeft(sessionId, nickname, room = null) {
|
|
|
81
94
|
return msg;
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
// Room-addressed group message. One ciphertext for the whole room, fanned out by
|
|
98
|
+
// the relay to the room's members.
|
|
99
|
+
//
|
|
100
|
+
// There is deliberately no `to` and no `from`. The relay routes on `room`, which
|
|
101
|
+
// it already knows the sender is in, and it must not stamp a sender on the way
|
|
102
|
+
// out — identity on this project's wire is only ever carried sealed. Recipients
|
|
103
|
+
// resolve `keyId` to a member through the sender key they were handed over the
|
|
104
|
+
// pairwise channel, so the label means something to members and nothing to the
|
|
105
|
+
// relay.
|
|
106
|
+
// `signature` is what makes the sender a *person* rather than just a member: the
|
|
107
|
+
// chain is symmetric, so without it any member of the room could write in
|
|
108
|
+
// anyone's name. See src/crypto/SenderKey.js.
|
|
109
|
+
export function createGroupMessage(room, packet) {
|
|
110
|
+
return {
|
|
111
|
+
...base(MSG.GROUP_MESSAGE),
|
|
112
|
+
room,
|
|
113
|
+
keyId: packet.keyId,
|
|
114
|
+
counter: packet.counter,
|
|
115
|
+
ciphertext: packet.ciphertext,
|
|
116
|
+
nonce: packet.nonce,
|
|
117
|
+
signature: packet.signature,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
85
122
|
return {
|
|
86
123
|
...base(MSG.ENCRYPTED_MESSAGE),
|
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
ROOM_AUTH_PK_SIZE,
|
|
7
7
|
ROOM_AUTH_SIG_SIZE,
|
|
8
8
|
ROOM_CHALLENGE_NONCE_SIZE,
|
|
9
|
+
MAX_CAPABILITIES,
|
|
10
|
+
MAX_CAPABILITY_LENGTH,
|
|
11
|
+
SIGNATURE_SIZE,
|
|
9
12
|
} from '../shared/constants.js';
|
|
10
13
|
import { PQ_PUBLIC_KEY_SIZE } from '../crypto/PQHybrid.js';
|
|
11
14
|
|
|
@@ -49,6 +52,34 @@ function sanitizeNickname(nick) {
|
|
|
49
52
|
return clean;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
// Capability list off the wire. Absent is the normal case — every client before
|
|
56
|
+
// capabilities existed — and means "supports nothing extra", not "malformed".
|
|
57
|
+
// Anything present but misshapen is rejected rather than filtered: the relay
|
|
58
|
+
// stores this list and hands it to other clients, and quietly forwarding junk
|
|
59
|
+
// makes a peer look capable of something it never claimed.
|
|
60
|
+
// Returns an array on success, or null to signal rejection.
|
|
61
|
+
function sanitizeCapabilities(caps) {
|
|
62
|
+
if (caps === undefined || caps === null) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(caps) || caps.length > MAX_CAPABILITIES) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const clean = [];
|
|
69
|
+
for (const cap of caps) {
|
|
70
|
+
if (!isString(cap) || cap.length === 0 || cap.length > MAX_CAPABILITY_LENGTH) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cap)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!clean.includes(cap)) {
|
|
77
|
+
clean.push(cap);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return clean;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
// ── Parse + validate incoming JSON ─────────────────────────────
|
|
53
84
|
export function parseMessage(raw) {
|
|
54
85
|
if (typeof raw === 'string' && raw.length > MAX_PAYLOAD_SIZE) {
|
|
@@ -100,7 +131,19 @@ export function validateJoin(msg) {
|
|
|
100
131
|
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
101
132
|
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
102
133
|
}
|
|
103
|
-
|
|
134
|
+
const capabilities = sanitizeCapabilities(msg.caps);
|
|
135
|
+
if (capabilities === null) {
|
|
136
|
+
return {
|
|
137
|
+
valid: false,
|
|
138
|
+
error: `Invalid capability list (max ${MAX_CAPABILITIES} entries of up to ${MAX_CAPABILITY_LENGTH} lowercase chars)`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
valid: true,
|
|
143
|
+
nickname: nick,
|
|
144
|
+
pqPublicKey: msg.pqPublicKey || null,
|
|
145
|
+
capabilities,
|
|
146
|
+
};
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
// Sealed sender (protocol v2): the relay only ever sees the recipient and an
|
|
@@ -125,6 +168,35 @@ export function validateKeyUpdate(msg) {
|
|
|
125
168
|
return { valid: true };
|
|
126
169
|
}
|
|
127
170
|
|
|
171
|
+
// Room-addressed group message. Like validateEncryptedMessage, the relay cannot
|
|
172
|
+
// and must not inspect the content — it checks only what it needs to route:
|
|
173
|
+
// a well-formed room, an opaque chain label, a sane counter, and a non-empty
|
|
174
|
+
// envelope. Membership of `room` is the caller's check, not this one's.
|
|
175
|
+
export function validateGroupMessage(msg) {
|
|
176
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
177
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
178
|
+
}
|
|
179
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
|
|
180
|
+
return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
|
|
181
|
+
}
|
|
182
|
+
if (!isString(msg.keyId) || msg.keyId.length === 0 || msg.keyId.length > 64) {
|
|
183
|
+
return { valid: false, error: 'Invalid sender key id' };
|
|
184
|
+
}
|
|
185
|
+
if (!isNumber(msg.counter) || !Number.isInteger(msg.counter) || msg.counter < 0) {
|
|
186
|
+
return { valid: false, error: 'Invalid counter' };
|
|
187
|
+
}
|
|
188
|
+
if (!isValidBase64(msg.ciphertext) || !isValidBase64(msg.nonce)) {
|
|
189
|
+
return { valid: false, error: 'Missing or invalid group ciphertext' };
|
|
190
|
+
}
|
|
191
|
+
// Presence and size only. The relay holds no signing keys and could not
|
|
192
|
+
// verify this if it wanted to — that is the recipient's job, and the recipient
|
|
193
|
+
// does it before touching the ratchet.
|
|
194
|
+
if (!isValidBase64(msg.signature, SIGNATURE_SIZE)) {
|
|
195
|
+
return { valid: false, error: 'Missing or invalid group signature' };
|
|
196
|
+
}
|
|
197
|
+
return { valid: true, room: msg.room.toLowerCase() };
|
|
198
|
+
}
|
|
199
|
+
|
|
128
200
|
export function validateChangeRoom(msg) {
|
|
129
201
|
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
130
202
|
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
@@ -59,6 +59,15 @@ export class MessageRouter {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Spend one unit of a sender's per-second budget without routing anything.
|
|
64
|
+
* For paths that deliver themselves (the room fan-out) but must not get a
|
|
65
|
+
* cheaper allowance than route() gives the unicast path.
|
|
66
|
+
*/
|
|
67
|
+
allowFrom(senderSessionId) {
|
|
68
|
+
return this.#checkRateLimit(senderSessionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
#checkRateLimit(sessionId) {
|
|
63
72
|
const now = Date.now();
|
|
64
73
|
const entry = this.#rateCounts.get(sessionId);
|
|
@@ -30,13 +30,14 @@ export class SessionManager {
|
|
|
30
30
|
return this.#nicknames.has(nickname.toLowerCase());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null) {
|
|
33
|
+
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null, capabilities = []) {
|
|
34
34
|
const sessionId = randomUUID();
|
|
35
35
|
const session = {
|
|
36
36
|
ws,
|
|
37
37
|
nickname,
|
|
38
38
|
publicKey,
|
|
39
39
|
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
40
|
+
capabilities, // advertised in JOIN, relayed verbatim — the relay never acts on these
|
|
40
41
|
connectedAt: Date.now(),
|
|
41
42
|
rooms: new Set(),
|
|
42
43
|
};
|
|
@@ -104,6 +105,7 @@ export class SessionManager {
|
|
|
104
105
|
nickname: session.nickname,
|
|
105
106
|
publicKey: session.publicKey,
|
|
106
107
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
108
|
+
...(session.capabilities?.length ? { caps: [...session.capabilities] } : {}),
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
}
|
|
@@ -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')));
|
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;
|