ciphermesh 2.11.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 CHANGED
@@ -3,6 +3,63 @@
3
3
  Notable changes per release. Older versions are reconstructed from the git
4
4
  history — the commit bodies and pull requests remain the fuller record.
5
5
 
6
+ ## 2.12.0
7
+
8
+ Sender keys now send. 2.11.0 shipped the half that reads a group message; this
9
+ is the half that writes one, and a line in a room of fifty costs one encryption
10
+ instead of fifty.
11
+
12
+ The switch is unchanged and still strict — every member of the room and the hub
13
+ itself must say they can handle it. On a public hub the per-peer path remains
14
+ the common case, and it is untouched.
15
+
16
+ ### Added
17
+
18
+ - **Group sending on the relay.** 2.11.0 shipped the half that reads one; this
19
+ is the half that writes one. A line in a room of fifty cost fifty encryptions
20
+ and fifty envelopes — against the 1 MiB/s byte budget, a padded message to
21
+ fifty people throttled the sender for saying one thing. It now costs one of
22
+ each.
23
+
24
+ The switch is still the strict one: every member of the room advertises
25
+ `sk1`, **and** the hub does, **and** the message is not deniable. Any one
26
+ false and the per-peer path runs exactly as before, which on a public hub is
27
+ the common case rather than the exception. Nothing was removed.
28
+
29
+ Deniable messages stay pairwise permanently. Deniability comes from a key both
30
+ sides could have derived; a group packet is signed by one sender, which is the
31
+ opposite claim.
32
+
33
+ - **Sender keys rotate on every membership change.** Leaving, switching rooms,
34
+ disconnecting, being kicked, being banned. A chain ratchets forward, so the
35
+ copy a member holds opens every message after it: removing someone stopped the
36
+ relay delivering to them and did not stop them reading. Rotation is what
37
+ closes that, and `test/guarantees.test.js` now asserts it from outside the
38
+ code that implements it.
39
+
40
+ ### Fixed
41
+
42
+ - **A kick or a ban is announced as a departure, not only as a notification.**
43
+ Both moved the target out of the room and told the room `peer_kicked`, which
44
+ carries a nickname — and a nickname is not something a client can unwind a
45
+ member by, since `/nick` reassigns them. Every remaining client kept the
46
+ removed peer in its roster indefinitely. They now emit `peer_left` like every
47
+ other way out of a room, which is also what makes rotation reachable for the
48
+ two cases where it matters most.
49
+
50
+ - **Peer capabilities survive a room switch.** `room_changed` and `room_joined`
51
+ carried them and the client dropped them, so after switching rooms every peer
52
+ looked incapable and the room silently never turned the group path on. No
53
+ error, no failure — just an optimisation that quietly never applied.
54
+
55
+ - **Sender-key memory is released, not merely zeroed.** `sodium_malloc` pages are
56
+ `mlock`'d, and an operating system caps how much a process may lock at once.
57
+ Zeroing a spent key left its pages locked until the garbage collector happened
58
+ to run the buffer's finaliser, so a long session derived keys faster than it
59
+ released them. The ceiling is generous on macOS and small on Linux, where a
60
+ busy client would eventually have hit an allocation failure that aborts the
61
+ process rather than returning an error.
62
+
6
63
  ## 2.11.0
7
64
 
8
65
  Groundwork for sender keys on the relay. **Nothing sends a group message yet** —
package/docs/PROTOCOL.md CHANGED
@@ -155,6 +155,13 @@ and must be recognised by key.
155
155
  `peer_joined` carries the same peer object as `join_ack.peers`. Both carry an
156
156
  optional `room`; absent means the session's only room. Old clients ignore it.
157
157
 
158
+ `peer_left` is emitted for **every** way a session stops being in a room:
159
+ leaving, switching, disconnecting, being kicked, being banned. That completeness
160
+ is load-bearing rather than tidy. A sender chain (§7) is shared with exactly the
161
+ members of a room, so the set of departures a client hears about is the set of
162
+ moments it can rotate that chain at — and a departure it never hears about is a
163
+ chain that outlives the membership it was drawn for.
164
+
158
165
  ### `ping` / `pong`
159
166
 
160
167
  Either direction, no payload beyond the framing.
@@ -245,6 +252,29 @@ message keys may be cached.
245
252
  The unicast path costs one encryption and one envelope **per recipient**. Sender
246
253
  keys make it one of each for the whole room.
247
254
 
