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.
@@ -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';
@@ -29,7 +30,15 @@ import {
29
30
  isRoomWrapped,
30
31
  freeRoomSecrets,
31
32
  } from '../crypto/RoomKey.js';
32
- import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
33
+ import {
34
+ KEY_ROTATION_INTERVAL_MS,
35
+ EMOJI_MAP,
36
+ COVER_CONSTANT_MS,
37
+ OWN_CAPABILITIES,
38
+ CAP,
39
+ } from '../shared/constants.js';
40
+ import { normalizeCaps, peerSupports, roomSupports } from '../protocol/capabilities.js';
41
+ import { GroupSession } from '../crypto/SenderKey.js';
33
42
  import { KeyManager } from '../crypto/KeyManager.js';
34
43
  import { Handshake } from '../crypto/Handshake.js';
35
44
  import { NonceManager } from '../crypto/NonceManager.js';
@@ -76,7 +85,12 @@ export class ChatController {
76
85
  #handshake;
77
86
  #nonceManager;
78
87
  #sessionId;
79
- #peers; // Map<sessionId, { nickname, publicKey }>
88
+ #peers; // Map<sessionId, { nickname, publicKey, caps }>
89
+ #groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
90
+ #groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
91
+ #distributed = new Map(); // room -> Set<sessionId> holding our current chain
92
+ #serverCaps = []; // what the relay advertised in join_ack
93
+ #kickedSessions = new Set(); // sessionIds announced kicked, awaiting their peer_left
80
94
  #lastTypingSent;
81
95
  #peerTypingTimers; // Map<sessionId, timeoutId>
82
96
  #fileTransfer;
@@ -220,7 +234,12 @@ export class ChatController {
220
234
  this.#reconnectAttempts = 0;
221
235
  this.#ui.setConnectionState('online');
222
236
  this.#connection.send(
223
- createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
237
+ createJoin(
238
+ this.#nickname,
239
+ this.#keyManager.publicKeyB64,
240
+ this.#keyManager.pqPublicKeyB64,
241
+ OWN_CAPABILITIES,
242
+ ),
224
243
  );
225
244
  }
226
245
 
@@ -454,6 +473,10 @@ export class ChatController {
454
473
  this.#onEncryptedMessage(msg);
455
474
  break;
456
475
 
476
+ case MSG.GROUP_MESSAGE:
477
+ this.#onGroupMessage(msg);
478
+ break;
479
+
457
480
  case MSG.PEER_KEY_UPDATED:
458
481
  this.#onPeerKeyUpdated(msg);
459
482
  break;
@@ -542,6 +565,10 @@ export class ChatController {
542
565
  // ── JOIN_ACK: registered with server ──────────────────────────
543
566
  #onJoinAck(msg) {
544
567
  this.#sessionId = msg.sessionId;
568
+ // What the relay itself can do. No client can advertise this on its behalf,
569
+ // and the fan-out for a room-addressed message is the relay's job — so a
570
+ // capable room on an older hub is still not a room that can switch paths.
571
+ this.#serverCaps = normalizeCaps(msg.serverCaps);
545
572
  const room = msg.room || 'general';
546
573
  const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
547
574
 
@@ -562,6 +589,7 @@ export class ChatController {
562
589
  this.#allPeers.set(peer.sessionId, {
563
590
  nickname: peer.nickname,
564
591
  publicKey: peer.publicKey,
592
+ caps: normalizeCaps(peer.caps),
565
593
  rooms: new Set([room]),
566
594
  });
567
595
 
@@ -699,13 +727,33 @@ export class ChatController {
699
727
  this.#peers.clear();
700
728
  for (const [sid, p] of this.#allPeers) {
701
729
  if (p.rooms.has(this.#currentRoom)) {
702
- this.#peers.set(sid, { nickname: p.nickname, publicKey: p.publicKey });
730
+ this.#peers.set(sid, {
731
+ nickname: p.nickname,
732
+ publicKey: p.publicKey,
733
+ caps: p.caps || [],
734
+ });
703
735
  }
704
736
  }
705
737
  this.#ui.setOnlineCount(this.#peers.size + 1);
706
738
  this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
707
739
  }
