ciphermesh 2.9.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.
@@ -0,0 +1,118 @@
1
+ # Sender keys on the relay
2
+
3
+ Status: **design, not implemented.** Written 2026-08-07, straight after
4
+ measuring the problem, so the next session starts from the constraints rather
5
+ than rediscovering them.
6
+
7
+ ## The problem, measured
8
+
9
+ `ChatController.#broadcastPayload` loops over every peer in the room and seals
10
+ one envelope each:
11
+
12
+ ```js
13
+ for (const [peerId] of this.#peers) {
14
+ const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
15
+ ...
16
+ this.#sealAndSend(peerPublicKey, msg);
17
+ }
18
+ ```
19
+
20
+ One typed line in a room of N people is **N encryptions and N envelopes on the
21
+ wire**. Cost grows linearly with room size, on the sender's CPU and on the
22
+ sender's uplink — the two places least able to absorb it.
23
+
24
+ 2.10.0 made this a ceiling rather than a slope. `MAX_BYTES_PER_SECOND` bounds a
25
+ connection at 1 MiB/s by default, and messages are padded into buckets of up to
26
+ 32 KiB. A 32 KiB bucket sent to fifty people is 1.6 MiB for one line: the sender
27
+ is throttled, or disconnected, for saying one thing.
28
+
29
+ P2P does not have this problem. `P2PChatController` already uses
30
+ `GroupSession` from `src/crypto/SenderKey.js`, encrypts once, and distributes
31
+ the sender key per member. The code is written and tested; it is only the relay
32
+ path that never adopted it.
33
+
34
+ ## What changes
35
+
36
+ 1. Each member holds a **sender chain** for the room and distributes its
37
+ `distribution()` to every other member — sealed per member, once, rather
38
+ than per message.
39
+ 2. A message is encrypted **once** with the sender's chain and handed to the
40
+ relay with a room destination rather than a peer destination.
41
+ 3. The relay fans the single ciphertext out to the room's members. It still
42
+ cannot read anything, and it still never learns the sender under sealed
43
+ sender.
44
+ 4. On any membership change, the leaver's departure triggers `rotate()` and a
45
+ redistribution — exactly what `P2PChatController` does today at lines 320
46
+ and 487, with the comment already written there.
47
+
48
+ Cost per message goes from N encryptions and N envelopes to **one and one**.
49
+ Distribution cost is N, but paid on join and on membership change rather than
50
+ on every line.
51
+
52
+ ## The hard part: two versions in one room
53
+
54
+ This is a protocol change. A 2.11 client encrypting once to a group and a 2.10
55
+ client expecting an envelope addressed to it **cannot read each other**. The hub
56
+ is public and people upgrade whenever they upgrade, so "everyone updates at
57
+ once" is not available.
58
+
59
+ Rolling this out badly breaks live conversations for strangers. Options, in the
60
+ order they should be considered:
61
+
62
+ - **Negotiate, do not assume.** The JOIN acknowledgement already carries each
63
+ peer's public key; it can carry a capability list too. A sender uses group
64
+ encryption only when *every* member of the room advertises it, and falls back
65
+ to the current per-peer loop otherwise. Costs a room-wide check per send,
66
+ which is cheap and already computed.
67
+ - **Both paths coexist for at least one minor.** Deleting the fan-out in the
68
+ same release that adds sender keys leaves no way back if the new path has a
69
+ bug that only shows at scale — which is exactly the kind of bug it would have.
70
+ - **The relay needs a room-addressed message type** that does not exist yet.
71
+ It must not weaken sealed sender: today the relay learns the recipient and not
72
+ the sender, and a room-addressed envelope must not accidentally invert that.
73
+
74
+ ## What to be careful about
75
+
76
+ - **Rotation must be wired to every departure**, not just voluntary leaves.
77
+ `/kick`, `/mute`, `/ban` and a dropped connection all change membership. The
78
+ P2P side rotates on `peer_left`; the relay side has more ways to lose a member.
79
+ - **`SenderKey.rotate()` is a caller responsibility** — its own comment says
80
+ so. The distribution has to follow it or the room silently stops being able to
81
+ read the rotator.
82
+ - **Private rooms add a second layer** (`encryptRoomPayload` with the
83
+ password-derived key). Group encryption goes *inside* that, not instead of it.
84
+ - **The offline queue** stores envelopes addressed to a peer. A room-addressed
85
+ message needs an answer for someone who was offline when it was sent, and
86
+ "they get the sender key on rejoin but not the backlog" is a decision to make
87
+ deliberately rather than discover.
88
+
89
+ **Decided (2026-08-10): room-addressed messages are not queued.** The reason is
90
+ not policy but arithmetic — a sender key handed over on someone's return
91
+ serialises the chain at its *current* counter, so the backlog is unreadable to
92
+ them whatever the relay does with it. Queueing would hold ciphertext nobody can
93
+ open: all of the storage and the liability, none of the delivery. The unicast
94
+ queue survives because an envelope addressed to a peer is still openable when
95
+ they return with the same key. Pinned in `test/group-receive.test.js`.
96
+
97
+ - **Sender keys are symmetric, so any member can forge another.** Not in the
98
+ original list, and it does not matter much in a P2P mesh where membership is
99
+ small and deliberate. It matters on a public hub. **Closed (2026-08-10)** with
100
+ an Ed25519 key per sender chain, distributed alongside the chain and verified
101
+ before the ratchet is touched. Doing it before the send path existed meant it
102
+ cost a field on a wire nobody was using yet.
103
+
104
+ ## Suggested order
105
+
106
+ 1. Test vectors for `SenderKey` distribution and rotation, so both sides of the
107
+ change are pinned before either moves.
108
+ 2. Capability advertisement in JOIN, with the fallback path left untouched.
109
+ 3. Group send/receive behind that capability, both paths live.
110
+ 4. Rotation wired to every membership change, with a test per route in.
111
+ 5. Only then, consider retiring the per-peer loop — a release later, at least.
112
+
113
+ ## Why it is worth it
114
+
115
+ It is the one change that is simultaneously a feature, a fix and an
116
+ improvement: it removes a scaling limit the project just made visible to itself,
117
+ it reuses code that already exists and is already tested, and it is the
118
+ difference between the hub holding a room of five and a room of fifty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.9.0",
3
+ "version": "2.11.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",
@@ -49,7 +49,8 @@
49
49
  "docker:build": "docker compose build",
