ciphermesh 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,14 @@ import {
29
29
  isRoomWrapped,
30
30
  freeRoomSecrets,
31
31
  } from '../crypto/RoomKey.js';
32
- import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
32
+ import {
33
+ KEY_ROTATION_INTERVAL_MS,
34
+ EMOJI_MAP,
35
+ COVER_CONSTANT_MS,
36
+ OWN_CAPABILITIES,
37
+ } from '../shared/constants.js';
38
+ import { normalizeCaps, roomSupports } from '../protocol/capabilities.js';
39
+ import { GroupSession } from '../crypto/SenderKey.js';
33
40
  import { KeyManager } from '../crypto/KeyManager.js';
34
41
  import { Handshake } from '../crypto/Handshake.js';
35
42
  import { NonceManager } from '../crypto/NonceManager.js';
@@ -76,7 +83,10 @@ export class ChatController {
76
83
  #handshake;
77
84
  #nonceManager;
78
85
  #sessionId;
79
- #peers; // Map<sessionId, { nickname, publicKey }>
86
+ #peers; // Map<sessionId, { nickname, publicKey, caps }>
87
+ #groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
88
+ #groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
89
+ #serverCaps = []; // what the relay advertised in join_ack
80
90
  #lastTypingSent;
81
91
  #peerTypingTimers; // Map<sessionId, timeoutId>
82
92
  #fileTransfer;
@@ -220,7 +230,12 @@ export class ChatController {
220
230
  this.#reconnectAttempts = 0;
221
231
  this.#ui.setConnectionState('online');
222
232
  this.#connection.send(
223
- createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
233
+ createJoin(
234
+ this.#nickname,
235
+ this.#keyManager.publicKeyB64,
236
+ this.#keyManager.pqPublicKeyB64,
237
+ OWN_CAPABILITIES,
238
+ ),
224
239
  );
225
240
  }
226
241
 
@@ -454,6 +469,10 @@ export class ChatController {
454
469
  this.#onEncryptedMessage(msg);
455
470
  break;
456
471
 
472
+ case MSG.GROUP_MESSAGE:
473
+ this.#onGroupMessage(msg);
474
+ break;
475
+
457
476
  case MSG.PEER_KEY_UPDATED:
458
477
  this.#onPeerKeyUpdated(msg);
459
478
  break;
@@ -542,6 +561,10 @@ export class ChatController {
542
561
  // ── JOIN_ACK: registered with server ──────────────────────────
543
562
  #onJoinAck(msg) {
544
563
  this.#sessionId = msg.sessionId;
564
+ // What the relay itself can do. No client can advertise this on its behalf,
565
+ // and the fan-out for a room-addressed message is the relay's job — so a
566
+ // capable room on an older hub is still not a room that can switch paths.
567
+ this.#serverCaps = normalizeCaps(msg.serverCaps);
545
568
  const room = msg.room || 'general';
546
569
  const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
547
570
 
@@ -562,6 +585,7 @@ export class ChatController {
562
585
  this.#allPeers.set(peer.sessionId, {
563
586
  nickname: peer.nickname,
564
587
  publicKey: peer.publicKey,
588
+ caps: normalizeCaps(peer.caps),
565
589
  rooms: new Set([room]),
566
590
  });
567
591
 
@@ -699,13 +723,33 @@ export class ChatController {
699
723
  this.#peers.clear();
700
724
  for (const [sid, p] of this.#allPeers) {
701
725
  if (p.rooms.has(this.#currentRoom)) {
702
- this.#peers.set(sid, { nickname: p.nickname, publicKey: p.publicKey });
726
+ this.#peers.set(sid, {
727
+ nickname: p.nickname,
728
+ publicKey: p.publicKey,
729
+ caps: p.caps || [],
730
+ });
703
731
  }
704
732
  }
705
733
  this.#ui.setOnlineCount(this.#peers.size + 1);
706
734
  this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
707
735
  }
708
736
 
737
+ // Can every member of the active room speak `cap`? Nothing calls this on a
738
+ // send path yet — group send/receive is the next step, and this is the switch
739
+ // it will be gated on. Kept here so the negotiation is testable before the
740
+ // feature that depends on it exists; `own` is overridable for exactly that,
741
+ // since this build advertises nothing yet.
742
+ roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
743
+ return roomSupports(this.#peers.values(), cap, own);
744
+ }
745
+
746
+ // Did the relay say it can do this? Separate from the room check on purpose:
747
+ // both have to hold before a send path may switch, and they fail for
748
+ // different reasons — an old peer versus an old hub.
749
+ relaySupportsCapability(cap) {
750
+ return this.#serverCaps.includes(cap);
751
+ }
752
+
709
753
  #applyTopicToUI() {