255
+ ### When this path is used
256
+
257
+ Three conditions, all required, each failing for a different reason:
258
+
259
+ | Condition | Fails when |
260
+ | --- | --- |
261
+ | every member of the room advertises `sk1` | one peer is on an older build |
262
+ | `join_ack.serverCaps` contains `sk1` | the hub is older |
263
+ | the message is not deniable | see below |
264
+
265
+ Any one false and the per-peer path runs unchanged. A sender **must** re-check
266
+ per message rather than caching the answer: a single arrival can take a room off
267
+ this path, and encrypting for a member who cannot decrypt is silent.
268
+
269
+ **Deniable messages never take this path.** Deniability comes from a symmetric
270
+ key both sides could have derived, so neither can prove the other wrote it. A
271
+ group packet is signed by exactly one sender — sending a deniable message on it
272
+ would publish precisely what deniability is for hiding.
273
+
274
+ **Cover traffic takes whichever path real messages take.** A decoy that travelled
275
+ the per-peer path while the room was sending group messages would be
276
+ distinguishable from the thing it exists to imitate.
277
+
248
278
  ### The chain
249
279
 
250
280
  Each member owns a symmetric ratchet chain per room:
@@ -280,6 +310,23 @@ receives one mid-conversation therefore cannot read anything sent before it —
280
310
  that is forward secrecy, not a defect, and it is why the backlog question in §8
281
311
  answers itself.
282
312
 
313
+ **Who speaks first is not a free choice.** A client drops a ciphertext from a
314
+ session it holds no public key for, and a joiner learns the room from its
315
+ `join_ack` *before* the room learns of the joiner from `peer_joined`. A newcomer
316
+ that distributed on arrival would be talking to peers who cannot yet hear it, and
317
+ would have no way to discover that.
318
+
319
+ So distribution is **answered, never announced**:
320
+
321
+ 1. the peers who already know the newcomer distribute to it (on `peer_joined`);
322
+ 2. the newcomer replies with its own to anyone whose distribution it receives and
323
+ who does not already hold its current chain.
324
+
325
+ Receiving a distribution proves the sender holds your public key, so the reply
326
+ cannot race. A sender must also distribute to any room member it has not yet
327
+ given its current chain to before sending — the exchange above covers every
328
+ ordinary path, and that check covers the rest.
329
+
283
330
  ### The message
284
331
 