708
740
 
741
+ // Can every member of the active room speak `cap`? Nothing calls this on a
742
+ // send path yet — group send/receive is the next step, and this is the switch
743
+ // it will be gated on. Kept here so the negotiation is testable before the
744
+ // feature that depends on it exists; `own` is overridable for exactly that,
745
+ // since this build advertises nothing yet.
746
+ roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
747
+ return roomSupports(this.#peers.values(), cap, own);
748
+ }
749
+
750
+ // Did the relay say it can do this? Separate from the room check on purpose:
751
+ // both have to hold before a send path may switch, and they fail for
752
+ // different reasons — an old peer versus an old hub.
753
+ relaySupportsCapability(cap) {
754
+ return this.#serverCaps.includes(cap);
755
+ }
756
+
709
757
  #applyTopicToUI() {
710
758
  const topic = this.#buffers.get(this.#currentRoom)?.topic;
711
759
  this.#ui.setTopic(topic?.text || null);
@@ -788,6 +836,7 @@ export class ChatController {
788
836
  this.#allPeers.set(peer.sessionId, {
789
837
  nickname: peer.nickname,
790
838
  publicKey: peer.publicKey,
839
+ caps: normalizeCaps(peer.caps),
791
840
  rooms: new Set([room]),
792
841
  });
793
842
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
@@ -805,6 +854,17 @@ export class ChatController {
805
854
  });
806
855
  }
807
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
+
808
868
  // A newcomer doesn't know my presence — send only to them
809
869
  if (this.#away || this.#statusText) {
810
870
  this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
@@ -851,17 +911,47 @@ export class ChatController {
851
911
  }
852
912
  const goneEntirely = !entry || !room || entry.rooms.size === 0;
853
913
 
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);
927
+ }
928
+
854
929
  if (goneEntirely) {
855
930
  this.#hidePeerTyping(msg.sessionId, nickname);
856
931
  this.#handshake.removePeer(msg.sessionId);
857
932
  this.#nonceManager.removePeer(msg.sessionId);
858
933
  this.#allPeers.delete(msg.sessionId);
934
+ for (const [groupRoom, group] of this.#groups) {
935
+ if (group.removeMember(msg.sessionId)) {
936
+ lostFrom.add(groupRoom);
937
+ }
938
+ }
939
+ }
940
+
941
+ for (const lost of lostFrom) {
942
+ this.#rotateGroupFor(lost);
859
943
  }
860
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
+
861
949
  if (!room || room === this.#currentRoom) {
862
950
  this.#rebuildActivePeers();
863
- this.#ui.handshakeDisconnect(nickname);
864
- } else {
951
+ if (!wasKicked) {
952
+ this.#ui.handshakeDisconnect(nickname);
953
+ }
954
+ } else if (!wasKicked) {
865
955
  this.#ui.toBuffer(room, () => {
866
956
  this.#ui.addSystemMessage(`${nickname} left #${room}`);
867
957
  });
@@ -869,8 +959,265 @@ export class ChatController {
869
959
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
870
960
  }
871
961
 