50
50
  "docker:up": "docker compose up -d",
51
51
  "docker:down": "docker compose down",
52
- "docker:logs": "docker compose logs -f"
52
+ "docker:logs": "docker compose logs -f",
53
+ "commands:build": "node scripts/generate-commands.mjs"
53
54
  },
54
55
  "dependencies": {
55
56
  "@noble/post-quantum": "0.6.1",
@@ -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';
@@ -61,6 +68,7 @@ import {
61
68
  } from '../shared/dnd.js';
62
69
  import { saveLastSession } from '../shared/lastSession.js';
63
70
  import { diagnose, formatDiagnosis } from '../shared/doctor.js';
71
+ import { pluginsCommand } from '../shared/pluginCommand.js';
64
72
  import { COMMANDS } from './UI.js';
65
73
 
66
74
  const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
@@ -75,7 +83,10 @@ export class ChatController {
75
83
  #handshake;
76
84
  #nonceManager;
77
85
  #sessionId;
78
- #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
79
90
  #lastTypingSent;
80
91
  #peerTypingTimers; // Map<sessionId, timeoutId>
81
92
  #fileTransfer;
@@ -219,7 +230,12 @@ export class ChatController {
219
230
  this.#reconnectAttempts = 0;
220
231
  this.#ui.setConnectionState('online');
221
232
  this.#connection.send(
222
- 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
+ ),
223
239
  );
224
240
  }
225
241
 
@@ -453,6 +469,10 @@ export class ChatController {
453
469
  this.#onEncryptedMessage(msg);
454
470
  break;
455
471
 
472
+ case MSG.GROUP_MESSAGE:
473
+ this.#onGroupMessage(msg);
474
+ break;
475
+
456
476
  case MSG.PEER_KEY_UPDATED:
457
477
  this.#onPeerKeyUpdated(msg);
458
478
  break;
@@ -541,6 +561,10 @@ export class ChatController {
541
561
  // ── JOIN_ACK: registered with server ──────────────────────────
542
562
  #onJoinAck(msg) {
543
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);
544
568
  const room = msg.room || 'general';
545
569
  const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
546
570
 
@@ -561,6 +585,7 @@ export class ChatController {
561
585
  this.#allPeers.set(peer.sessionId, {
562
586
  nickname: peer.nickname,
563
587
  publicKey: peer.publicKey,
588
+ caps: normalizeCaps(peer.caps),
564
589
  rooms: new Set([room]),
565
590
  });
566
591
 
@@ -698,13 +723,33 @@ export class ChatController {
698
723
  this.#peers.clear();
699
724
  for (const [sid, p] of this.#allPeers) {
700
725
  if (p.rooms.has(this.#currentRoom)) {
701
- 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
+ });
702
731
  }
703
732
  }