285
332
  ```json
@@ -325,6 +372,21 @@ follow **every** membership change, and the caller must redistribute afterwards
325
372
  nothing signals a failure to do so, and the room simply stops being able to read
326
373
  the rotator.
327
374
 
375
+ Concretely, a departure rotates: leaving, switching rooms, disconnecting, being
376
+ kicked, being banned. All five reach the client as `peer_left` (§4), which is why
377
+ that message has to be emitted for all five and not only the voluntary ones — a
378
+ departure the client never hears about is a chain that is never rotated, and a
379
+ removed member whose copy still opens everything that follows.
380
+
381
+ **An arrival does not rotate.** A distribution carries the chain's current
382
+ counter, so a newcomer is handed what opens the next message and nothing before
383
+ it. Rotating on arrival would cost a redistribution to the whole room and buy
384
+ nothing.
385
+
386
+ A full room switch drops every chain rather than rotating one: the client is no
387
+ longer in the rooms those chains were drawn for, and carrying one across would
388
+ use a chain drawn for one membership against another.
389
+
328
390
  ### What the relay does
329
391
 
330
392
  Validates the shape, checks the sender **is a member of `room`**, spends the same
@@ -405,6 +467,14 @@ optional `room`. Reasons are truncated to 200 characters. The relay broadcasts
405
467
  `peer_kicked` / `peer_muted` to the room. A muted session is refused for
406
468
  `encrypted_message` and `group_message` alike.
407
469
 
470
+ A kick or a ban emits **`peer_kicked` and then `peer_left`**, in that order, for
471
+ the same session. `peer_kicked` carries an optional `sessionId` alongside the
472
+ nickname; a client that has it can match the two and report one event once,
473
+ while one that does not — every client before this — simply sees an ordinary
474
+ departure and a kick notice. Nicknames could not do this job: `/nick` reassigns
475
+ them, so unwinding a peer by name drops the wrong session as soon as two people
476
+ have ever shared one.
477
+
408
478
  These are relay-enforced conveniences and nothing more. A relay that ignores them
409
479
  breaks no cryptographic guarantee, which is why blocking is *also* implemented
410
480
  client-side, where it cannot be overruled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",
@@ -63,7 +63,7 @@
63
63
  "node-notifier": "10.0.1",
64
64
  "qrcode-terminal": "0.12.0",
65
65
  "sodium-native": "5.1.0",
66
- "ws": "8.21.1"
66
+ "ws": "8.21.3"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@eslint/js": "10.0.1",
@@ -18,6 +18,7 @@ import {
18
18
  createKickPeer,
19
19
  createMutePeer,
20
20
  createBanPeer,
21
+ createGroupMessage,
21
22
  ERR,
22
23
  } from '../protocol/messages.js';
23
24
  import { sealEnvelope, openEnvelope } from '../crypto/SealedSender.js';
@@ -34,8 +35,9 @@ import {
34
35
  EMOJI_MAP,
35
36
  COVER_CONSTANT_MS,
36
37
  OWN_CAPABILITIES,
38
+ CAP,
37
39
  } from '../shared/constants.js';
38
- import { normalizeCaps, roomSupports } from '../protocol/capabilities.js';
40
+ import { normalizeCaps, peerSupports, roomSupports } from '../protocol/capabilities.js';
39
41
  import { GroupSession } from '../crypto/SenderKey.js';
40
42
  import { KeyManager } from '../crypto/KeyManager.js';
41
43
  import { Handshake } from '../crypto/Handshake.js';
@@ -86,7 +88,9 @@ export class ChatController {
86
88
  #peers; // Map<sessionId, { nickname, publicKey, caps }>
87
89
  #groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
88
90
  #groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
91
+ #distributed = new Map(); // room -> Set<sessionId> holding our current chain
89
92
  #serverCaps = []; // what the relay advertised in join_ack
93
+ #kickedSessions = new Set(); // sessionIds announced kicked, awaiting their peer_left
90
94
  #lastTypingSent;
91
95
  #peerTypingTimers; // Map<sessionId, timeoutId>
92
96
  #fileTransfer;
@@ -850,6 +854,17 @@ export class ChatController {
850
854
  });
851
855
  }
852
856
 
857
+ // A newcomer holds no chain of ours, so give them one before anything is
858
+ // sent on it. Ordered ahead of the presence and topic sends below because
859
+ // those may themselves go out on the group path.
860
+ //
861
+ // No rotation here. Someone arriving is not someone gaining access to the
862
+ // past: a serialised chain carries its *current* counter, so what they are
863
+ // handed opens what comes next and nothing before it.
864
+ if (room === this.#currentRoom) {
865
+ this.#distributeSenderKey(room, peer.sessionId);
866
+ }
867
+
853
868
  // A newcomer doesn't know my presence — send only to them
854
869
  if (this.#away || this.#statusText) {
855
870
  this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
@@ -896,11 +911,19 @@ export class ChatController {
896
911
  }
897
912
  const goneEntirely = !entry || !room || entry.rooms.size === 0;
898
913
 
899
- // Drop their sender chain for the room they left. Their own rotation is what
900
- // gives forward secrecy — this only stops us holding a key we can no longer
901
- // be sent anything on.
902
- if (room) {
903
- this.#groups.get(room)?.removeMember(msg.sessionId);
914
+ // Two separate things, and only the second is forward secrecy.
915
+ //
916
+ // Dropping *their* chain stops us holding a key we can no longer be sent
917
+ // anything on. Rotating *ours* is what stops them reading what the room says
918
+ // next — a chain ratchets forward, so the copy they were handed opens every
919
+ // message after it until we draw a new one.
920
+ //
921
+ // Which rooms rotate is decided by which ones actually lost a member, not by
922
+ // which ones we happen to hold a session for: every rotation costs a
923
+ // redistribution to everyone remaining.
924
+ const lostFrom = new Set();
925
+ if (room && this.#groups.get(room)?.removeMember(msg.sessionId)) {
926
+ lostFrom.add(room);
904
927
  }
905
928
 
906
929
  if (goneEntirely) {
@@ -908,15 +931,27 @@ export class ChatController {
908
931
  this.#handshake.removePeer(msg.sessionId);
909
932
  this.#nonceManager.removePeer(msg.sessionId);
910
933
  this.#allPeers.delete(msg.sessionId);
911
- for (const group of this.#groups.values()) {
912
- group.removeMember(msg.sessionId);
934
+ for (const [groupRoom, group] of this.#groups) {
935
+ if (group.removeMember(msg.sessionId)) {
936
+ lostFrom.add(groupRoom);
937
+ }
913
938
  }
914
939
  }
915
940
 
941
+ for (const lost of lostFrom) {
942
+ this.#rotateGroupFor(lost);
943
+ }
944
+
945
+ // A kick already announced itself. Do every bit of the state work, and say
946
+ // nothing — "was kicked" followed by "left" describes one event twice.
947
+ const wasKicked = this.#kickedSessions.delete(msg.sessionId);
948
+
916
949
  if (!room || room === this.#currentRoom) {
917
950
  this.#rebuildActivePeers();
918
- this.#ui.handshakeDisconnect(nickname);
919
- } else {
951
+ if (!wasKicked) {
952
+ this.#ui.handshakeDisconnect(nickname);
953
+ }
954
+ } else if (!wasKicked) {
920
955
  this.#ui.toBuffer(room, () => {
921
956
  this.#ui.addSystemMessage(`${nickname} left #${room}`);