962
+ // ── Sender keys on the relay ──────────────────────────────────
963
+ //
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.
977
+
978
+ #getGroup(room) {
979
+ let group = this.#groups.get(room);
980
+ if (!group) {
981
+ group = new GroupSession();
982
+ this.#groups.set(room, group);
983
+ }
984
+ return group;
985
+ }
986
+
987
+ // A member handed us their sender key. This arrives over the pairwise sealed
988
+ // channel, so `fromSessionId` was authenticated by opening the envelope —
989
+ // never asserted by the relay, which is the whole reason distribution does not
990
+ // ride on the group path itself.
991
+ #onSenderKeyDistribution(fromSessionId, data) {
992
+ if (typeof data.room !== 'string' || !data.dist || typeof data.dist !== 'object') {
993
+ return;
994
+ }
995
+ this.#getGroup(data.room).addMember(fromSessionId, data.dist);
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));
1153
+ }
1154
+
1155
+ #onGroupMessage(msg) {
1156
+ const group = this.#groups.get(msg.room);
1157
+ const from = group ? group.memberForKeyId(msg.keyId) : null;
1158
+
1159
+ // A label we hold no distribution for. Usually a race — the fan-out beat the
1160
+ // sender key through a different path — so hold it rather than drop it.
1161
+ if (!from || !this.#allPeers.has(from)) {
1162
+ this.#bufferGroupMessage(msg);
1163
+ return;
1164
+ }
1165
+
1166
+ const plaintext = group.decrypt(from, {
1167
+ keyId: msg.keyId,
1168
+ counter: msg.counter,
1169
+ ciphertext: msg.ciphertext,
1170
+ nonce: msg.nonce,
1171
+ signature: msg.signature,
1172
+ });
1173
+ if (!plaintext) {
1174
+ // A replayed counter, a chain that rotated without us being told yet, or a
1175
+ // signature that does not check out. The first two resolve on the next
1176
+ // distribution; the third never will, and buffering it costs one slot in a
1177
+ // bounded map rather than a decision made on too little information here.
1178
+ this.#bufferGroupMessage(msg);
1179
+ return;
1180
+ }
1181
+
1182
+ // Hand it to the one path that knows what a payload means. `payload` is
1183
+ // empty because there was no pairwise envelope: the group chain replaced it.
1184
+ this.#onEncryptedMessage({ from, payload: {} }, plaintext);
1185
+ }
1186
+
1187
+ #bufferGroupMessage(msg) {
1188
+ // Bounded twice over: a hostile relay can invent keyIds all day, and each
1189
+ // one must not become a place to park memory.
1190
+ if (!this.#groupBuffer.has(msg.keyId) && this.#groupBuffer.size >= 32) {
1191
+ return;
1192
+ }
1193
+ let buf = this.#groupBuffer.get(msg.keyId);
1194
+ if (!buf) {
1195
+ buf = [];
1196
+ this.#groupBuffer.set(msg.keyId, buf);
1197
+ }
1198
+ if (buf.length < 20) {
1199
+ buf.push(msg);
1200
+ }
1201
+ }
1202
+
1203
+ #flushGroupBuffer(keyId) {
1204
+ const buf = this.#groupBuffer.get(keyId);
1205
+ if (!buf) {
1206
+ return;
1207
+ }
1208
+ this.#groupBuffer.delete(keyId);
1209
+ for (const msg of buf) {
1210
+ this.#onGroupMessage(msg);
1211
+ }
1212
+ }
1213
+
872
1214
  // ── Received encrypted message ────────────────────────────────
873
- #onEncryptedMessage(msg) {
1215
+ // `preDecrypted` is the group path handing over plaintext it already opened
1216
+ // with a sender chain (see #onGroupMessage). Everything after the decryption
1217
+ // step is shared on purpose: a group message must land in history, buffers,
1218
+ // receipts and the action dispatch exactly like a pairwise one, and the way to
1219
+ // guarantee that is for there to be only one copy of it.
1220
+ #onEncryptedMessage(msg, preDecrypted = null) {
874
1221
  // Sealed sender: the relay handed us only `to` + an opaque blob. Open it
875
1222
  // with our identity key to recover the real sender + payload; from here the
876
1223
  // rest of the handler is unchanged. A blob that isn't for us (or is tampered)
@@ -904,51 +1251,74 @@ export class ChatController {
904
1251
  return;
905
1252
  }
906
1253
 