710
754
  const topic = this.#buffers.get(this.#currentRoom)?.topic;
711
755
  this.#ui.setTopic(topic?.text || null);
@@ -788,6 +832,7 @@ export class ChatController {
788
832
  this.#allPeers.set(peer.sessionId, {
789
833
  nickname: peer.nickname,
790
834
  publicKey: peer.publicKey,
835
+ caps: normalizeCaps(peer.caps),
791
836
  rooms: new Set([room]),
792
837
  });
793
838
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
@@ -851,11 +896,21 @@ export class ChatController {
851
896
  }
852
897
  const goneEntirely = !entry || !room || entry.rooms.size === 0;
853
898
 
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);
904
+ }
905
+
854
906
  if (goneEntirely) {
855
907
  this.#hidePeerTyping(msg.sessionId, nickname);
856
908
  this.#handshake.removePeer(msg.sessionId);
857
909
  this.#nonceManager.removePeer(msg.sessionId);
858
910
  this.#allPeers.delete(msg.sessionId);
911
+ for (const group of this.#groups.values()) {
912
+ group.removeMember(msg.sessionId);
913
+ }
859
914
  }
860
915
 
861
916
  if (!room || room === this.#currentRoom) {
@@ -869,8 +924,101 @@ export class ChatController {
869
924
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
870
925
  }
871
926
 
927
+ // ── Sender keys on the relay: the receive half ────────────────
928
+ //
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.
934
+
935
+ #getGroup(room) {
936
+ let group = this.#groups.get(room);
937
+ if (!group) {
938
+ group = new GroupSession();
939
+ this.#groups.set(room, group);
940
+ }
941
+ return group;
942
+ }
943
+
944
+ // A member handed us their sender key. This arrives over the pairwise sealed
945
+ // channel, so `fromSessionId` was authenticated by opening the envelope —
946
+ // never asserted by the relay, which is the whole reason distribution does not
947
+ // ride on the group path itself.
948
+ #onSenderKeyDistribution(fromSessionId, data) {
949
+ if (typeof data.room !== 'string' || !data.dist || typeof data.dist !== 'object') {
950
+ return;
951
+ }
952
+ this.#getGroup(data.room).addMember(fromSessionId, data.dist);
953
+ this.#flushGroupBuffer(data.dist.keyId);
954
+ }
955
+
956
+ #onGroupMessage(msg) {
957
+ const group = this.#groups.get(msg.room);
958
+ const from = group ? group.memberForKeyId(msg.keyId) : null;
959
+
960
+ // A label we hold no distribution for. Usually a race — the fan-out beat the
961
+ // sender key through a different path — so hold it rather than drop it.
962
+ if (!from || !this.#allPeers.has(from)) {
963
+ this.#bufferGroupMessage(msg);
964
+ return;
965
+ }
966
+
967
+ const plaintext = group.decrypt(from, {
968
+ keyId: msg.keyId,
969
+ counter: msg.counter,
970
+ ciphertext: msg.ciphertext,
971
+ nonce: msg.nonce,
972
+ signature: msg.signature,
973
+ });
974
+ if (!plaintext) {
975
+ // A replayed counter, a chain that rotated without us being told yet, or a
976
+ // signature that does not check out. The first two resolve on the next
977
+ // distribution; the third never will, and buffering it costs one slot in a
978
+ // bounded map rather than a decision made on too little information here.
979
+ this.#bufferGroupMessage(msg);
980
+ return;
981
+ }
982
+
983
+ // Hand it to the one path that knows what a payload means. `payload` is
984
+ // empty because there was no pairwise envelope: the group chain replaced it.
985
+ this.#onEncryptedMessage({ from, payload: {} }, plaintext);
986
+ }
987
+
988
+ #bufferGroupMessage(msg) {
989
+ // Bounded twice over: a hostile relay can invent keyIds all day, and each
990
+ // one must not become a place to park memory.
991
+ if (!this.#groupBuffer.has(msg.keyId) && this.#groupBuffer.size >= 32) {
992
+ return;
993
+ }
994
+ let buf = this.#groupBuffer.get(msg.keyId);
995
+ if (!buf) {
996
+ buf = [];
997
+ this.#groupBuffer.set(msg.keyId, buf);
998
+ }
999
+ if (buf.length < 20) {
1000
+ buf.push(msg);
1001
+ }
1002
+ }
1003
+
1004
+ #flushGroupBuffer(keyId) {
1005
+ const buf = this.#groupBuffer.get(keyId);
1006
+ if (!buf) {
1007
+ return;
1008
+ }
1009
+ this.#groupBuffer.delete(keyId);
1010
+ for (const msg of buf) {
1011
+ this.#onGroupMessage(msg);
1012
+ }
1013
+ }
1014
+
872
1015
  // ── Received encrypted message ────────────────────────────────