922
957
  });
@@ -924,13 +959,21 @@ export class ChatController {
924
959
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
925
960
  }
926
961
 
927
- // ── Sender keys on the relay: the receive half ────────────────
962
+ // ── Sender keys on the relay ──────────────────────────────────
928
963
  //
929
- // Sending is deliberately absent #broadcastPayload still seals one envelope
930
- // per peer. This is the half that has to be in the field first: a room only
931
- // switches to group sending once every member advertises it, so the ability to
932
- // read one must ship a release before the ability to write one, or the switch
933
- // never becomes true for anybody.
964
+ // The receive half shipped a release ahead of this one, on purpose: a room
965
+ // switches to group sending only once every member advertises that it can
966
+ // read one, so the readers had to be in the field before the writers or the
967
+ // switch would never have become true for anybody.
968
+ //
969
+ // Three things have to hold before a line goes out once instead of N times,
970
+ // and they fail for different reasons:
971
+ //
972
+ // 1. every member of the room advertises `sk1` — an old peer
973
+ // 2. the relay advertises `sk1` — an old hub
974
+ // 3. the message is not deniable — see #canSendToGroup
975
+ //
976
+ // Any one of them false and the per-peer loop runs, unchanged.
934
977
 
935
978
  #getGroup(room) {
936
979
  let group = this.#groups.get(room);
@@ -951,6 +994,162 @@ export class ChatController {
951
994
  }
952
995
  this.#getGroup(data.room).addMember(fromSessionId, data.dist);
953
996
  this.#flushGroupBuffer(data.dist.keyId);