907
- const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
908
- const nonce = Buffer.from(msg.payload.nonce, 'base64');
1254
+ let plaintext = preDecrypted;
1255
+ // A group message is never deniable — deniable sends stay pairwise, as they
1256
+ // do in P2P — and carries no pairwise envelope to inspect.
1257
+ const isDeniable = !preDecrypted && !!msg.payload.deniable;
909
1258
 
910
- let plaintext = null;
911
- const isDeniable = !!msg.payload.deniable;
1259
+ if (!plaintext) {
1260
+ const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
1261
+ const nonce = Buffer.from(msg.payload.nonce, 'base64');
912
1262
 
913
- // Deniable message path (symmetric crypto_secretbox)
914
- if (isDeniable) {
915
- // Anti-replay: deniable sends already use a structured NonceManager nonce.
916
- if (!this.#nonceManager.validate(msg.from, nonce)) {
917
- this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
918
- this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
919
- return;
920
- }
921
- const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
922
- plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
923
- if (!plaintext) {
924
- this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, { nickname: peer.nickname, deniable: true });
925
- this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
926
- return;
1263
+ // Deniable message path (symmetric crypto_secretbox)
1264
+ if (isDeniable) {
1265
+ // Anti-replay: deniable sends already use a structured NonceManager nonce.
1266
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
1267
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
1268
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
1269
+ return;
1270
+ }
1271
+ const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
1272
+ plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
1273
+ if (!plaintext) {
1274
+ this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, {
1275
+ nickname: peer.nickname,
1276
+ deniable: true,
1277
+ });
1278
+ this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
1279
+ return;
1280
+ }
927
1281
  }
928
- }
929
1282
 
930
- // Ratcheted message path (has ephemeralPublicKey)
931
- if (!isDeniable && msg.payload.ephemeralPublicKey) {
932
- const ratchet = this.#handshake.getRatchet(msg.from);
933
- if (ratchet) {
934
- const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
935
- plaintext = ratchet.decrypt(
936
- ciphertext,
937
- nonce,
938
- ephPub,
939
- msg.payload.counter,
940
- msg.payload.previousCounter,
941
- msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
942
- );
943
- }
1283
+ // Ratcheted message path (has ephemeralPublicKey)
1284
+ if (!isDeniable && msg.payload.ephemeralPublicKey) {
1285
+ const ratchet = this.#handshake.getRatchet(msg.from);
1286
+ if (ratchet) {
1287
+ const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
1288
+ plaintext = ratchet.decrypt(
1289
+ ciphertext,
1290
+ nonce,
1291
+ ephPub,
1292
+ msg.payload.counter,
1293
+ msg.payload.previousCounter,
1294
+ msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
1295
+ );
1296
+ }
944
1297
 
945
- // Fallback to static decrypt if ratchet failed
946
- if (!plaintext) {
1298
+ // Fallback to static decrypt if ratchet failed
1299
+ if (!plaintext) {
1300
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
1301
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
1302
+ this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
1303
+ return;
1304
+ }
1305
+ plaintext = MessageCrypto.decryptWithFallback(
1306
+ ciphertext,
1307
+ nonce,
1308
+ senderPublicKey,
1309
+ this.#handshake.secretKey,
1310
+ this.#handshake.getPreviousPeerPublicKey(msg.from),
1311
+ this.#handshake.previousSecretKey,
1312
+ );
1313
+ }
1314
+ } else if (!isDeniable) {
1315
+ // Static message path (no ephemeralPublicKey)
947
1316
  if (!this.#nonceManager.validate(msg.from, nonce)) {
948
1317
  this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
949
- this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
1318
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
950
1319
  return;
951
1320
  }
1321
+
952
1322
  plaintext = MessageCrypto.decryptWithFallback(
953
1323
  ciphertext,
954
1324
  nonce,
@@ -958,22 +1328,6 @@ export class ChatController {
958
1328
  this.#handshake.previousSecretKey,
959
1329
  );
960
1330
  }
961
- } else if (!isDeniable) {
962
- // Static message path (no ephemeralPublicKey)
963
- if (!this.#nonceManager.validate(msg.from, nonce)) {
964
- this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
965
- this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
966
- return;
967
- }
968
-
969
- plaintext = MessageCrypto.decryptWithFallback(
970
- ciphertext,
971
- nonce,
972
- senderPublicKey,
973
- this.#handshake.secretKey,
974
- this.#handshake.getPreviousPeerPublicKey(msg.from),
975
- this.#handshake.previousSecretKey,
976
- );
977
1331
  }
978
1332
 
979
1333
  if (!plaintext) {
@@ -1012,6 +1366,14 @@ export class ChatController {
1012
1366
  return;
1013
1367
  }
1014
1368
 
1369
+ // A sender key. Never surfaced to the user and never carried on the group
1370
+ // path itself — it has to arrive pairwise, where the envelope proves who
1371
+ // sent it.
1372
+ if (data.action === 'sk_dist') {
1373
+ this.#onSenderKeyDistribution(msg.from, data);
1374
+ return;
1375
+ }
1376
+
1015
1377
  // Which buffer this belongs to (the tag rides inside the E2EE envelope).
1016
1378
  const msgRoom = this.#roomForIncoming(data, msg.from);
1017
1379
  const roomActive = msgRoom === this.#currentRoom;
@@ -2776,7 +3138,12 @@ export class ChatController {
2776
3138
  this.#nickname = newNick;
2777
3139
  this.#ui.setNickname(newNick);
2778
3140
  this.#connection.send(
2779
- createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
3141
+ createJoin(
3142
+ newNick,
3143
+ this.#keyManager.publicKeyB64,
3144
+ this.#keyManager.pqPublicKeyB64,
3145
+ OWN_CAPABILITIES,
3146
+ ),
2780
3147
  );
2781
3148
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
2782
3149
  break;
@@ -3002,6 +3369,11 @@ export class ChatController {
3002
3369
  this.#allPeers.set(peer.sessionId, {
3003
3370
  nickname: peer.nickname,
3004
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),
3005
3377
  rooms: new Set([msg.room]),
3006
3378
  });