704
733
  this.#ui.setOnlineCount(this.#peers.size + 1);
705
734
  this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
706
735
  }
707
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
+
708
753
  #applyTopicToUI() {
709
754
  const topic = this.#buffers.get(this.#currentRoom)?.topic;
710
755
  this.#ui.setTopic(topic?.text || null);
@@ -787,6 +832,7 @@ export class ChatController {
787
832
  this.#allPeers.set(peer.sessionId, {
788
833
  nickname: peer.nickname,
789
834
  publicKey: peer.publicKey,
835
+ caps: normalizeCaps(peer.caps),
790
836
  rooms: new Set([room]),
791
837
  });
792
838
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
@@ -850,11 +896,21 @@ export class ChatController {
850
896
  }
851
897
  const goneEntirely = !entry || !room || entry.rooms.size === 0;
852
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
+
853
906
  if (goneEntirely) {
854
907
  this.#hidePeerTyping(msg.sessionId, nickname);
855
908
  this.#handshake.removePeer(msg.sessionId);
856
909
  this.#nonceManager.removePeer(msg.sessionId);
857
910
  this.#allPeers.delete(msg.sessionId);
911
+ for (const group of this.#groups.values()) {
912
+ group.removeMember(msg.sessionId);
913
+ }
858
914
  }
859
915
 
860
916
  if (!room || room === this.#currentRoom) {
@@ -868,8 +924,101 @@ export class ChatController {
868
924
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
869
925
  }
870
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
+
871
1015
  // ── Received encrypted message ────────────────────────────────
872
- #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) {
873
1022
  // Sealed sender: the relay handed us only `to` + an opaque blob. Open it
874
1023
  // with our identity key to recover the real sender + payload; from here the
875
1024
  // rest of the handler is unchanged. A blob that isn't for us (or is tampered)
@@ -903,51 +1052,74 @@ export class ChatController {
903
1052
  return;
904
1053
  }
905
1054
 
906
- const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
907
- 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;
908
1059
 
909
- let plaintext = null;
910
- 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');
911
1063
 
912
- // Deniable message path (symmetric crypto_secretbox)
913
- if (isDeniable) {
914
- // Anti-replay: deniable sends already use a structured NonceManager nonce.
915
- if (!this.#nonceManager.validate(msg.from, nonce)) {
916
- this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
917
- this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
918
- return;
919
- }
920
- const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
921
- plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
922
- if (!plaintext) {
923
- this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, { nickname: peer.nickname, deniable: true });
924
- this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
925
- 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
+ }
926
1082
  }
927
- }
928
1083
 
929
- // Ratcheted message path (has ephemeralPublicKey)
930
- if (!isDeniable && msg.payload.ephemeralPublicKey) {
931
- const ratchet = this.#handshake.getRatchet(msg.from);
932
- if (ratchet) {
933
- const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
934
- plaintext = ratchet.decrypt(
935
- ciphertext,
936
- nonce,
937
- ephPub,
938
- msg.payload.counter,
939
- msg.payload.previousCounter,
940
- msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
941
- );
942
- }
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
+ }
943
1098
 
944
- // Fallback to static decrypt if ratchet failed
945
- 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)
946
1117
  if (!this.#nonceManager.validate(msg.from, nonce)) {
947
1118
  this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
948
- this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
1119
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
949
1120
  return;
950
1121
  }
1122
+
951
1123
  plaintext = MessageCrypto.decryptWithFallback(
952
1124
  ciphertext,
953
1125
  nonce,
@@ -957,22 +1129,6 @@ export class ChatController {
957
1129
  this.#handshake.previousSecretKey,
958
1130
  );
959
1131
  }
960
- } else if (!isDeniable) {
961
- // Static message path (no ephemeralPublicKey)
962
- if (!this.#nonceManager.validate(msg.from, nonce)) {
963
- this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
964
- this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
965
- return;
966
- }
967
-
968
- plaintext = MessageCrypto.decryptWithFallback(
969
- ciphertext,
970
- nonce,
971
- senderPublicKey,
972
- this.#handshake.secretKey,
973
- this.#handshake.getPreviousPeerPublicKey(msg.from),
974
- this.#handshake.previousSecretKey,
975
- );
976
1132
  }
977
1133
 