997
+
998
+ // Answer with ours if they do not have it. This is what makes distribution
999
+ // reliable without anything having to know who joined in which order:
1000
+ // whoever knows the other first speaks, and the reply cannot race, because
1001
+ // receiving this proves they already hold our public key.
1002
+ if (!this.#hasDistributedTo(data.room, fromSessionId)) {
1003
+ this.#distributeSenderKey(data.room, fromSessionId);
1004
+ }
1005
+ }
1006
+
1007
+ // A full room switch: every buffer is dropped and we exist only in `room`.
1008
+ // The chains follow. Carrying one across would mean a chain drawn for one
1009
+ // room's membership being used against another's, and a keyId that outlives
1010
+ // the set of people it was ever meant to label.
1011
+ #dropAllGroups() {
1012
+ for (const group of this.#groups.values()) {
1013
+ group.destroy();
1014
+ }
1015
+ this.#groups.clear();
1016
+ this.#groupBuffer.clear();
1017
+ this.#distributed.clear();
1018
+ }
1019
+
1020
+ // Hand our sender key for `room` to one peer, or to everyone in it.
1021
+ //
1022
+ // Always pairwise. The envelope is what proves who the key belongs to; a
1023
+ // distribution arriving on the group path would be a chain vouching for
1024
+ // itself, and the relay would be the only thing asserting whose it was.
1025
+ //
1026
+ // Never sent to a peer that may not know us yet. A client drops a ciphertext
1027
+ // from a session it has no public key for, and it learns ours from the
1028
+ // `peer_joined` the relay sends *after* our `join_ack` — so a newcomer
1029
+ // announcing itself into the room on arrival is talking to people who cannot
1030
+ // hear it. That is why nothing distributes on join: the peers who already
1031
+ // know us distribute to us (#onPeerJoined), and we answer (#onSenderKeyDistribution).
1032
+ #distributeSenderKey(room, toPeer = null) {
1033
+ const recipients = (toPeer ? [toPeer] : [...this.#peers.keys()]).filter((id) =>
1034
+ this.#worthDistributingTo(id),
1035
+ );
1036
+ if (recipients.length === 0) {
1037
+ return;
1038
+ }
1039
+
1040
+ // Only now: distribution() is what draws the chain, and a chain is guarded
1041
+ // memory. See #worthDistributingTo.
1042
+ const payload = JSON.stringify({
1043
+ action: 'sk_dist',
1044
+ room,
1045
+ dist: this.#getGroup(room).distribution(),
1046
+ sentAt: Date.now(),
1047
+ });
1048
+ let sent = this.#distributed.get(room);
1049
+ if (!sent) {
1050
+ sent = new Set();
1051
+ this.#distributed.set(room, sent);
1052
+ }
1053
+ // Record before sending, never after.
1054
+ //
1055
+ // Sending re-enters this object. The peer receives the distribution, finds
1056
+ // it holds none of ours, and answers — and its answer can arrive before
1057
+ // #sendPayloadToPeer has returned. Marking afterwards means both sides
1058
+ // consult a record neither has written yet, each answers the other's
1059
+ // answer, and the exchange never converges.
1060
+ //
1061
+ // On a real socket that is a burst of duplicate distributions rather than a
1062
+ // hang, which is why it is worth stating: the bug is re-entrancy, and the
1063
+ // synchronous case is only the one that makes it obvious.
1064
+ for (const peerId of recipients) {
1065
+ sent.add(peerId);
1066
+ }
1067
+ for (const peerId of recipients) {
1068
+ this.#sendPayloadToPeer(peerId, payload);
1069
+ }
1070
+ }
1071
+
1072
+ // A sender key is only ever useful to a peer that can read a group message,
1073
+ // on a hub that can fan one out. Handing one to anybody else is a wasted
1074
+ // round trip — and, less obviously, a wasted allocation.
1075
+ //
1076
+ // Chains live in sodium_malloc'd memory, which is mlock'd. Linux caps how much
1077
+ // a process may lock (RLIMIT_MEMLOCK), and the cap is small; drawing a chain
1078
+ // per room per peer regardless of whether it could ever be used exhausted it,
1079
+ // and sodium_malloc then returns NULL. That surfaced as a SIGABRT in an
1080
+ // unrelated ratchet call — the first allocation to fail, not the one at fault.
1081
+ #worthDistributingTo(peerId) {
1082
+ if (!this.relaySupportsCapability(CAP.SENDER_KEYS)) {
1083
+ return false;
1084
+ }
1085
+ const peer = this.#peers.get(peerId);
1086
+ return peer ? peerSupports(peer, CAP.SENDER_KEYS) : false;
1087
+ }
1088
+
1089
+ // Has this peer been given our *current* chain for this room? Reset by
1090
+ // rotate(), because after one the answer is no for everybody.
1091
+ #hasDistributedTo(room, peerId) {
1092
+ return this.#distributed.get(room)?.has(peerId) ?? false;
1093
+ }
1094
+
1095
+ // Someone is no longer in `room`: draw a new chain and hand it to whoever is
1096
+ // left.
1097
+ //
1098
+ // This is the forward secrecy the design promises, and the reason the relay
1099
+ // now reports a kick as a departure (#482). Without it a removed member keeps
1100
+ // the chain they were given, and a chain ratchets *forward* — holding it at
1101
+ // counter N opens every counter after N. Being removed from a room would stop
1102
+ // the relay delivering to them and would not stop them reading.
1103
+ //
1104
+ // rotate() has no failure to check and no value to return. The distribution
1105
+ // that follows is the whole point, so the two must not drift apart: a rotation
1106
+ // whose redistribution never happens is a room that quietly stopped being able
1107
+ // to read this client, with nothing raised anywhere.
1108
+ #rotateGroupFor(room) {
1109
+ const group = this.#groups.get(room);
1110
+ if (!group) {
1111
+ return;
1112
+ }
1113
+ group.rotate();
1114
+ this.#distributed.delete(room); // a new chain: nobody has it
1115
+ if (room === this.#currentRoom) {
1116
+ this.#distributeSenderKey(room);
1117
+ }
1118
+ }
1119
+
1120
+ // Can this payload go out once, addressed to the room, instead of N times?
1121
+ #canSendToGroup(deniable) {
1122
+ // Deniability is a property of the pairwise construction — a symmetric key
1123
+ // both sides could have derived, so neither can prove the other wrote it. A
1124
+ // group packet is signed by exactly one sender for exactly that reason, so
1125
+ // sending a deniable message on it would publish the opposite of what was
1126
+ // asked for.
1127
+ if (deniable) {
1128
+ return false;
1129
+ }
1130
+ if (this.#peers.size === 0) {
1131
+ return false;
1132
+ }
1133
+ return (
1134
+ this.roomSupportsCapability(CAP.SENDER_KEYS) && this.relaySupportsCapability(CAP.SENDER_KEYS)
1135
+ );
1136
+ }
1137
+
1138
+ // One encryption, one frame, the whole room. The payload arrives already
1139
+ // room-tagged and already through the private-room layer when there is one —
1140
+ // both happen before the path splits, so a group message and a pairwise one
1141
+ // carry exactly the same bytes inside.
1142
+ #sendRoomGroup(room, payload) {
1143
+ // Nobody can read a packet on a chain they were never given. The exchange
1144
+ // above covers every ordinary path; this covers the rest, and costs one
1145
+ // Set lookup per member when there is nothing to do.
1146
+ for (const [peerId] of this.#peers) {
1147
+ if (!this.#hasDistributedTo(room, peerId)) {
1148
+ this.#distributeSenderKey(room, peerId);
1149
+ }
1150
+ }
1151
+ const packet = this.#getGroup(room).encrypt(payload);
1152
+ this.#connection.send(createGroupMessage(room, packet));
954
1153
  }