873
- #onEncryptedMessage(msg) {
1016
+ // `preDecrypted` is the group path handing over plaintext it already opened
1017
+ // with a sender chain (see #onGroupMessage). Everything after the decryption
1018
+ // step is shared on purpose: a group message must land in history, buffers,
1019
+ // receipts and the action dispatch exactly like a pairwise one, and the way to
1020
+ // guarantee that is for there to be only one copy of it.
1021
+ #onEncryptedMessage(msg, preDecrypted = null) {
874
1022
  // Sealed sender: the relay handed us only `to` + an opaque blob. Open it
875
1023
  // with our identity key to recover the real sender + payload; from here the
876
1024
  // rest of the handler is unchanged. A blob that isn't for us (or is tampered)
@@ -904,51 +1052,74 @@ export class ChatController {
904
1052
  return;
905
1053
  }
906
1054
 
907
- const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
908
- const nonce = Buffer.from(msg.payload.nonce, 'base64');
1055
+ let plaintext = preDecrypted;
1056
+ // A group message is never deniable — deniable sends stay pairwise, as they
1057
+ // do in P2P — and carries no pairwise envelope to inspect.
1058
+ const isDeniable = !preDecrypted && !!msg.payload.deniable;
909
1059
 
910
- let plaintext = null;
911
- const isDeniable = !!msg.payload.deniable;
1060
+ if (!plaintext) {
1061
+ const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
1062
+ const nonce = Buffer.from(msg.payload.nonce, 'base64');
912
1063
 
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;
1064
+ // Deniable message path (symmetric crypto_secretbox)
1065
+ if (isDeniable) {
1066
+ // Anti-replay: deniable sends already use a structured NonceManager nonce.
1067
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
1068
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
1069
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
1070
+ return;
1071
+ }
1072
+ const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
1073
+ plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
1074
+ if (!plaintext) {
1075
+ this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, {
1076
+ nickname: peer.nickname,
1077
+ deniable: true,
1078
+ });
1079
+ this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
1080
+ return;
1081
+ }
927
1082
  }
928
- }
929
1083
 
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
- }
1084
+ // Ratcheted message path (has ephemeralPublicKey)
1085
+ if (!isDeniable && msg.payload.ephemeralPublicKey) {
1086
+ const ratchet = this.#handshake.getRatchet(msg.from);
1087
+ if (ratchet) {
1088
+ const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
1089
+ plaintext = ratchet.decrypt(
1090
+ ciphertext,
1091
+ nonce,
1092
+ ephPub,
1093
+ msg.payload.counter,
1094
+ msg.payload.previousCounter,
1095
+ msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
1096
+ );
1097
+ }
944
1098
 
945
- // Fallback to static decrypt if ratchet failed
946
- if (!plaintext) {
1099
+ // Fallback to static decrypt if ratchet failed
1100
+ if (!plaintext) {
1101
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
1102
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
1103
+ this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
1104
+ return;
1105
+ }
1106
+ plaintext = MessageCrypto.decryptWithFallback(
1107
+ ciphertext,
1108
+ nonce,
1109
+ senderPublicKey,
1110
+ this.#handshake.secretKey,
1111
+ this.#handshake.getPreviousPeerPublicKey(msg.from),
1112
+ this.#handshake.previousSecretKey,
1113
+ );
1114
+ }
1115
+ } else if (!isDeniable) {
1116
+ // Static message path (no ephemeralPublicKey)
947
1117
  if (!this.#nonceManager.validate(msg.from, nonce)) {
948
1118
  this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
949
- this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
1119
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
950
1120
  return;
951
1121
  }
1122
+
952
1123
  plaintext = MessageCrypto.decryptWithFallback(
953
1124
  ciphertext,
954
1125
  nonce,
@@ -958,22 +1129,6 @@ export class ChatController {
958
1129
  this.#handshake.previousSecretKey,
959
1130
  );
960
1131
  }
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
1132
  }
