ciphermesh 2.11.0 → 2.13.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.
@@ -9,6 +9,9 @@ const TRUST_FILE = 'trusted-peers.json';
9
9
  export const TrustResult = {
10
10
  NEW_PEER: 'new_peer',
11
11
  TRUSTED: 'trusted',
12
+ // A key that is not the one on the record, but that the identity bound to
13
+ // this record has signed as another of its devices. Not a mismatch.
14
+ KNOWN_DEVICE: 'known_device',
12
15
  MISMATCH: 'mismatch',
13
16
  VERIFIED_MISMATCH: 'verified_mismatch',
14
17
  };
@@ -75,6 +78,18 @@ export class TrustStore {
75
78
 
76
79
  // Compare the FULL public key (256 bits), not the 64-bit fingerprint.
77
80
  // Fall back to the fingerprint only for legacy records without publicKey.
81
+ // Once a synced device list exists, it is the authority on which keys are
82
+ // this person — including about the key the record was built on. A revoked
83
+ // primary that stayed TRUSTED here would make revocation decorative.
84
+ if (Array.isArray(record.devices)) {
85
+ if (record.devices.includes(publicKeyB64)) {
86
+ record.lastSeen = Date.now();
87
+ this.#save();
88
+ return record.publicKey === publicKeyB64 ? TrustResult.TRUSTED : TrustResult.KNOWN_DEVICE;
89
+ }
90
+ return record.verified ? TrustResult.VERIFIED_MISMATCH : TrustResult.MISMATCH;
91
+ }
92
+
78
93
  const matches = record.publicKey
79
94
  ? record.publicKey === publicKeyB64
80
95
  : record.fingerprint === KeyManager.computeFingerprint(Buffer.from(publicKeyB64, 'base64'));
@@ -85,6 +100,18 @@ export class TrustStore {
85
100
  return TrustResult.TRUSTED;
86
101
  }
87
102
 
103
+ // Another device of the same person is not a key that changed.
104
+ //
105
+ // Every key here was put there by a device list signed by the identity
106
+ // bound to this record — see syncDevices, which is the only writer. So this
107
+ // is not "a second key we have seen before", it is "a key the identity you
108
+ // verified says is also them".
109
+ if (record.devices?.includes(publicKeyB64)) {
110
+ record.lastSeen = Date.now();
111
+ this.#save();
112
+ return TrustResult.KNOWN_DEVICE;
113
+ }
114
+
88
115
  if (record.verified) {
89
116
  return TrustResult.VERIFIED_MISMATCH;
90
117
  }
@@ -173,6 +200,132 @@ export class TrustStore {
173
200
  return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
174
201
  }
175
202
 
203
+ /**
204
+ * The same construction over Ed25519 identity keys instead of box keys.
205
+ *
206
+ * A separate domain tag, not the same one with different inputs: two
207
+ * protocols that can produce the same digits from different material are two
208
+ * protocols one of them can be tricked into accepting. Same width, because
209
+ * the reason 40 bits replaced 20 has not changed.
210
+ *
211
+ * Only ever used when *both* sides will use it — see
212
+ * ChatController#identitySasReady. A pair where one side compares identity
213
+ * keys and the other compares box keys would show two different codes to two
214
+ * people who are doing everything right.
215
+ */
216
+ static computeIdentitySAS(myIdentityPk, peerIdentityPk) {
217
+ const mine = Buffer.isBuffer(myIdentityPk) ? myIdentityPk : Buffer.from(myIdentityPk, 'base64');
218
+ const theirs = Buffer.isBuffer(peerIdentityPk)
219
+ ? peerIdentityPk
220
+ : Buffer.from(peerIdentityPk, 'base64');
221
+
222
+ const [first, second] = Buffer.compare(mine, theirs) <= 0 ? [mine, theirs] : [theirs, mine];
223
+ const context = Buffer.from('CipherMesh-IdentitySAS-v1');
224
+ const hash = Buffer.alloc(32);
225
+ sodium.crypto_generichash(hash, Buffer.concat([first, second, context]));
226
+
227
+ let num = 0n;
228
+ for (let i = 0; i < 5; i++) {
229
+ num = (num << 8n) | BigInt(hash[i]);
230
+ }
231
+ const digits = num.toString().padStart(13, '0');
232
+ return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
233
+ }
234
+
235
+ /**
236
+ * Bind an identity key to a peer's record, keeping whatever verification the
237
+ * record already carries.
238
+ *
239
+ * This is the migration, and it is only ever called when the binding is
240
+ * provable: the identity signed a device list naming the box key this record
241
+ * was built on, and that list arrived over a channel only the holder of that
242
+ * box key could have written to. So the identity is vouched for by exactly
243
+ * the thing the user already verified out of band — the same reasoning
244
+ * `autoUpdatePeer` uses to carry verification across a key rotation.
245
+ *
246
+ * Nothing here can *create* verification. A record that was never verified is
247
+ * bound and stays unverified.
248
+ *
249
+ * @returns {'bound'|'unchanged'|'conflict'|'unknown'}
250
+ */
251
+ bindIdentity(nickname, identityPkB64) {
252
+ const record = this.#store.get(nickname.toLowerCase());
253
+ if (!record) {
254
+ return 'unknown';
255
+ }
256
+ if (record.identityPk === identityPkB64) {
257
+ return 'unchanged';
258
+ }
259
+ // A second identity claiming a record that already has one. Never silently
260
+ // overwritten: for a verified record this is the loud case, and it is the
261
+ // shape a takeover would have.
262
+ if (record.identityPk) {
263
+ return 'conflict';
264
+ }
265
+ record.identityPk = identityPkB64;
266
+ record.lastSeen = Date.now();
267
+ this.#save();
268
+ return 'bound';
269
+ }
270
+
271
+ /** The identity bound to a peer, or null. */
272
+ identityFor(nickname) {
273
+ return this.#store.get(nickname.toLowerCase())?.identityPk ?? null;
274
+ }
275
+
276
+ /**
277
+ * Set the device keys an identity has signed for to exactly this set.
278
+ *
279
+ * A replacement, not an accumulation, and that is the whole of revocation on
280
+ * the receiving side: a device that has been removed from the list stops
281
+ * being one of this person's keys here too. Accumulating would leave every
282
+ * revoked key trusted forever, which is worse than not having revocation at
283
+ * all, because it would look like it worked.
284
+ *
285
+ * The only writer of `record.devices`, and it refuses unless the identity
286
+ * asking is the one already bound to the record. Without that check any
287
+ * signed list could write keys onto anybody's record, which is the attack
288
+ * this prevents rather than enables.
289
+ *
290
+ * The record's primary `publicKey` is left alone even when the list no longer
291
+ * names it: it is the historical fact of what the user compared digits over.
292
+ * `checkPeer` stops honouring it, which is the part that matters.
293
+ *
294
+ * @returns {{added: string[], removed: string[]}}
295
+ */
296
+ syncDevices(nickname, identityPkB64, boxKeys) {
297
+ const record = this.#store.get(nickname.toLowerCase());
298
+ if (!record || !record.identityPk || record.identityPk !== identityPkB64) {
299
+ return { added: [], removed: [] };
300
+ }
301
+ const before = new Set(record.devices ?? []);
302
+ const after = new Set(boxKeys);
303
+
304
+ const added = [...after].filter((key) => !before.has(key));
305
+ const removed = [...before].filter((key) => !after.has(key));
306
+
307
+ if (added.length > 0 || removed.length > 0 || !record.devices) {
308
+ record.devices = [...after];
309
+ record.lastSeen = Date.now();
310
+ this.#save();
311
+ }
312
+ return { added, removed };
313
+ }
314
+
315
+ /**
316
+ * Every box key this peer is currently known by.
317
+ *
318
+ * Once a list has been synced it is the answer on its own; before that, the
319
+ * verified key is all there is.
320
+ */
321
+ devicesFor(nickname) {
322
+ const record = this.#store.get(nickname.toLowerCase());
323
+ if (!record) {
324
+ return [];
325
+ }
326
+ return record.devices ? [...record.devices] : [record.publicKey].filter(Boolean);
327
+ }
328
+
176
329
  markVerified(nickname) {
177
330
  const key = nickname.toLowerCase();
178
331
  const record = this.#store.get(key);
@@ -47,6 +47,25 @@ import { COMMANDS } from '../client/UI.js';
47
47
  const TYPING_SEND_INTERVAL = 2000;
48
48
  const TYPING_EXPIRE_TIMEOUT = 3000;
49
49
 
50
+ // Commands that exist only on the relay side, and why — so the mesh can say so
51
+ // instead of guessing at a typo.
52
+ //
53
+ // `/device` is the interesting one. Multi-device is not merely unimplemented
54
+ // here: a mesh peer is keyed by *nickname* (`#peers` above is
55
+ // `Map<peerNickname, …>`) and has no session id, so two devices of one person
56
+ // collide on the very thing the peer map is indexed by. Supporting them means
57
+ // re-keying the mesh's whole model of who a peer is, which is a different
58
+ // design from the relay's and deliberately out of scope — see
59
+ // docs/design/multi-device.md.
60
+ const RELAY_ONLY = {
61
+ '/device':
62
+ 'In the mesh a peer is known by nickname, which is exactly what two of ' +
63
+ 'your devices would share, so it is a different design and not built yet.',
64
+ '/create': 'Rooms with an owner exist only where there is a relay to hold one.',
65
+ '/invite': 'There is no address to invite anyone to without a relay.',
66
+ '/nick': 'Your name here is the one you started with; there is no registry to change it in.',
67
+ };
68
+
50
69
  export class P2PChatController {
51
70
  #nickname;
52
71
  #connManager;
@@ -1996,6 +2015,7 @@ export class P2PChatController {
1996
2015
 
1997
2016
  case '/room':
1998
2017
  this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
2018
+ this.#ui.addInfoMessage(`Sending: ${this.#describeSendPath()}`);
1999
2019
  break;
2000
2020
 
2001
2021
  case '/tips': {
@@ -2108,6 +2128,14 @@ export class P2PChatController {
2108
2128
  break;
2109
2129
  }
2110
2130
  }
2131
+ // A relay-only command is not a typo, and guessing at one is worse than
2132
+ // saying nothing: `/device` currently suggests `/voice`, which sends
2133
+ // somebody looking in the wrong place entirely.
2134
+ const relayOnly = RELAY_ONLY[cmd];
2135
+ if (relayOnly) {
2136
+ this.#ui.addErrorMessage(`${cmd} needs a relay. ${relayOnly}`);
2137
+ return;
2138
+ }
2111
2139
  const suggestion = suggestCommand(cmd, COMMANDS);
2112
2140
  const hint = suggestion ? ` Did you mean ${suggestion}?` : ' Use /help';
2113
2141
  this.#ui.addErrorMessage(`Unknown command: ${cmd}.${hint}`);
@@ -2375,6 +2403,51 @@ export class P2PChatController {
2375
2403
  this.#broadcastPayload(payload, false, toPeer, room);
2376
2404
  }
2377
2405
 
2406
+ // Which path the next line you type will take, and what is holding it there.
2407
+ //
2408
+ // The mesh half of #481. The saving here is not frames — there is no relay to
2409
+ // fan anything out, so a mesh sends one frame per peer either way — it is
2410
+ // encryptions: one for the room, or one per peer. That is the cost the room
2411
+ // silently pays when something has pushed it back onto the pairwise path, and
2412
+ // until now nothing said so.
2413
+ //
2414
+ // The order matches the decision in #sendMessage, which is the only place
2415
+ // that chooses. Two of the reasons are relay-side concerns that do not exist
2416
+ // here (no hub to be older, no capability negotiation); one is a reason the
2417
+ // relay does not have — constant cover paces messages through pairwise slots
2418
+ // on purpose, because the timing guarantee is what it is for.
2419
+ groupSendStatus() {
2420
+ const inRoom = [...this.#peers.keys()].filter(
2421
+ (nick) => (this.#peerRooms.get(nick) || 'general') === this.#currentRoom,
2422
+ );
2423
+ if (this.#deniableMode) {
2424
+ return { group: false, reason: 'deniable', peers: inRoom.length };
2425
+ }
2426
+ if (this.#coverMode === 'constant') {
2427
+ return { group: false, reason: 'cover', peers: inRoom.length };
2428
+ }
2429
+ if (inRoom.length === 0) {
2430
+ return { group: false, reason: 'alone', peers: 0 };
2431
+ }
2432
+ return { group: true, reason: null, peers: inRoom.length };
2433
+ }
2434
+
2435
+ #describeSendPath() {
2436
+ const status = this.groupSendStatus();
2437
+ if (status.group) {
2438
+ return `one encryption for the room, sent to ${status.peers}`;
2439
+ }
2440
+ const cost = `${status.peers} encryption${status.peers === 1 ? '' : 's'} per message`;
2441
+ switch (status.reason) {
2442
+ case 'deniable':
2443
+ return `${cost} — deniable mode is on, and deniability is pairwise`;
2444
+ case 'cover':
2445
+ return `${cost} — constant cover paces messages through pairwise slots`;
2446
+ default:
2447
+ return 'nothing yet — no one else is in this room';
2448
+ }
2449
+ }
2450
+
2378
2451
  // Encrypt a room message ONCE and send the same ciphertext to every online
2379
2452
  // room peer — real group cryptography (O(1) encryption instead of O(N)).
2380
2453
  #sendRoomGroup(room, payload) {
@@ -50,7 +50,22 @@ function base(type) {
50
50
  // post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
51
51
  // caps (optional): what this client can do beyond the baseline protocol. Omitted
52
52
  // when empty so the wire is byte-identical to a pre-capability client.
53
- export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps = null) {
53
+ // identityKey: the Ed25519 key a device list will one day be signed with
54
+ // (docs/design/multi-device.md, step 2). Advertised now so the field is already
55
+ // in the field when something reads it, exactly as the capability list was.
56
+ //
57
+ // It is **unauthenticated here** and must stay that way in every reader's mind
58
+ // until step 3: the relay forwards it verbatim, so it can substitute one. What
59
+ // makes an identity key trustworthy is a signed device list checked against it,
60
+ // not its presence in a JOIN.
61
+ export function createJoin(
62
+ nickname,
63
+ publicKeyB64,
64
+ pqPublicKeyB64 = null,
65
+ caps = null,
66
+ identityKeyB64 = null,
67
+ deviceList = null,
68
+ ) {
54
69
  const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
55
70
  if (pqPublicKeyB64) {
56
71
  msg.pqPublicKey = pqPublicKeyB64;
@@ -58,6 +73,15 @@ export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps =
58
73
  if (Array.isArray(caps) && caps.length > 0) {
59
74
  msg.caps = [...caps];
60
75
  }
76
+ if (identityKeyB64) {
77
+ msg.identityKey = identityKeyB64;
78
+ }
79
+ // Only needed when the nickname is already held by another of your devices.
80
+ // Sent always when there is one, because a client cannot know in advance
81
+ // whether its other device is already online.
82
+ if (deviceList) {
83
+ msg.deviceList = deviceList;
84
+ }
61
85
  return msg;
62
86
  }
63
87
 
@@ -249,8 +273,17 @@ export function createBanPeer(targetNickname, reason = '') {
249
273
  return { ...base(MSG.BAN_PEER), targetNickname, reason };
250
274
  }
251
275
 
252
- export function createPeerKicked(nickname, reason = '') {
253
- return { ...base(MSG.PEER_KICKED), nickname, reason };
276
+ // `sessionId` (optional) names *which* session was removed. `nickname` cannot:
277
+ // it is not unique over time — /nick reassigns it — and a client that removed a
278
+ // peer by name would drop the wrong session whenever two people had ever shared
279
+ // one. Older clients ignore the field; it exists so a kick can be matched to the
280
+ // `peer_left` that follows it and reported as a kick rather than a departure.
281
+ export function createPeerKicked(nickname, reason = '', sessionId = null) {
282
+ const msg = { ...base(MSG.PEER_KICKED), nickname, reason };
283
+ if (sessionId) {
284
+ msg.sessionId = sessionId;
285
+ }
286
+ return msg;
254
287
  }
255
288
 
256
289
  export function createPeerMuted(nickname, durationMs) {
@@ -2,6 +2,7 @@ import {
2
2
  PROTOCOL_VERSION,
3
3
  MAX_NICKNAME_LENGTH,
4
4
  MAX_PAYLOAD_SIZE,
5
+ IDENTITY_KEY_SIZE,
5
6
  PUBLIC_KEY_SIZE,
6
7
  ROOM_AUTH_PK_SIZE,
7
8
  ROOM_AUTH_SIG_SIZE,
@@ -131,6 +132,19 @@ export function validateJoin(msg) {
131
132
  if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
132
133
  return { valid: false, error: 'Invalid post-quantum public key' };
133
134
  }
135
+ // Optional Ed25519 identity key (multi-device). Absent = a client from before
136
+ // it existed. Checked for shape only: the relay cannot tell whose key it is,
137
+ // and is not supposed to be able to.
138
+ if (msg.identityKey !== undefined && !isValidBase64(msg.identityKey, IDENTITY_KEY_SIZE)) {
139
+ return { valid: false, error: 'Invalid identity key' };
140
+ }
141
+ // Optional signed device list, used only to claim a nickname another of your
142
+ // own devices already holds. Shape here; the signature is checked where the
143
+ // claim is decided, because only there is it known what identity to check it
144
+ // against.
145
+ if (msg.deviceList !== undefined && (typeof msg.deviceList !== 'object' || !msg.deviceList)) {
146
+ return { valid: false, error: 'Invalid device list' };
147
+ }
134
148
  const capabilities = sanitizeCapabilities(msg.caps);
135
149
  if (capabilities === null) {
136
150
  return {
@@ -142,6 +156,8 @@ export function validateJoin(msg) {
142
156
  valid: true,
143
157
  nickname: nick,
144
158
  pqPublicKey: msg.pqPublicKey || null,
159
+ identityKey: msg.identityKey || null,
160
+ deviceList: msg.deviceList || null,
145
161
  capabilities,
146
162
  };
147
163
  }
@@ -5,7 +5,11 @@ const log = createLogger('session');
5
5
 
6
6
  export class SessionManager {
7
7
  #sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, rooms: Set }>
8
- #nicknames; // Set<nickname> for quick dupe check
8
+ // lowercased nickname -> the sessions holding it. A Set of names was enough
9
+ // while a name meant one connection; multi-device means several sessions can
10
+ // share one, and releasing the name when the *first* of them leaves would
11
+ // hand it to a stranger while its owner is still in the room.
12
+ #nicknames; // Map<lowerNickname, Set<sessionId>>
9
13
  #recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
10
14
  #rooms; // Map<roomName, Set<sessionId>>
11
15
  #roomOwners; // Map<roomName, sessionId>
@@ -15,7 +19,7 @@ export class SessionManager {
15
19
 
16
20
  constructor() {
17
21
  this.#sessions = new Map();
18
- this.#nicknames = new Set();
22
+ this.#nicknames = new Map();
19
23
  this.#recentlyLeft = new Map();
20
24
  this.#rooms = new Map();
21
25
  this.#roomOwners = new Map();
@@ -27,23 +31,68 @@ export class SessionManager {
27
31
  }
28
32
 
29
33
  isNicknameTaken(nickname) {
30
- return this.#nicknames.has(nickname.toLowerCase());
34
+ return (this.#nicknames.get(nickname.toLowerCase())?.size ?? 0) > 0;
31
35
  }
32
36
 
33
- addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null, capabilities = []) {
37
+ /**
38
+ * The identity key the sessions under this nickname are using, or null.
39
+ *
40
+ * Null when they disagree — which should not happen, since a second session
41
+ * is only admitted after matching, but a name whose holders cannot agree on
42
+ * an identity is not a name anything else should be admitted to.
43
+ */
44
+ identityForNickname(nickname) {
45
+ const holders = this.#nicknames.get(nickname.toLowerCase());
46
+ if (!holders || holders.size === 0) {
47
+ return null;
48
+ }
49
+ let identity = null;
50
+ for (const sessionId of holders) {
51
+ const key = this.#sessions.get(sessionId)?.identityKey ?? null;
52
+ if (!key || (identity && identity !== key)) {
53
+ return null;
54
+ }
55
+ identity = key;
56
+ }
57
+ return identity;
58
+ }
59
+
60
+ /** How many sessions currently answer to this nickname. */
61
+ sessionsForNickname(nickname) {
62
+ return this.#nicknames.get(nickname.toLowerCase())?.size ?? 0;
63
+ }
64
+
65
+ addSession(
66
+ ws,
67
+ nickname,
68
+ publicKey,
69
+ room = 'general',
70
+ pqPublicKey = null,
71
+ capabilities = [],
72
+ identityKey = null,
73
+ ) {
34
74
  const sessionId = randomUUID();
35
75
  const session = {
36
76
  ws,
37
77
  nickname,
38
78
  publicKey,
39
79
  pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
80
+ // Ed25519 identity key, relayed verbatim like the rest. The relay cannot
81
+ // check it belongs to this session and must not pretend to: a client
82
+ // trusts an identity key because a signed device list says so, never
83
+ // because a relay repeated it.
84
+ identityKey,
40
85
  capabilities, // advertised in JOIN, relayed verbatim — the relay never acts on these
41
86
  connectedAt: Date.now(),
42
87
  rooms: new Set(),
43
88
  };
44
89
 
45
90
  this.#sessions.set(sessionId, session);
46
- this.#nicknames.add(nickname.toLowerCase());
91
+ const lower = nickname.toLowerCase();
92
+ if (!this.#nicknames.has(lower)) {
93
+ this.#nicknames.set(lower, new Set());
94
+ }
95
+ this.#nicknames.get(lower).add(sessionId);
47
96
  this.#joinRoom(sessionId, room);
48
97
 
49
98
  log.info(`${nickname} connected (${sessionId.slice(0, 8)}) in room ${room}`);
@@ -59,7 +108,14 @@ export class SessionManager {
59
108
  for (const room of [...session.rooms]) {
60
109
  this.#leaveRoom(sessionId, room);
61
110
  }
62
- this.#nicknames.delete(session.nickname.toLowerCase());
111
+ const lower = session.nickname.toLowerCase();
112
+ const holders = this.#nicknames.get(lower);
113
+ if (holders) {
114
+ holders.delete(sessionId);
115
+ if (holders.size === 0) {
116
+ this.#nicknames.delete(lower);
117
+ }
118
+ }
63
119
  this.#sessions.delete(sessionId);
64
120
  this.#muteState.delete(sessionId);
65
121
 
@@ -105,6 +161,7 @@ export class SessionManager {
105
161
  nickname: session.nickname,
106
162
  publicKey: session.publicKey,
107
163
  ...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
164
+ ...(session.identityKey ? { identityKey: session.identityKey } : {}),
108
165
  ...(session.capabilities?.length ? { caps: [...session.capabilities] } : {}),
109
166
  });
110
167
  }
@@ -44,6 +44,7 @@ import {
44
44
  validateBanPeer,
45
45
  } from '../protocol/validators.js';
46
46
  import { verifyRoomChallenge } from '../crypto/RoomKey.js';
47
+ import { DEVICE_LIMITS, verifyDeviceList } from '../crypto/DeviceIdentity.js';
47
48
  import { parseServerConfig, clientAddress, normalizeIp } from './config.js';
48
49
 
49
50
  const log = createLogger('ws-server');
@@ -281,6 +282,37 @@ export class SecureWSServer {
281
282
  }
282
283
  }
283
284
 
285
+ /**
286
+ * May this JOIN share a nickname that is already in use?
287
+ *
288
+ * Only if it is another device of whoever holds it. The proof is the signed
289
+ * device list in the JOIN: it must be signed by the identity the existing
290
+ * sessions are using, and it must name *this* JOIN's public key.
291
+ *
292
+ * No challenge is needed and none is invented. Replaying a list somebody else
293
+ * published gets you a seat in a room under a name whose messages you cannot
294
+ * read, because you do not hold the box secret the list names — and the peers,
295
+ * who do their own checking, learn nothing from the relay having allowed it.
296
+ * The relay is doing admission control on a nickname here, not attesting to
297
+ * anyone's identity; clients never take its word for that.
298
+ */
299
+ #isAnotherDeviceOf(validation, publicKey) {
300
+ const identityKey = this.#sessionManager.identityForNickname(validation.nickname);
301
+ if (!identityKey || !validation.identityKey || validation.identityKey !== identityKey) {
302
+ return false;
303
+ }
304
+ const list = verifyDeviceList(validation.deviceList);
305
+ if (!list || list.identityPk !== identityKey) {
306
+ return false;
307
+ }
308
+ if (
309
+ this.#sessionManager.sessionsForNickname(validation.nickname) >= DEVICE_LIMITS.MAX_DEVICES
310
+ ) {
311
+ return false;
312
+ }
313
+ return list.devices.some((device) => device.boxPk === publicKey);
314
+ }
315
+
284
316
  #handleJoin(ws, msg) {
285
317
  if (ws.hasJoined) {
286
318
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Already in the chat')));
@@ -293,7 +325,10 @@ export class SecureWSServer {
293
325
  return;
294
326
  }
295
327
 
296
- if (this.#sessionManager.isNicknameTaken(validation.nickname)) {
328
+ if (
329
+ this.#sessionManager.isNicknameTaken(validation.nickname) &&
330
+ !this.#isAnotherDeviceOf(validation, msg.publicKey)
331
+ ) {
297
332
  ws.send(
298
333
  JSON.stringify(
299
334
  createError(ERR.NICKNAME_TAKEN, `Nickname "${validation.nickname}" is already in use`),
@@ -310,6 +345,7 @@ export class SecureWSServer {
310
345
  room,
311
346
  validation.pqPublicKey,
312
347
  validation.capabilities,
348
+ validation.identityKey,
313
349
  );
314
350
  ws.sessionId = sessionId;
315
351
  ws.hasJoined = true;
@@ -347,6 +383,7 @@ export class SecureWSServer {
347
383
  nickname: validation.nickname,
348
384
  publicKey: msg.publicKey,
349
385
  ...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
386
+ ...(validation.identityKey ? { identityKey: validation.identityKey } : {}),
350
387
  ...(validation.capabilities.length ? { caps: [...validation.capabilities] } : {}),
351
388
  }),
352
389
  sessionId,
@@ -665,6 +702,7 @@ export class SecureWSServer {
665
702
  nickname: session.nickname,
666
703
  publicKey: session.publicKey,
667
704
  ...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
705
+ ...(session.identityKey ? { identityKey: session.identityKey } : {}),
668
706
  },
669
707
  room,
670
708
  ),
@@ -776,6 +814,27 @@ export class SecureWSServer {
776
814
  this.#finishRoomSwitch(ws, session, result);
777
815
  }
778
816
 
817
+ // One session stopped being in one room. Every way out of a room ends here:
818
+ // leaving, switching, disconnecting, and — since this was the gap — being
819
+ // kicked or banned.
820
+ //
821
+ // Kick and ban used to announce only `peer_kicked`, which carries a nickname
822
+ // and no session. Clients had nothing to remove a member *by*, so the removed
823
+ // peer stayed in every remaining roster. Harmless-looking while the relay path
824
+ // seals one envelope per peer; not harmless at all once a room encrypts once
825
+ // to a shared chain, because "who is still in this room" is then the input to
826
+ // when that chain must be rotated away from someone.
827
+ #emitRoomDeparture(room, sessionId, nickname) {
828
+ if (!room || !sessionId) {
829
+ return;
830
+ }
831
+ this.#sessionManager.broadcastToRoom(
832
+ room,
833
+ createPeerLeft(sessionId, nickname || 'Unknown', room),
834
+ sessionId,
835
+ );
836
+ }
837
+
779
838
  // Shared tail of a successful room switch: notify every old room and send
780
839
  // the ROOM_CHANGED (with the private flag) to the mover.
781
840
  #finishRoomSwitch(ws, session, result) {
@@ -800,6 +859,7 @@ export class SecureWSServer {
800
859
  nickname: session.nickname,
801
860
  publicKey: session.publicKey,
802
861
  ...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
862
+ ...(session.identityKey ? { identityKey: session.identityKey } : {}),
803
863
  },
804
864
  result.newRoom,
805
865
  ),
@@ -902,11 +962,14 @@ export class SecureWSServer {
902
962
  if (result) {
903
963
  const targetSession = this.#sessionManager.getSession(targetSessionId);
904
964
 
905
- // Notify old room
965
+ // Notify old room. The kick goes first so a client can mark the session
966
+ // before the peer_left lands and report it as a kick rather than a
967
+ // departure; both travel the same socket, so the order holds.
906
968
  this.#sessionManager.broadcastToRoom(
907
969
  room,
908
- createPeerKicked(validation.targetNickname, validation.reason),
970
+ createPeerKicked(validation.targetNickname, validation.reason, targetSessionId),
909
971
  );
972
+ this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
910
973
 
911
974
  // Notify target with room change + kick reason
912
975
  const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
@@ -1018,8 +1081,9 @@ export class SecureWSServer {
1018
1081
  if (result) {
1019
1082
  this.#sessionManager.broadcastToRoom(
1020
1083
  room,
1021
- createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
1084
+ createPeerKicked(validation.targetNickname, validation.reason || 'banned', targetSessionId),
1022
1085
  );
1086
+ this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
1023
1087
 
1024
1088
  const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
1025
1089
  targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
@@ -17,11 +17,15 @@ export const CAP = {
17
17
  // and fan-out land a release before anyone sends, so that by the time a sender
18
18
  // exists, every advertised room can already read it.
19
19
  SENDER_KEYS: 'sk1',
20
+ // I can read a signed device list handed to me over the pairwise channel.
21
+ // Client-only: distribution rides the sealed channel the relay already
22
+ // carries, so unlike sk1 there is nothing for the relay to agree to.
23
+ DEVICE_LIST: 'dl1',
20
24
  };
21
25
 
22
26
  // What this client advertises. SENDER_KEYS is honest here: the receive path
23
27
  // exists. Nothing sends group messages yet.
24
- export const OWN_CAPABILITIES = [CAP.SENDER_KEYS];
28
+ export const OWN_CAPABILITIES = [CAP.SENDER_KEYS, CAP.DEVICE_LIST];
25
29
 
26
30
  // What the relay advertises, in join_ack. A client cannot promise this on the
27
31
  // relay's behalf — the fan-out is the relay's job — so a sender has to check the
@@ -71,6 +75,10 @@ export const MAX_BYTES_BURST = 4_194_304;
71
75
  // Crypto sizes (libsodium Curve25519 + XSalsa20-Poly1305)
72
76
  export const NONCE_SIZE = 24;
73
77
  export const PUBLIC_KEY_SIZE = 32;
78
+ // Ed25519, and the same 32 bytes as above by coincidence of the curves rather
79
+ // than by anything shared. Named separately so a future change to one does not
80
+ // silently resize the other.
81
+ export const IDENTITY_KEY_SIZE = 32;
74
82
  export const SECRET_KEY_SIZE = 32;
75
83
  export const MAC_SIZE = 16;
76
84
  export const SHARED_KEY_SIZE = 32;