955
1154
 
956
1155
  #onGroupMessage(msg) {
@@ -3170,6 +3369,11 @@ export class ChatController {
3170
3369
  this.#allPeers.set(peer.sessionId, {
3171
3370
  nickname: peer.nickname,
3172
3371
  publicKey: peer.publicKey,
3372
+ // The relay sends these here exactly as it does in join_ack. Dropping
3373
+ // them made every peer look incapable after a room switch, which is not
3374
+ // an error anywhere — it is a room that silently never turns the group
3375
+ // path on, because one absent capability is enough to hold all of it.
3376
+ caps: normalizeCaps(peer.caps),
3173
3377
  rooms: new Set([msg.room]),
3174
3378
  });
3175
3379
  if (!this.#handshake.getRatchet(peer.sessionId)) {
@@ -3179,6 +3383,12 @@ export class ChatController {
3179
3383
  }
3180
3384
 
3181
3385
  this.#rebuildActivePeers();
3386
+ // A switch drops every buffer, so it drops every chain with them. Carrying
3387
+ // one across would mean a chain drawn for one room's membership being used
3388
+ // against another's, and a keyId outliving the set of people it labelled.
3389
+ // The new room's chain is drawn on first use and distributed by the same
3390
+ // exchange as any other.
3391
+ this.#dropAllGroups();
3182
3392
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
3183
3393
  this.#announceJoinedRoom(
3184
3394
  msg.room,
@@ -3205,6 +3415,7 @@ export class ChatController {
3205
3415
  this.#allPeers.set(peer.sessionId, {
3206
3416
  nickname: peer.nickname,
3207
3417
  publicKey: peer.publicKey,
3418
+ caps: normalizeCaps(peer.caps),
3208
3419
  rooms: new Set([msg.room]),
3209
3420
  });