978
1133
 
979
1134
  if (!plaintext) {
@@ -1012,6 +1167,14 @@ export class ChatController {
1012
1167
  return;
1013
1168
  }
1014
1169
 
1170
+ // A sender key. Never surfaced to the user and never carried on the group
1171
+ // path itself — it has to arrive pairwise, where the envelope proves who
1172
+ // sent it.
1173
+ if (data.action === 'sk_dist') {
1174
+ this.#onSenderKeyDistribution(msg.from, data);
1175
+ return;
1176
+ }
1177
+
1015
1178
  // Which buffer this belongs to (the tag rides inside the E2EE envelope).
1016
1179
  const msgRoom = this.#roomForIncoming(data, msg.from);
1017
1180
  const roomActive = msgRoom === this.#currentRoom;
@@ -2776,7 +2939,12 @@ export class ChatController {
2776
2939
  this.#nickname = newNick;
2777
2940
  this.#ui.setNickname(newNick);
2778
2941
  this.#connection.send(
2779
- createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
2942
+ createJoin(
2943
+ newNick,
2944
+ this.#keyManager.publicKeyB64,
2945
+ this.#keyManager.pqPublicKeyB64,
2946
+ OWN_CAPABILITIES,
2947
+ ),
2780
2948
  );
2781
2949
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
2782
2950
  break;
@@ -3642,6 +3810,11 @@ export class ChatController {
3642
3810
  this.#historyStore.destroy();
3643
3811
  }
3644
3812
  this.#fileTransfer.destroy();
3813
+ for (const group of this.#groups.values()) {
3814
+ group.destroy();
3815
+ }
3816
+ this.#groups.clear();
3817
+ this.#groupBuffer.clear();
3645
3818
  this.#handshake.destroy();
3646
3819
  this.#keyManager.destroy();
3647
3820
  this.#connection.close();
@@ -14,6 +14,62 @@ const KEY_SIZE = 32;
14
14
  const MSG_KEY_TAG = Buffer.from([0x01]);
15
15
  const CHAIN_KEY_TAG = Buffer.from([0x02]);
16
16
  const DEFAULT_MAX_SKIP = 1000; // bound out-of-order / skipped message keys
17
+ const KEY_ID_SIZE = 16;
18
+
19
+ // An opaque label for a sender chain, handed out with the distribution.
20
+ //
21
+ // On the relay path a group message arrives by fan-out with no sender on it —
22
+ // the relay must not stamp one, or it would be asserting an identity that today
23
+ // is only ever carried sealed inside the envelope. So the packet names its
24
+ // *chain* instead of its sender, and only members who hold the distribution can
25
+ // map the two. To the relay it is a random string; it already knows which socket
26
+ // sent the frame, so this tells it nothing new. It is drawn fresh on every
27
+ // rotate(), so it never outlives the chain it labels.
28
+ function newKeyId() {
29
+ const buf = Buffer.alloc(KEY_ID_SIZE);
30
+ sodium.randombytes_buf(buf);
31
+ return buf.toString('base64');
32
+ }
33
+
34
+ // ── Per-sender signatures ───────────────────────────────────────
35
+ // A sender key is symmetric: every member of the room holds the chain that
36
+ // decrypts a given sender, which means every member can also *produce*
37
+ // ciphertext on it. Without something asymmetric on top, "Alice said this" is
38
+ // only ever "somebody in this room said this" — and on a public relay, where
39
+ // `general` has no owner and no admission control, that is a much wider set of
40
+ // somebodies than in a P2P mesh.
41
+ //
42
+ // So each sender also holds an Ed25519 keypair for the life of its chain. The
43
+ // public half travels in the distribution, over the pairwise sealed channel that
44
+ // already authenticates who sent it; every packet carries a detached signature.
45
+ // Forging a member now needs their signing key, not just membership.
46
+ function newSigningKeypair() {
47
+ const publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
48
+ const secretKey = sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES);
49
+ sodium.crypto_sign_keypair(publicKey, secretKey);
50
+ return { publicKey, secretKey };
51
+ }
52
+
53
+ // What the signature covers. Every field the relay could tamper with or replay
54
+ // across chains: the label, the position in the chain, and the box itself.
55
+ // Length-prefixed so no two different packets can serialise to the same bytes.
56
+ function signedBytes({ keyId, counter, ciphertext, nonce }) {
57
+ const parts = [keyId, String(counter), ciphertext, nonce];
58
+ return Buffer.from(parts.map((p) => `${p.length}:${p}`).join('|'), 'utf-8');
59
+ }
60
+
61
+ function decodeSignPk(b64) {
62
+ if (typeof b64 !== 'string') {
63
+ return null;
64
+ }
65
+ let buf;
66
+ try {
67
+ buf = Buffer.from(b64, 'base64');
68
+ } catch {
69
+ return null;
70
+ }
71
+ return buf.length === sodium.crypto_sign_PUBLICKEYBYTES ? buf : null;
72
+ }
17
73
 