3007
3379
  if (!this.#handshake.getRatchet(peer.sessionId)) {
@@ -3011,6 +3383,12 @@ export class ChatController {
3011
3383
  }
3012
3384
 
3013
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();
3014
3392
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
3015
3393
  this.#announceJoinedRoom(
3016
3394
  msg.room,
@@ -3037,6 +3415,7 @@ export class ChatController {
3037
3415
  this.#allPeers.set(peer.sessionId, {
3038
3416
  nickname: peer.nickname,
3039
3417
  publicKey: peer.publicKey,
3418
+ caps: normalizeCaps(peer.caps),
3040
3419
  rooms: new Set([msg.room]),
3041
3420
  });
3042
3421
  if (!this.#handshake.getRatchet(peer.sessionId)) {
@@ -3095,6 +3474,18 @@ export class ChatController {
3095
3474
 
3096
3475
  // ── Handle PEER_KICKED ────────────────────────────────────
3097
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
+ }
3098
3489
  if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
3099
3490
  const reason = msg.reason ? ` (reason: ${msg.reason})` : '';
3100
3491
  this.#ui.addErrorMessage(`You were kicked from the room${reason}`);
@@ -3390,6 +3781,15 @@ export class ChatController {
3390
3781
  payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
3391
3782
  }
3392
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
+
3393
3793
  for (const [peerId] of this.#peers) {
3394
3794
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
3395
3795
  if (!peerPublicKey) {
@@ -3642,6 +4042,7 @@ export class ChatController {
3642
4042
  this.#historyStore.destroy();
3643
4043
  }
3644
4044
  this.#fileTransfer.destroy();
4045
+ this.#dropAllGroups();
3645
4046
  this.#handshake.destroy();
3646
4047
  this.#keyManager.destroy();
3647
4048
  this.#connection.close();