978
1134
  if (!plaintext) {
@@ -1011,6 +1167,14 @@ export class ChatController {
1011
1167
  return;
1012
1168
  }
1013
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
+
1014
1178
  // Which buffer this belongs to (the tag rides inside the E2EE envelope).
1015
1179
  const msgRoom = this.#roomForIncoming(data, msg.from);
1016
1180
  const roomActive = msgRoom === this.#currentRoom;
@@ -1518,7 +1682,9 @@ export class ChatController {
1518
1682
  this.#ui.addInfoMessage(
1519
1683
  ' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
1520
1684
  );
1521
- this.#ui.addInfoMessage(' /plugins - List loaded plugins');
1685
+ this.#ui.addInfoMessage(
1686
+ ' /plugins [allow <file>] - List plugins; approve one before it runs',
1687
+ );
1522
1688
  this.#ui.addInfoMessage(' /quit - Leave the chat');
1523
1689
  this.#ui.addInfoMessage('Tip: PageUp/PageDown scroll the chat history');
1524
1690
  this.#ui.addInfoMessage('Tip: shortcodes like :fire: become emoji — Tab autocompletes');
@@ -2603,16 +2769,17 @@ export class ChatController {
2603
2769
  }
2604
2770
 
2605
2771
  case '/plugins': {
2606
- if (!this.#pluginManager || this.#pluginManager.pluginCount === 0) {
2607
- this.#ui.addInfoMessage('No plugins loaded. Place .js files in ~/.ciphermesh/plugins/');
2608
- } else {
2609
- const names = this.#pluginManager.getPluginNames();
2610
- this.#ui.addInfoMessage(`Plugins loaded (${names.length}): ${names.join(', ')}`);
2611
- const cmds = this.#pluginManager.getCommandNames();
2612
- if (cmds.length > 0) {
2613
- this.#ui.addInfoMessage(`Commands: ${cmds.join(', ')}`);
2772
+ pluginsCommand(this.#pluginManager, parts.slice(1)).then((lines) => {
2773
+ for (const { kind, text } of lines) {
2774
+ if (kind === 'error') {
2775
+ this.#ui.addErrorMessage(text);
2776
+ } else if (kind === 'system') {
2777
+ this.#ui.addSystemMessage(text);
2778
+ } else {
2779
+ this.#ui.addInfoMessage(text);
2780
+ }
2614
2781
  }
2615
- }
2782
+ });
2616
2783
  break;
2617
2784
  }
2618
2785
 
@@ -2772,7 +2939,12 @@ export class ChatController {
2772
2939
  this.#nickname = newNick;
2773
2940
  this.#ui.setNickname(newNick);
2774
2941
  this.#connection.send(
2775
- createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
2942
+ createJoin(
2943
+ newNick,
2944
+ this.#keyManager.publicKeyB64,
2945
+ this.#keyManager.pqPublicKeyB64,
2946
+ OWN_CAPABILITIES,
2947
+ ),
2776
2948
  );
2777
2949
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
2778
2950
  break;
@@ -3638,6 +3810,11 @@ export class ChatController {
3638
3810
  this.#historyStore.destroy();
3639
3811
  }
3640
3812
  this.#fileTransfer.destroy();
3813
+ for (const group of this.#groups.values()) {
3814
+ group.destroy();
3815
+ }
3816
+ this.#groups.clear();
3817
+ this.#groupBuffer.clear();
3641
3818
  this.#handshake.destroy();
3642
3819
  this.#keyManager.destroy();
3643
3820
  this.#connection.close();
package/src/client/UI.js CHANGED
@@ -1798,7 +1798,10 @@ export class UI extends EventEmitter {
1798
1798
  this.#lines.push(line);
1799
1799
  this.#chatLog.log(line);
1800
1800
  this.#screen.render();
1801
- this.#lastSender = isSelfNow ? 'self' : nickname;
1801
+ // The sentinel is written as an escape, not typed as a raw byte: a bare
1802
+ // NUL anywhere in the source makes this entire file count as binary, and
1803
+ // a binary file is skipped by grep and shown without a diff on GitHub.
1804
+ this.#lastSender = isSelfNow ? '\u0000self' : nickname;
1802
1805
  if (!isSelfNow) {
1803
1806
  this.#noteIncoming(mentioned || isDM);
1804
1807
  }
@@ -210,7 +210,12 @@ await bootSequence([
210
210
  'XSalsa20-Poly1305 cipher',
211
211
  'Double Ratchet — forward secrecy',
212
212
  'TOFU trust store',
213
- { label: 'Loading plugins', task: () => pluginManager.loadAll() },
213
+ {
214
+ label: 'Loading plugins',
215
+ // Only what the user approved. An unapproved file is left alone —
216
+ // importing it would already be running it.
217
+ task: () => pluginManager.loadAll(undefined, config.pluginsAllowed),
218
+ },
214
219
  {
215
220
  label: 'Connecting to relay',
216
221
  timeoutMs: 3500,