18
74
  // A single sender's ratchet chain. Used to *send* (deriveNext) when it's your
19
75
  // own chain, or to *receive* (messageKeyFor) when it's a peer's distributed one.
@@ -129,28 +185,51 @@ export function groupDecrypt(messageKey, ciphertext, nonce) {
129
185
  // member. encrypt() runs once; every member decrypt()s the same ciphertext.
130
186
  export class GroupSession {
131
187
  #own;
188
+ #ownKeyId;
189
+ #signPk; // Ed25519 — proves *which member* wrote a packet
190
+ #signSk;
132
191
  #members; // Map<memberId, SenderChain>
192
+ #byKeyId; // Map<keyId, memberId> — which chain opens an incoming packet
193
+ #memberSignPk; // Map<memberId, Buffer> — who is allowed to have written it
133
194
 
134
195
  constructor() {
135
196
  this.#own = new SenderChain();
197
+ this.#ownKeyId = newKeyId();
198
+ ({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
136
199
  this.#members = new Map();
200
+ this.#byKeyId = new Map();
201
+ this.#memberSignPk = new Map();
137
202
  }
138
203
 
139
204
  encrypt(plaintext) {
140
205
  const { messageKey, counter } = this.#own.deriveNext();
141
206
  const { ciphertext, nonce } = groupEncrypt(messageKey, plaintext);
142
- return {
207
+ const packet = {
208
+ keyId: this.#ownKeyId,
143
209
  counter,
144
210
  ciphertext: ciphertext.toString('base64'),
145
211
  nonce: nonce.toString('base64'),
146
212
  };
213
+ const signature = Buffer.alloc(sodium.crypto_sign_BYTES);
214
+ sodium.crypto_sign_detached(signature, signedBytes(packet), this.#signSk);
215
+ packet.signature = signature.toString('base64');
216
+ return packet;
147
217
  }
148
218
 
149
- decrypt(memberId, { counter, ciphertext, nonce }) {
219
+ decrypt(memberId, { keyId, counter, ciphertext, nonce, signature }) {
150
220
  const chain = this.#members.get(memberId);
151
221
  if (!chain) {
152
222
  return null;
153
223
  }
224
+
225
+ // Verify BEFORE touching the chain. Two reasons, and the second is the one
226
+ // that is easy to miss: a bad signature must not be able to advance the
227
+ // ratchet or fill the skipped-key cache, or an unauthenticated packet with a
228
+ // large counter becomes a way to make the receiver derive a thousand keys.
229
+ if (!this.#verify(memberId, { keyId, counter, ciphertext, nonce, signature })) {
230
+ return null;
231
+ }
232
+
154
233
  const messageKey = chain.messageKeyFor(counter);
155
234
  if (!messageKey) {
156
235
  return null;
@@ -162,9 +241,51 @@ export class GroupSession {
162
241
  );
163
242
  }
164
243
 
244
+ #verify(memberId, packet) {
245
+ const signPk = this.#memberSignPk.get(memberId);
246
+ if (!signPk || typeof packet.signature !== 'string' || typeof packet.keyId !== 'string') {
247
+ return false;
248
+ }
249
+ // The label on the packet has to be the one this member distributed.
250
+ // Verifying against the stored label instead would leave keyId outside
251
+ // everything that protects it — the AEAD does not cover it either — so
252
+ // decrypt() could be talked into checking one member's signature against
253
+ // another member's label.
254
+ if (this.#byKeyId.get(packet.keyId) !== memberId) {
255
+ return false;
256
+ }
257
+ let sig;
258
+ try {
259
+ sig = Buffer.from(packet.signature, 'base64');
260
+ } catch {
261
+ return false;
262
+ }
263
+ if (sig.length !== sodium.crypto_sign_BYTES) {
264
+ return false;
265
+ }
266
+ return sodium.crypto_sign_verify_detached(sig, signedBytes(packet), signPk);
267
+ }
268
+
165
269
  // The distribution message to hand a (new) member so they can decrypt you.
270
+ // `keyId` labels the chain; `signPk` is what stops any *other* member using
271
+ // that chain to write in your name.
166
272
  distribution() {
167
- return this.#own.serialize();
273
+ return {
274
+ ...this.#own.serialize(),
275
+ keyId: this.#ownKeyId,
276
+ signPk: this.#signPk.toString('base64'),
277
+ };
278
+ }
279
+
280
+ // Which member does an incoming packet's keyId belong to? Null for a label we
281
+ // were never given a distribution for — an unknown sender, or one that
282
+ // rotated without telling us yet.
283
+ memberForKeyId(keyId) {
284
+ return this.#byKeyId.get(keyId) ?? null;
285
+ }
286
+
287
+ get keyId() {
288
+ return this.#ownKeyId;
168
289
  }
169
290
 
170
291
  addMember(memberId, distribution) {
@@ -172,7 +293,23 @@ export class GroupSession {
172
293
  if (existing) {
173
294
  existing.destroy();
174
295
  }
296
+ // Drop any label this member held before — a redistribution after rotate()
297
+ // replaces the chain, and leaving the old keyId mapped would route the
298
+ // member's next packet to a chain that can no longer open it.
299
+ this.#forgetKeyIdsOf(memberId);
175
300
  this.#members.set(memberId, SenderChain.deserialize(distribution));
301
+ if (typeof distribution?.keyId === 'string') {
302
+ this.#byKeyId.set(distribution.keyId, memberId);
303
+ }
304
+ // A distribution without a usable signing key leaves the member registered
305
+ // but unreadable: #verify fails closed, so nothing they send is accepted.
306
+ // Better a member who cannot be heard than one who cannot be attributed.
307
+ const signPk = decodeSignPk(distribution?.signPk);
308
+ if (signPk) {
309
+ this.#memberSignPk.set(memberId, signPk);
310
+ } else {
311
+ this.#memberSignPk.delete(memberId);
312
+ }
176
313
  }
177
314
 
178
315
  removeMember(memberId) {
@@ -181,6 +318,16 @@ export class GroupSession {
181
318
  chain.destroy();
182
319
  this.#members.delete(memberId);
183
320
  }
321
+ this.#forgetKeyIdsOf(memberId);
322
+ this.#memberSignPk.delete(memberId);
323
+ }
324
+
325
+ #forgetKeyIdsOf(memberId) {
326
+ for (const [id, owner] of this.#byKeyId) {
327
+ if (owner === memberId) {
328
+ this.#byKeyId.delete(id);
329
+ }
330
+ }
184
331
  }
185
332
 
186
333
  hasMember(memberId) {
@@ -192,13 +339,22 @@ export class GroupSession {
192
339
  rotate() {
193
340
  this.#own.destroy();
194
341
  this.#own = new SenderChain();
342
+ this.#ownKeyId = newKeyId();
343
+ // The signing key rotates with the chain it authenticates. Keeping it would
344
+ // let anyone holding the old public key keep attributing new packets to a
345
+ // chain that was rotated precisely because the room membership changed.
346
+ sodium.sodium_memzero(this.#signSk);
347
+ ({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
195
348
  }
196
349
 
197
350
  destroy() {
198
351
  this.#own.destroy();
352
+ sodium.sodium_memzero(this.#signSk);
199
353
  for (const chain of this.#members.values()) {
200
354
  chain.destroy();
201
355
  }
202
356
  this.#members.clear();
357
+ this.#byKeyId.clear();
358
+ this.#memberSignPk.clear();
203
359
  }
204
360
  }
@@ -2392,9 +2392,11 @@ export class P2PChatController {
2392
2392
  return;
2393
2393
  }
2394
2394
  const plaintext = this.#getGroup(msg.room).decrypt(fromNickname, {
2395
+ keyId: msg.keyId,
2395
2396
  counter: msg.counter,
2396
2397
  ciphertext: msg.ciphertext,
2397
2398
  nonce: msg.nonce,
2399
+ signature: msg.signature,
2398
2400
  });
2399
2401
  if (!plaintext) {
2400
2402
  // No sender key yet (rare race) — buffer until sk_dist arrives.