3210
3421
  if (!this.#handshake.getRatchet(peer.sessionId)) {
@@ -3263,6 +3474,18 @@ export class ChatController {
3263
3474
 
3264
3475
  // ── Handle PEER_KICKED ────────────────────────────────────
3265
3476
  #onPeerKicked(msg) {
3477
+ // The relay sends this immediately before the peer_left for the same
3478
+ // session. Remember it so the departure is reported as a kick and not
3479
+ // announced twice; the state work still happens in #onPeerLeft, which is
3480
+ // the one place that knows how to unwind a member.
3481
+ if (typeof msg.sessionId === 'string') {
3482
+ // A kick whose peer_left never arrives (an older relay) must not park an
3483
+ // entry here forever.
3484
+ if (this.#kickedSessions.size >= 64) {
3485
+ this.#kickedSessions.clear();
3486
+ }
3487
+ this.#kickedSessions.add(msg.sessionId);
3488
+ }
3266
3489
  if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
3267
3490
  const reason = msg.reason ? ` (reason: ${msg.reason})` : '';
3268
3491
  this.#ui.addErrorMessage(`You were kicked from the room${reason}`);
@@ -3558,6 +3781,15 @@ export class ChatController {
3558
3781
  payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
3559
3782
  }
3560
3783
 
3784
+ // One ciphertext for the room, when the room and the relay can both take
3785
+ // one. Everything above this line has already run, so the bytes inside are
3786
+ // identical either way — including cover traffic, which has to travel the
3787
+ // path real messages travel or it stops resembling them.
3788
+ if (this.#canSendToGroup(deniable)) {
3789
+ this.#sendRoomGroup(this.#currentRoom, payload);
3790
+ return;
3791
+ }
3792
+
3561
3793
  for (const [peerId] of this.#peers) {
3562
3794
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
3563
3795
  if (!peerPublicKey) {
@@ -3810,11 +4042,7 @@ export class ChatController {
3810
4042
  this.#historyStore.destroy();
3811
4043
  }
3812
4044
  this.#fileTransfer.destroy();
3813
- for (const group of this.#groups.values()) {
3814
- group.destroy();
3815
- }
3816
- this.#groups.clear();
3817
- this.#groupBuffer.clear();
4045
+ this.#dropAllGroups();
3818
4046
  this.#handshake.destroy();
3819
4047
  this.#keyManager.destroy();
3820
4048
  this.#connection.close();
@@ -16,6 +16,26 @@ const CHAIN_KEY_TAG = Buffer.from([0x02]);
16
16
  const DEFAULT_MAX_SKIP = 1000; // bound out-of-order / skipped message keys
17
17
  const KEY_ID_SIZE = 16;
18
18
 
19
+ // Release a key allocated with sodium_malloc.
20
+ //
21
+ // `sodium_memzero` alone is not enough, and the difference is not academic.
22
+ // sodium_malloc'd pages are **mlock'd**, and an operating system caps how much
23
+ // a process may lock at once — RLIMIT_MEMLOCK, which on Linux is small and on
24
+ // macOS is typically unlimited. Zeroing a key leaves its pages locked until the
25
+ // garbage collector happens to run the buffer's finaliser, so a process that
26
+ // derives keys faster than it collects them walks into an allocation failure it
27
+ // never sees coming: sodium_malloc returns NULL, and the crash lands on whatever
28
+ // unrelated call allocated next.
29
+ //
30
+ // sodium_free zeroes before releasing, so this is strictly stronger than the
31
+ // memzero it replaces. Null-safe and idempotent by convention: every caller
32
+ // drops its reference immediately after, so nothing can reach freed memory.
33
+ function freeKey(buf) {
34
+ if (buf) {
35
+ sodium.sodium_free(buf);
36
+ }
37
+ }
38
+
19
39
  // An opaque label for a sender chain, handed out with the distribution.
20
40
  //
21
41
  // On the relay path a group message arrives by fan-out with no sender on it —
@@ -97,8 +117,9 @@ export class SenderChain {
97
117
  sodium.crypto_generichash(messageKey, MSG_KEY_TAG, this.#chainKey);
98
118
  const nextChainKey = sodium.sodium_malloc(KEY_SIZE);
99
119
  sodium.crypto_generichash(nextChainKey, CHAIN_KEY_TAG, this.#chainKey);
100
- sodium.sodium_memzero(this.#chainKey);
120
+ const spent = this.#chainKey;
101
121
  this.#chainKey = nextChainKey;
122
+ freeKey(spent); // released, not merely zeroed — the hot path, once per message
102
123
  return messageKey;
103
124
  }
104
125
 
@@ -112,7 +133,8 @@ export class SenderChain {
112
133
 
113
134
  // Receiving: the message key at `targetCounter`, caching skipped keys for
114
135
  // out-of-order delivery. Returns null on replay (already consumed) or if the
115
- // gap exceeds maxSkip. The caller must sodium_memzero the returned key.
136
+ // gap exceeds maxSkip. The caller owns the returned key and must release it —
137
+ // groupDecrypt does, on every path including the failures.
116
138
  messageKeyFor(targetCounter) {
117
139
  if (this.#skipped.has(targetCounter)) {
118
140
  const key = this.#skipped.get(targetCounter);
@@ -145,9 +167,10 @@ export class SenderChain {
145
167
  }
146
168
 
147
169
  destroy() {
148
- sodium.sodium_memzero(this.#chainKey);
170
+ freeKey(this.#chainKey);
171
+ this.#chainKey = null;
149
172
  for (const key of this.#skipped.values()) {
150
- sodium.sodium_memzero(key);
173
+ freeKey(key);
151
174
  }
152
175
  this.#skipped.clear();
153
176
  }
@@ -162,18 +185,18 @@ export function groupEncrypt(messageKey, plaintext) {
162
185
  const ciphertext = Buffer.alloc(padded.length + sodium.crypto_secretbox_MACBYTES);
163
186
  sodium.crypto_secretbox_easy(ciphertext, padded, nonce, messageKey);
164
187
  sodium.sodium_memzero(padded);
165
- sodium.sodium_memzero(messageKey);
188
+ freeKey(messageKey);
166
189
  return { ciphertext, nonce };
167
190
  }
168
191
 
169
192
  export function groupDecrypt(messageKey, ciphertext, nonce) {
170
193
  if (ciphertext.length < sodium.crypto_secretbox_MACBYTES) {
171
- sodium.sodium_memzero(messageKey);
194
+ freeKey(messageKey);
172
195
  return null;
173
196
  }
174
197
  const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
175
198
  const ok = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, messageKey);
176
- sodium.sodium_memzero(messageKey);
199
+ freeKey(messageKey);
177
200
  if (!ok) {
178
201
  sodium.sodium_memzero(padded);
179
202
  return null;
@@ -312,14 +335,21 @@ export class GroupSession {
312
335
  }
313
336
  }
314
337
 
338
+ // Returns whether this actually removed a member. Callers rotate on a real
339
+ // membership change and must not rotate on a departure they have already
340
+ // handled: every rotation costs a redistribution to everyone remaining, and a
341
+ // redistribution that crosses an incoming message is a message the recipient
342
+ // has to buffer.
315
343
  removeMember(memberId) {
316
344
  const chain = this.#members.get(memberId);
345
+ const had = this.#members.has(memberId) || this.#memberSignPk.has(memberId);
317
346
  if (chain) {
318
347
  chain.destroy();
319
348
  this.#members.delete(memberId);
320
349
  }
321
350
  this.#forgetKeyIdsOf(memberId);
322
351
  this.#memberSignPk.delete(memberId);
352
+ return had;
323
353
  }
324
354
 
325
355
  #forgetKeyIdsOf(memberId) {
@@ -343,13 +373,18 @@ export class GroupSession {
343
373
  // The signing key rotates with the chain it authenticates. Keeping it would
344
374
  // let anyone holding the old public key keep attributing new packets to a
345
375
  // chain that was rotated precisely because the room membership changed.
346
- sodium.sodium_memzero(this.#signSk);
376
+ //
377
+ // Released rather than zeroed: rotation runs on every membership change, so
378
+ // a room with any churn would otherwise accumulate locked pages for the life
379
+ // of the session.
380
+ freeKey(this.#signSk);
347
381
  ({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
348
382
  }
349
383
 
350
384
  destroy() {
351
385
  this.#own.destroy();
352
- sodium.sodium_memzero(this.#signSk);
386
+ freeKey(this.#signSk);
387
+ this.#signSk = null;
353
388
  for (const chain of this.#members.values()) {
354
389
  chain.destroy();
355
390
  }
@@ -249,8 +249,17 @@ export function createBanPeer(targetNickname, reason = '') {
249
249
  return { ...base(MSG.BAN_PEER), targetNickname, reason };
250
250
  }
251
251
 
252
- export function createPeerKicked(nickname, reason = '') {
253
- return { ...base(MSG.PEER_KICKED), nickname, reason };
252
+ // `sessionId` (optional) names *which* session was removed. `nickname` cannot:
253
+ // it is not unique over time — /nick reassigns it — and a client that removed a
254
+ // peer by name would drop the wrong session whenever two people had ever shared
255
+ // one. Older clients ignore the field; it exists so a kick can be matched to the
256
+ // `peer_left` that follows it and reported as a kick rather than a departure.
257
+ export function createPeerKicked(nickname, reason = '', sessionId = null) {
258
+ const msg = { ...base(MSG.PEER_KICKED), nickname, reason };
259
+ if (sessionId) {
260
+ msg.sessionId = sessionId;
261
+ }
262
+ return msg;
254
263
  }
255
264
 
256
265
  export function createPeerMuted(nickname, durationMs) {
@@ -776,6 +776,27 @@ export class SecureWSServer {
776
776
  this.#finishRoomSwitch(ws, session, result);
777
777
  }
778
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
+
779
800
  // Shared tail of a successful room switch: notify every old room and send
780
801
  // the ROOM_CHANGED (with the private flag) to the mover.
781
802
  #finishRoomSwitch(ws, session, result) {
@@ -902,11 +923,14 @@ export class SecureWSServer {
902
923
  if (result) {
903
924
  const targetSession = this.#sessionManager.getSession(targetSessionId);
904
925
 
905
- // 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.
906
929
  this.#sessionManager.broadcastToRoom(
907
930
  room,
908
- createPeerKicked(validation.targetNickname, validation.reason),
931
+ createPeerKicked(validation.targetNickname, validation.reason, targetSessionId),
909
932
  );
933
+ this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
910
934
 
911
935
  // Notify target with room change + kick reason
912
936
  const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
@@ -1018,8 +1042,9 @@ export class SecureWSServer {
1018
1042
  if (result) {
1019
1043
  this.#sessionManager.broadcastToRoom(
1020
1044
  room,
1021
- createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
1045
+ createPeerKicked(validation.targetNickname, validation.reason || 'banned', targetSessionId),
1022
1046
  );
1047
+ this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
1023
1048
 
1024
1049
  const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
1025
1050
  targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));