ciphermesh 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,22 @@ 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';
41
+ import {
42
+ buildDeviceGrant,
43
+ buildDeviceRequest,
44
+ parseDeviceGrant,
45
+ parseDeviceRequest,
46
+ } from '../shared/deviceProvisioning.js';
47
+ import {
48
+ DEVICE_LIMITS,
49
+ identityFingerprint,
50
+ isNewerList,
51
+ signDeviceList,
52
+ verifyDeviceList,
53
+ } from '../crypto/DeviceIdentity.js';
39
54
  import { GroupSession } from '../crypto/SenderKey.js';
40
55
  import { KeyManager } from '../crypto/KeyManager.js';
41
56
  import { Handshake } from '../crypto/Handshake.js';
@@ -86,7 +101,9 @@ export class ChatController {
86
101
  #peers; // Map<sessionId, { nickname, publicKey, caps }>
87
102
  #groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
88
103
  #groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
104
+ #distributed = new Map(); // room -> Set<sessionId> holding our current chain
89
105
  #serverCaps = []; // what the relay advertised in join_ack
106
+ #kickedSessions = new Set(); // sessionIds announced kicked, awaiting their peer_left
90
107
  #lastTypingSent;
91
108
  #peerTypingTimers; // Map<sessionId, timeoutId>
92
109
  #fileTransfer;
@@ -142,6 +159,17 @@ export class ChatController {
142
159
  #buffers = new Map(); // room → { unread, mentions, private, owner, secrets, pins }
143
160
  #bufferOrder = []; // Alt+1..9 order
144
161
  #allPeers = new Map(); // sessionId → { nickname, publicKey, rooms: Set } (all my rooms)
162
+ // Device lists, keyed by the identity that signed them. Multi-device step 3
163
+ // (docs/design/multi-device.md): received, verified and kept — and consulted
164
+ // by nothing. Every identity here has exactly one device today, because
165
+ // nothing can yet add a second.
166
+ #deviceLists = new Map(); // identityPk → verified list
167
+ #deviceListSentTo = new Set(); // sessionIds holding our *current* list
168
+ // `nickname:boxPk` pairs we have warned the user about. Kept so that a proof
169
+ // arriving later can close the warning it answers, rather than leaving the
170
+ // user with an unexplained alarm in their scrollback.
171
+ #warnedKeys = new Set();
172
+ #ownList = null; // cached signature; redrawn when the counter moves
145
173
  #pendingRoomSecrets = null; // derived while joining/creating, promoted on join
146
174
 
147
175
  constructor(
@@ -235,6 +263,11 @@ export class ChatController {
235
263
  this.#keyManager.publicKeyB64,
236
264
  this.#keyManager.pqPublicKeyB64,
237
265
  OWN_CAPABILITIES,
266
+ this.#keyManager.identityPublicKeyB64,
267
+ // Carried so the relay can let this session share a nickname another of
268
+ // our own devices already holds. Sent whenever there is one: a client
269
+ // cannot know in advance whether its other device is already online.
270
+ this.#ownDeviceListForDisplay(),
238
271
  ),
239
272
  );
240
273
  }
@@ -542,7 +575,14 @@ export class ChatController {
542
575
  case TrustResult.TRUSTED:
543
576
  break;
544
577
 
578
+ // Another of this peer's devices, signed by the identity bound to their
579
+ // record. Silent: the alarm below is for a key nobody vouched for, and
580
+ // this one has been vouched for by exactly what the user verified.
581
+ case TrustResult.KNOWN_DEVICE:
582
+ break;
583
+
545
584
  case TrustResult.MISMATCH:
585
+ this.#warnedKeys.add(`${nickname.toLowerCase()}:${publicKey}`);
546
586
  this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname });
547
587
  this.#ui.addErrorMessage(
548
588
  `WARNING: ${nickname}'s key changed! Possible MITM attack. Use /trust ${nickname} to accept or /verify ${nickname} to verify.`,
@@ -550,6 +590,7 @@ export class ChatController {
550
590
  break;
551
591
 
552
592
  case TrustResult.VERIFIED_MISMATCH:
593
+ this.#warnedKeys.add(`${nickname.toLowerCase()}:${publicKey}`);
553
594
  this.#auditLog.log(AuditEvent.TRUST_VERIFIED_MISMATCH, { nickname });
554
595
  this.#ui.addErrorMessage(
555
596
  `ALERT: ${nickname}'s VERIFIED key changed! This may indicate an attack. Use /verify ${nickname} to re-verify.`,
@@ -586,6 +627,7 @@ export class ChatController {
586
627
  nickname: peer.nickname,
587
628
  publicKey: peer.publicKey,
588
629
  caps: normalizeCaps(peer.caps),
630
+ identityKey: peer.identityKey ?? null,
589
631
  rooms: new Set([room]),
590
632
  });
591
633
 
@@ -597,7 +639,9 @@ export class ChatController {
597
639
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
598
640
  }
599
641
 
600
- this.#checkTrust(peer.nickname, peer.publicKey);
642
+ if (!this.#isOwnDevice(peer)) {
643
+ this.#checkTrust(peer.nickname, peer.publicKey);
644
+ }
601
645
  }
602
646
 
603
647
  // Initialize ratchets now that we have our session ID
@@ -727,18 +771,24 @@ export class ChatController {
727
771
  nickname: p.nickname,
728
772
  publicKey: p.publicKey,
729
773
  caps: p.caps || [],
774
+ // Carried, not read. Step 3 is what gives it meaning; carrying it now
775
+ // is what lets step 3 be a small change instead of a wide one, and
776
+ // what makes "did the advertisement survive the trip" testable before
777
+ // anything depends on the answer.
778
+ identityKey: p.identityKey ?? null,
730
779
  });
731
780
  }
732
781
  }
733
- this.#ui.setOnlineCount(this.#peers.size + 1);
734
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
782
+ // People, not connections: your own other devices are you.
783
+ const people = this.#otherPeople();
784
+ this.#ui.setOnlineCount(people.length + 1);
785
+ this.#ui.setPeerNames(people.map(([, p]) => p.nickname));
735
786
  }
736
787
 
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.
788
+ // Can every member of the active room speak `cap`? This is the switch the
789
+ // group send path is gated on, together with the relay check below see
790
+ // #canSendToGroup. `own` stays overridable so a test can ask the question as
791
+ // a build that advertises something else.
742
792
  roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
743
793
  return roomSupports(this.#peers.values(), cap, own);
744
794
  }
@@ -833,23 +883,46 @@ export class ChatController {
833
883
  nickname: peer.nickname,
834
884
  publicKey: peer.publicKey,
835
885
  caps: normalizeCaps(peer.caps),
886
+ identityKey: peer.identityKey ?? null,
836
887
  rooms: new Set([room]),
837
888
  });
838
889
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
839
890
  }
840
- this.#checkTrust(peer.nickname, peer.publicKey);
891
+ // Your own other device is not a peer to be trusted on first sight: a
892
+ // record for yourself would sit in the trust store forever, and the verify
893
+ // nudge would be asking you to compare digits with your own phone.
894
+ const ownDevice = this.#isOwnDevice(peer);
895
+ if (!ownDevice) {
896
+ this.#checkTrust(peer.nickname, peer.publicKey);
897
+ }
841
898
  this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
842
899
 
843
900
  if (room === this.#currentRoom) {
844
901
  this.#rebuildActivePeers();
845
902
  this.#ui.handshakeConnect(peer.nickname);
846
- this.#nudgeVerify(peer.nickname);
903
+ if (!ownDevice) {
904
+ this.#nudgeVerify(peer.nickname);
905
+ }
847
906
  } else {
848
907
  this.#ui.toBuffer(room, () => {
849
908
  this.#ui.addSystemMessage(`${peer.nickname} joined #${room}`);
850
909
  });
851
910
  }
852
911
 
912
+ // A newcomer holds no chain of ours, so give them one before anything is
913
+ // sent on it. Ordered ahead of the presence and topic sends below because
914
+ // those may themselves go out on the group path.
915
+ //
916
+ // No rotation here. Someone arriving is not someone gaining access to the
917
+ // past: a serialised chain carries its *current* counter, so what they are
918
+ // handed opens what comes next and nothing before it.
919
+ if (room === this.#currentRoom) {
920
+ this.#distributeSenderKey(room, peer.sessionId);
921
+ }
922
+ if (this.#wantsDeviceList(peer.sessionId)) {
923
+ this.#distributeDeviceList(peer.sessionId);
924
+ }
925
+
853
926
  // A newcomer doesn't know my presence — send only to them
854
927
  if (this.#away || this.#statusText) {
855
928
  this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
@@ -896,11 +969,19 @@ export class ChatController {
896
969
  }
897
970
  const goneEntirely = !entry || !room || entry.rooms.size === 0;
898
971
 
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);
972
+ // Two separate things, and only the second is forward secrecy.
973
+ //
974
+ // Dropping *their* chain stops us holding a key we can no longer be sent
975
+ // anything on. Rotating *ours* is what stops them reading what the room says
976
+ // next — a chain ratchets forward, so the copy they were handed opens every
977
+ // message after it until we draw a new one.
978
+ //
979
+ // Which rooms rotate is decided by which ones actually lost a member, not by
980
+ // which ones we happen to hold a session for: every rotation costs a
981
+ // redistribution to everyone remaining.
982
+ const lostFrom = new Set();
983
+ if (room && this.#groups.get(room)?.removeMember(msg.sessionId)) {
984
+ lostFrom.add(room);
904
985
  }
905
986
 
906
987
  if (goneEntirely) {
@@ -908,15 +989,27 @@ export class ChatController {
908
989
  this.#handshake.removePeer(msg.sessionId);
909
990
  this.#nonceManager.removePeer(msg.sessionId);
910
991
  this.#allPeers.delete(msg.sessionId);
911
- for (const group of this.#groups.values()) {
912
- group.removeMember(msg.sessionId);
992
+ for (const [groupRoom, group] of this.#groups) {
993
+ if (group.removeMember(msg.sessionId)) {
994
+ lostFrom.add(groupRoom);
995
+ }
913
996
  }
914
997
  }
915
998
 
999
+ for (const lost of lostFrom) {
1000
+ this.#rotateGroupFor(lost);
1001
+ }
1002
+
1003
+ // A kick already announced itself. Do every bit of the state work, and say
1004
+ // nothing — "was kicked" followed by "left" describes one event twice.
1005
+ const wasKicked = this.#kickedSessions.delete(msg.sessionId);
1006
+
916
1007
  if (!room || room === this.#currentRoom) {
917
1008
  this.#rebuildActivePeers();
918
- this.#ui.handshakeDisconnect(nickname);
919
- } else {
1009
+ if (!wasKicked) {
1010
+ this.#ui.handshakeDisconnect(nickname);
1011
+ }
1012
+ } else if (!wasKicked) {
920
1013
  this.#ui.toBuffer(room, () => {
921
1014
  this.#ui.addSystemMessage(`${nickname} left #${room}`);
922
1015
  });
@@ -924,13 +1017,21 @@ export class ChatController {
924
1017
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
925
1018
  }
926
1019
 
927
- // ── Sender keys on the relay: the receive half ────────────────
1020
+ // ── Sender keys on the relay ──────────────────────────────────
928
1021
  //
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.
1022
+ // The receive half shipped a release ahead of this one, on purpose: a room
1023
+ // switches to group sending only once every member advertises that it can
1024
+ // read one, so the readers had to be in the field before the writers or the
1025
+ // switch would never have become true for anybody.
1026
+ //
1027
+ // Three things have to hold before a line goes out once instead of N times,
1028
+ // and they fail for different reasons:
1029
+ //
1030
+ // 1. every member of the room advertises `sk1` — an old peer
1031
+ // 2. the relay advertises `sk1` — an old hub
1032
+ // 3. the message is not deniable — see #canSendToGroup
1033
+ //
1034
+ // Any one of them false and the per-peer loop runs, unchanged.
934
1035
 
935
1036
  #getGroup(room) {
936
1037
  let group = this.#groups.get(room);
@@ -951,6 +1052,652 @@ export class ChatController {
951
1052
  }
952
1053
  this.#getGroup(data.room).addMember(fromSessionId, data.dist);
953
1054
  this.#flushGroupBuffer(data.dist.keyId);
1055
+
1056
+ // Answer with ours if they do not have it. This is what makes distribution
1057
+ // reliable without anything having to know who joined in which order:
1058
+ // whoever knows the other first speaks, and the reply cannot race, because
1059
+ // receiving this proves they already hold our public key.
1060
+ if (!this.#hasDistributedTo(data.room, fromSessionId)) {
1061
+ this.#distributeSenderKey(data.room, fromSessionId);
1062
+ }
1063
+ }
1064
+
1065
+ // A full room switch: every buffer is dropped and we exist only in `room`.
1066
+ // The chains follow. Carrying one across would mean a chain drawn for one
1067
+ // room's membership being used against another's, and a keyId that outlives
1068
+ // the set of people it was ever meant to label.
1069
+ #dropAllGroups() {
1070
+ for (const group of this.#groups.values()) {
1071
+ group.destroy();
1072
+ }
1073
+ this.#groups.clear();
1074
+ this.#groupBuffer.clear();
1075
+ this.#distributed.clear();
1076
+ }
1077
+
1078
+ // Hand our sender key for `room` to one peer, or to everyone in it.
1079
+ //
1080
+ // Always pairwise. The envelope is what proves who the key belongs to; a
1081
+ // distribution arriving on the group path would be a chain vouching for
1082
+ // itself, and the relay would be the only thing asserting whose it was.
1083
+ //
1084
+ // Never sent to a peer that may not know us yet. A client drops a ciphertext
1085
+ // from a session it has no public key for, and it learns ours from the
1086
+ // `peer_joined` the relay sends *after* our `join_ack` — so a newcomer
1087
+ // announcing itself into the room on arrival is talking to people who cannot
1088
+ // hear it. That is why nothing distributes on join: the peers who already
1089
+ // know us distribute to us (#onPeerJoined), and we answer (#onSenderKeyDistribution).
1090
+ #distributeSenderKey(room, toPeer = null) {
1091
+ const recipients = (toPeer ? [toPeer] : [...this.#peers.keys()]).filter((id) =>
1092
+ this.#worthDistributingTo(id),
1093
+ );
1094
+ if (recipients.length === 0) {
1095
+ return;
1096
+ }
1097
+
1098
+ // Only now: distribution() is what draws the chain, and a chain is guarded
1099
+ // memory. See #worthDistributingTo.
1100
+ const payload = JSON.stringify({
1101
+ action: 'sk_dist',
1102
+ room,
1103
+ dist: this.#getGroup(room).distribution(),
1104
+ sentAt: Date.now(),
1105
+ });
1106
+ let sent = this.#distributed.get(room);
1107
+ if (!sent) {
1108
+ sent = new Set();
1109
+ this.#distributed.set(room, sent);
1110
+ }
1111
+ // Record before sending, never after.
1112
+ //
1113
+ // Sending re-enters this object. The peer receives the distribution, finds
1114
+ // it holds none of ours, and answers — and its answer can arrive before
1115
+ // #sendPayloadToPeer has returned. Marking afterwards means both sides
1116
+ // consult a record neither has written yet, each answers the other's
1117
+ // answer, and the exchange never converges.
1118
+ //
1119
+ // On a real socket that is a burst of duplicate distributions rather than a
1120
+ // hang, which is why it is worth stating: the bug is re-entrancy, and the
1121
+ // synchronous case is only the one that makes it obvious.
1122
+ for (const peerId of recipients) {
1123
+ sent.add(peerId);
1124
+ }
1125
+ for (const peerId of recipients) {
1126
+ this.#sendPayloadToPeer(peerId, payload);
1127
+ }
1128
+ }
1129
+
1130
+ // A sender key is only ever useful to a peer that can read a group message,
1131
+ // on a hub that can fan one out. Handing one to anybody else is a wasted
1132
+ // round trip — and, less obviously, a wasted allocation.
1133
+ //
1134
+ // Chains live in sodium_malloc'd memory, which is mlock'd. Linux caps how much
1135
+ // a process may lock (RLIMIT_MEMLOCK), and the cap is small; drawing a chain
1136
+ // per room per peer regardless of whether it could ever be used exhausted it,
1137
+ // and sodium_malloc then returns NULL. That surfaced as a SIGABRT in an
1138
+ // unrelated ratchet call — the first allocation to fail, not the one at fault.
1139
+ #worthDistributingTo(peerId) {
1140
+ if (!this.relaySupportsCapability(CAP.SENDER_KEYS)) {
1141
+ return false;
1142
+ }
1143
+ const peer = this.#peers.get(peerId);
1144
+ return peer ? peerSupports(peer, CAP.SENDER_KEYS) : false;
1145
+ }
1146
+
1147
+ // Has this peer been given our *current* chain for this room? Reset by
1148
+ // rotate(), because after one the answer is no for everybody.
1149
+ #hasDistributedTo(room, peerId) {
1150
+ return this.#distributed.get(room)?.has(peerId) ?? false;
1151
+ }
1152
+
1153
+ // Someone is no longer in `room`: draw a new chain and hand it to whoever is
1154
+ // left.
1155
+ //
1156
+ // This is the forward secrecy the design promises, and the reason the relay
1157
+ // now reports a kick as a departure (#482). Without it a removed member keeps
1158
+ // the chain they were given, and a chain ratchets *forward* — holding it at
1159
+ // counter N opens every counter after N. Being removed from a room would stop
1160
+ // the relay delivering to them and would not stop them reading.
1161
+ //
1162
+ // rotate() has no failure to check and no value to return. The distribution
1163
+ // that follows is the whole point, so the two must not drift apart: a rotation
1164
+ // whose redistribution never happens is a room that quietly stopped being able
1165
+ // to read this client, with nothing raised anywhere.
1166
+ #rotateGroupFor(room) {
1167
+ const group = this.#groups.get(room);
1168
+ if (!group) {
1169
+ return;
1170
+ }
1171
+ group.rotate();
1172
+ this.#distributed.delete(room); // a new chain: nobody has it
1173
+ if (room === this.#currentRoom) {
1174
+ this.#distributeSenderKey(room);
1175
+ }
1176
+ }
1177
+
1178
+ // ── Device lists ────────────────────────────────────────────────
1179
+ //
1180
+ // Step 3 of multi-device. A device list says "these are the keys that are me",
1181
+ // signed by the identity key advertised in JOIN. Today every list has exactly
1182
+ // one device in it, because nothing can add a second yet — the point of
1183
+ // landing it now is that the distribution, the verification and the replay
1184
+ // rule are all exercised before they carry weight.
1185
+ //
1186
+ // Nothing reads a stored list. That is deliberate and it is the last step at
1187
+ // which it is true: step 4 moves verification onto these keys.
1188
+
1189
+ // Our own list, signed once per counter. The counter moves when the
1190
+ // descriptor does — see KeyManager.rotate — so caching on it cannot serve a
1191
+ // signature for a device that has since changed its key.
1192
+ #ownDeviceList() {
1193
+ // A secondary holds no identity secret, so the only list it can publish is
1194
+ // the one it was granted. It is signed by the same identity and says the
1195
+ // same thing; it simply cannot be updated from here.
1196
+ if (!this.#keyManager.isPrimaryDevice) {
1197
+ return this.#keyManager.grantedList;
1198
+ }
1199
+ const counter = this.#keyManager.listCounter;
1200
+ if (!this.#ownList || this.#ownList.counter !== counter) {
1201
+ this.#ownList = signDeviceList(this.#keyManager.identity, counter, [
1202
+ this.#keyManager.deviceDescriptor(),
1203
+ ]);
1204
+ // A new list is a list nobody has.
1205
+ this.#deviceListSentTo.clear();
1206
+ }
1207
+ return this.#ownList;
1208
+ }
1209
+
1210
+ // Worth handing one to this peer? Same reasoning as #worthDistributingTo for
1211
+ // sender keys: a peer that cannot read it gains nothing and costs a round
1212
+ // trip. The relay is not consulted — a device list travels on the pairwise
1213
+ // channel the relay already carries, so there is nothing for it to agree to.
1214
+ #wantsDeviceList(peerId) {
1215
+ const peer = this.#peers.get(peerId);
1216
+ return peer ? peerSupports(peer, CAP.DEVICE_LIST) : false;
1217
+ }
1218
+
1219
+ // Hand our list to one peer, or to everyone who can take one.
1220
+ //
1221
+ // Never on join, for the reason sender keys are not: a newcomer learns the
1222
+ // room from its join_ack before the room learns of the newcomer, so a client
1223
+ // announcing itself on arrival is talking to peers who hold no key for it and
1224
+ // will drop the ciphertext. The peers who already know us speak first, and we
1225
+ // answer.
1226
+ #distributeDeviceList(toPeer = null) {
1227
+ const recipients = (toPeer ? [toPeer] : [...this.#peers.keys()]).filter((id) =>
1228
+ this.#wantsDeviceList(id),
1229
+ );
1230
+ if (recipients.length === 0) {
1231
+ return;
1232
+ }
1233
+ const list = this.#ownDeviceList();
1234
+ if (!list) {
1235
+ return;
1236
+ }
1237
+ const payload = JSON.stringify({ action: 'device_list', list, sentAt: Date.now() });
1238
+ // Record before sending, never after — the same re-entrancy that bit sender
1239
+ // key distribution. Sending re-enters this object: the peer receives our
1240
+ // list, finds it holds none of ours, and answers, and its answer can arrive
1241
+ // before #sendPayloadToPeer has returned. Marked afterwards, both sides
1242
+ // consult a record neither has written yet and each answers the other's
1243
+ // answer.
1244
+ for (const peerId of recipients) {
1245
+ this.#deviceListSentTo.add(peerId);
1246
+ this.#sendPayloadToPeer(peerId, payload);
1247
+ }
1248
+ }
1249
+
1250
+ // A peer handed us theirs.
1251
+ //
1252
+ // What authenticates it is not the seal — crypto_box_seal is anonymous, and
1253
+ // anyone can seal a blob to us claiming any sender. It is the layer under it:
1254
+ // this payload only decrypted because it was encrypted to us *by the holder
1255
+ // of this peer's box secret key*. So the pairwise channel is the authority on
1256
+ // whose list this is, and the identityKey the relay repeated in JOIN is only
1257
+ // ever a hint about whether distributing is worth the round trip. A relay that
1258
+ // tampers with that hint can stop us bothering; it cannot put words in a
1259
+ // peer's mouth, because it cannot produce this payload.
1260
+ #onDeviceList(fromSessionId, data) {
1261
+ const list = verifyDeviceList(data?.list);
1262
+ if (!list) {
1263
+ return;
1264
+ }
1265
+
1266
+ // Highest counter wins, enforced here rather than trusted from the sender:
1267
+ // a relay that kept a copy of an older list could otherwise replay it, and
1268
+ // once revocation exists that would put a removed device back.
1269
+ const held = this.#deviceLists.get(list.identityPk);
1270
+ if (isNewerList(list, held)) {
1271
+ this.#deviceLists.set(list.identityPk, list);
1272
+ this.#bindPeerIdentity(fromSessionId, list);
1273
+ }
1274
+
1275
+ // Answer with ours if they do not have it. Whoever knows the other first
1276
+ // speaks; the reply cannot race, because receiving this proves they already
1277
+ // hold our public key.
1278
+ if (!this.#deviceListSentTo.has(fromSessionId)) {
1279
+ this.#distributeDeviceList(fromSessionId);
1280
+ }
1281
+ }
1282
+
1283
+ /** The list held for an identity, or null. */
1284
+ deviceListFor(identityPk) {
1285
+ return this.#deviceLists.get(identityPk) ?? null;
1286
+ }
1287
+
1288
+ // Attach an identity to the trust record the user already has — step 4.
1289
+ //
1290
+ // Only when the binding is provable, which is a narrower condition than it
1291
+ // looks: the list has to *name the box key this peer is using*, and the list
1292
+ // only reached us because the holder of that box key encrypted it to us. So
1293
+ // the identity is vouched for by exactly the key the user compared digits
1294
+ // over. Anything else and we leave the record alone.
1295
+ //
1296
+ // This is the migration the design doc worried about, and it is silent on
1297
+ // purpose. Every already-verified record was verified against a box key; the
1298
+ // one thing that must not happen is thousands of clients simultaneously
1299
+ // telling their users that everyone they trust has been replaced.
1300
+ #bindPeerIdentity(fromSessionId, list) {
1301
+ const peer = this.#allPeers.get(fromSessionId);
1302
+ if (!peer) {
1303
+ return;
1304
+ }
1305
+ if (!list.devices.some((device) => device.boxPk === peer.publicKey)) {
1306
+ // A valid list that does not mention the key it arrived under. Not
1307
+ // necessarily an attack — a rotation can race a distribution — but it
1308
+ // proves nothing, so it binds nothing.
1309
+ return;
1310
+ }
1311
+
1312
+ const result = this.#trustStore.bindIdentity(peer.nickname, list.identityPk);
1313
+
1314
+ if (result === 'bound' || result === 'unchanged') {
1315
+ this.#recordPeerDevices(peer, list);
1316
+ return;
1317
+ }
1318
+
1319
+ // A second identity for a record that already had one. On an unverified
1320
+ // record this is worth a line; on a verified one it is the same class of
1321
+ // event as VERIFIED_MISMATCH and is said in the same voice.
1322
+ this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname: peer.nickname });
1323
+ if (this.#trustStore.isVerified(peer.nickname)) {
1324
+ this.#ui.addErrorMessage(
1325
+ `${peer.nickname} is presenting a different identity key than the one you verified. ` +
1326
+ 'Nothing has been changed. Verify again out of band before trusting this session.',
1327
+ );
1328
+ } else {
1329
+ this.#ui.addSystemMessage(
1330
+ `${peer.nickname} is presenting a different identity key than before.`,
1331
+ );
1332
+ }
1333
+ }
1334
+
1335
+ // Write the devices an identity has signed for onto the peer's trust record,
1336
+ // so the next time one of them shows up it is recognised instead of reported.
1337
+ //
1338
+ // A second device is otherwise indistinguishable from an attack: it arrives
1339
+ // under the same nickname with a box key the record has never seen, which is
1340
+ // precisely what checkPeer is built to shout about. It *should* shout — until
1341
+ // something proves otherwise, a new key under a known name is the shape of a
1342
+ // MITM. So the alarm is never suppressed in advance. It is answered, once the
1343
+ // proof exists, and the answer is said out loud rather than swallowed: the
1344
+ // user saw a warning and is owed the resolution.
1345
+ #recordPeerDevices(peer, list) {
1346
+ const { added, removed } = this.#trustStore.syncDevices(
1347
+ peer.nickname,
1348
+ list.identityPk,
1349
+ list.devices.map((device) => device.boxPk),
1350
+ );
1351
+
1352
+ // A device was revoked. It holds a sender chain, and a chain ratchets
1353
+ // forward — so being dropped from a list stops the relay delivering to it
1354
+ // and does not stop it reading. Rotation is what closes that, exactly as it
1355
+ // does for a member who leaves (#482).
1356
+ if (removed.length > 0) {
1357
+ this.#ui.addSystemMessage(
1358
+ `${peer.nickname} removed ${removed.length === 1 ? 'a device' : `${removed.length} devices`}. ` +
1359
+ 'Rotating this room, so nothing said from here reaches it.',
1360
+ );
1361
+ this.#rotateGroupFor(this.#currentRoom);
1362
+ }
1363
+
1364
+ // Only speak about the keys the user was actually warned about. A list that
1365
+ // simply happens to mention devices nobody has met is not news.
1366
+ const nick = peer.nickname.toLowerCase();
1367
+ const answered = added.filter((key) => this.#warnedKeys.has(`${nick}:${key}`));
1368
+ if (answered.length === 0) {
1369
+ return;
1370
+ }
1371
+ for (const key of answered) {
1372
+ this.#warnedKeys.delete(`${nick}:${key}`);
1373
+ }
1374
+ this.#ui.addSystemMessage(
1375
+ `The key you were warned about for ${peer.nickname} is another of their devices — ` +
1376
+ `signed by the identity you already ${
1377
+ this.#trustStore.isVerified(peer.nickname) ? 'verified' : 'know'
1378
+ }, so it is not a key that changed.`,
1379
+ );
1380
+ }
1381
+
1382
+ /** Whatever list this device would publish, primary or secondary. */
1383
+ #ownDeviceListForDisplay() {
1384
+ return this.#keyManager.isPrimaryDevice ? this.#ownDeviceList() : this.#keyManager.grantedList;
1385
+ }
1386
+
1387
+ // Sign a new list that includes the asking device, and hand back a grant.
1388
+ //
1389
+ // The counter moves because the list changed — that is what makes every peer
1390
+ // take the new one instead of keeping a list the new device is missing from.
1391
+ #grantDevice(request) {
1392
+ const current = this.#ownDeviceList();
1393
+ const devices = current ? [...current.devices] : [this.#keyManager.deviceDescriptor()];
1394
+
1395
+ if (devices.some((device) => device.deviceId === request.deviceId)) {
1396
+ this.#ui.addErrorMessage('That device is already on the list.');
1397
+ return null;
1398
+ }
1399
+ if (devices.some((device) => device.boxPk === request.boxPk)) {
1400
+ // One key under two device ids makes "which device is this" unanswerable
1401
+ // for every reader, and verifyDeviceList refuses such a list outright.
1402
+ this.#ui.addErrorMessage('That key is already on the list under another device.');
1403
+ return null;
1404
+ }
1405
+ if (devices.length >= DEVICE_LIMITS.MAX_DEVICES) {
1406
+ this.#ui.addErrorMessage(`A list holds at most ${DEVICE_LIMITS.MAX_DEVICES} devices.`);
1407
+ return null;
1408
+ }
1409
+
1410
+ devices.push({
1411
+ deviceId: request.deviceId,
1412
+ boxPk: request.boxPk,
1413
+ label: request.label,
1414
+ createdAt: Date.now(),
1415
+ });
1416
+
1417
+ this.#keyManager.bumpListCounter();
1418
+ const list = signDeviceList(this.#keyManager.identity, this.#keyManager.listCounter, devices);
1419
+ // A new list is a list nobody has; #ownDeviceList caches on the counter, so
1420
+ // resetting here is what makes the next distribution carry this one.
1421
+ this.#ownList = list;
1422
+ this.#deviceListSentTo.clear();
1423
+ this.#distributeDeviceList();
1424
+
1425
+ this.#auditLog.log(AuditEvent.KEY_ROTATION_OWN, { fingerprint: request.deviceId.slice(0, 8) });
1426
+ return buildDeviceGrant({ identityPk: list.identityPk, list });
1427
+ }
1428
+
1429
+ // Sign a list without a device, and rotate so it cannot read what comes next.
1430
+ //
1431
+ // The list alone is only half of revocation. A removed device still holds
1432
+ // every member's sender chain, and a chain ratchets forward — dropping it
1433
+ // from the list stops the relay delivering to it and does not stop it
1434
+ // reading. Rotating is what closes that, and it is the same reasoning that
1435
+ // made #482 rotate on a kick.
1436
+ #revokeDevice(prefix) {
1437
+ const current = this.#ownDeviceList();
1438
+ if (!prefix || !current) {
1439
+ this.#ui.addErrorMessage('Usage: /device remove <id> (the first 8 characters are enough)');
1440
+ return;
1441
+ }
1442
+
1443
+ const matches = current.devices.filter((device) => device.deviceId.startsWith(prefix));
1444
+ if (matches.length === 0) {
1445
+ this.#ui.addErrorMessage(`No device on the list starts with "${prefix}".`);
1446
+ return;
1447
+ }
1448
+ if (matches.length > 1) {
1449
+ this.#ui.addErrorMessage(
1450
+ `"${prefix}" matches ${matches.length} devices. Use more of the id.`,
1451
+ );
1452
+ return;
1453
+ }
1454
+ if (matches[0].deviceId === this.#keyManager.deviceId) {
1455
+ // Removing the device that holds the identity secret would leave a list
1456
+ // signed by a key no listed device holds — an identity that can still
1457
+ // sign but belongs to nobody in it.
1458
+ this.#ui.addErrorMessage('This device holds the identity key and cannot remove itself.');
1459
+ return;
1460
+ }
1461
+
1462
+ const remaining = current.devices.filter((device) => device.deviceId !== matches[0].deviceId);
1463
+ this.#keyManager.bumpListCounter();
1464
+ this.#ownList = signDeviceList(
1465
+ this.#keyManager.identity,
1466
+ this.#keyManager.listCounter,
1467
+ remaining,
1468
+ );
1469
+ this.#deviceListSentTo.clear();
1470
+ this.#distributeDeviceList();
1471
+ this.#rotateGroupFor(this.#currentRoom);
1472
+
1473
+ this.#auditLog.log(AuditEvent.KEY_ROTATION_OWN, {
1474
+ fingerprint: matches[0].deviceId.slice(0, 8),
1475
+ });
1476
+ this.#ui.addSystemMessage(
1477
+ `Removed device ${matches[0].deviceId.slice(0, 8)}. The room has been rotated, so nothing ` +
1478
+ 'said from now on reaches it. It keeps whatever it already received.',
1479
+ );
1480
+ }
1481
+
1482
+ // Adopt an identity this device does not hold the secret for.
1483
+ //
1484
+ // Three checks, and all three matter. The list has to verify under the
1485
+ // identity it claims, or a grant is just a JSON blob. It has to name *this*
1486
+ // device by both id and key, or a grant intended for somebody else — or
1487
+ // tampered with in transit — would be accepted. And this device must not
1488
+ // already be a secondary of something else, because there is no sensible
1489
+ // meaning for two.
1490
+ #acceptDeviceGrant(text) {
1491
+ const grant = parseDeviceGrant(text);
1492
+ if (!grant) {
1493
+ this.#ui.addErrorMessage('Usage: /device accept ciphermesh-device://grant/…');
1494
+ return;
1495
+ }
1496
+ if (!this.#keyManager.isPrimaryDevice) {
1497
+ this.#ui.addErrorMessage('This device already belongs to an identity.');
1498
+ return;
1499
+ }
1500
+
1501
+ const list = verifyDeviceList(grant.list);
1502
+ if (!list || list.identityPk !== grant.identityPk) {
1503
+ this.#ui.addErrorMessage('That grant is not signed by the identity it names.');
1504
+ return;
1505
+ }
1506
+
1507
+ const me = list.devices.find(
1508
+ (device) =>
1509
+ device.deviceId === this.#keyManager.deviceId &&
1510
+ device.boxPk === this.#keyManager.publicKeyB64,
1511
+ );
1512
+ if (!me) {
1513
+ this.#ui.addErrorMessage(
1514
+ 'That grant is for a different device. Run /device request here and use that.',
1515
+ );
1516
+ return;
1517
+ }
1518
+
1519
+ this.#keyManager.adoptIdentity(grant.identityPk, list);
1520
+ this.#ownList = null;
1521
+ this.#deviceListSentTo.clear();
1522
+ this.#ui.addSystemMessage(
1523
+ `This device now belongs to identity ${this.#keyManager.identityFingerprint}. ` +
1524
+ 'It cannot add or remove devices — only the device holding the identity key can. ' +
1525
+ 'Reconnect for peers to see it.',
1526
+ );
1527
+ }
1528
+
1529
+ /**
1530
+ * Is this peer one of *our* devices?
1531
+ *
1532
+ * Proven, never asserted. The obvious test — does the peer's `identityKey`
1533
+ * match ours — would be worse than useless: the relay forwards that field
1534
+ * unchecked, so a hostile one could label a stranger as your own device and
1535
+ * every protection below would be turned off for them. Their messages would
1536
+ * be attributed to you.
1537
+ *
1538
+ * So the answer comes from a list we hold the signature of: our own. A device
1539
+ * is ours if our list names its box key, which only the holder of the
1540
+ * identity secret could have arranged.
1541
+ */
1542
+ #isOwnDevice(peer) {
1543
+ const own = this.#ownDeviceListForDisplay();
1544
+ if (!own || !peer?.publicKey) {
1545
+ return false;
1546
+ }
1547
+ return own.devices.some(
1548
+ (device) => device.boxPk === peer.publicKey && device.boxPk !== this.#keyManager.publicKeyB64,
1549
+ );
1550
+ }
1551
+
1552
+ /** The peers in this room that are other people, rather than other devices. */
1553
+ #otherPeople() {
1554
+ return [...this.#peers.entries()].filter(([, peer]) => !this.#isOwnDevice(peer));
1555
+ }
1556
+
1557
+ // Can this pair compare identity keys instead of box keys?
1558
+ //
1559
+ // Both sides have to answer the same way or two people doing everything right
1560
+ // are shown two different codes and conclude they are under attack. So the
1561
+ // condition is symmetric by construction: each of us holds the other's list,
1562
+ // which is exactly the state the exchange leaves both in.
1563
+ #identitySasReady(peerId) {
1564
+ const peer = this.#peers.get(peerId);
1565
+ if (!peer?.identityKey || !peerSupports(peer, CAP.DEVICE_LIST)) {
1566
+ return false;
1567
+ }
1568
+ const bound = this.#trustStore.identityFor(peer.nickname);
1569
+ return Boolean(bound) && this.#deviceListSentTo.has(peerId);
1570
+ }
1571
+
1572
+ /**
1573
+ * The code to compare for a peer, and which keys it is over.
1574
+ *
1575
+ * Exposed rather than inlined into the command so a test can ask the question
1576
+ * without going through the UI.
1577
+ */
1578
+ sasFor(peerId) {
1579
+ const peer = this.#peers.get(peerId);
1580
+ if (!peer) {
1581
+ return null;
1582
+ }
1583
+ if (this.#identitySasReady(peerId)) {
1584
+ return {
1585
+ over: 'identity',
1586
+ code: TrustStore.computeIdentitySAS(
1587
+ this.#keyManager.identityPublicKeyB64,
1588
+ this.#trustStore.identityFor(peer.nickname),
1589
+ ),
1590
+ };
1591
+ }
1592
+ return {
1593
+ over: 'device',
1594
+ code: TrustStore.computeSAS(this.#keyManager.publicKeyB64, peer.publicKey),
1595
+ };
1596
+ }
1597
+
1598
+ // Can this payload go out once, addressed to the room, instead of N times?
1599
+ #canSendToGroup(deniable) {
1600
+ // Deniability is a property of the pairwise construction — a symmetric key
1601
+ // both sides could have derived, so neither can prove the other wrote it. A
1602
+ // group packet is signed by exactly one sender for exactly that reason, so
1603
+ // sending a deniable message on it would publish the opposite of what was
1604
+ // asked for.
1605
+ if (deniable) {
1606
+ return false;
1607
+ }
1608
+ if (this.#peers.size === 0) {
1609
+ return false;
1610
+ }
1611
+ return (
1612
+ this.roomSupportsCapability(CAP.SENDER_KEYS) && this.relaySupportsCapability(CAP.SENDER_KEYS)
1613
+ );
1614
+ }
1615
+
1616
+ // Which path the next line you type will take out of this room, and — when it
1617
+ // is the expensive one — what is holding it there.
1618
+ //
1619
+ // The fallback is invisible today. A room quietly sends N envelopes per line
1620
+ // instead of one and nobody can tell whether that is one peer on an older
1621
+ // build, an older hub, or deniable mode left on an hour ago. That is the same
1622
+ // shape as the three bugs this feature already shipped with: nothing errors,
1623
+ // nothing is logged, the room is just paying fifty times over and no one
1624
+ // knows.
1625
+ //
1626
+ // #481 asks whether the per-peer loop can be retired. It cannot — see the
1627
+ // decision recorded in docs/design/sender-keys-on-relay.md — so the useful
1628
+ // thing is being able to see when it runs and why, which is also what turns
1629
+ // "consider retiring it" into a question answerable with data.
1630
+ //
1631
+ // The order matches #canSendToGroup so the two cannot disagree, with one
1632
+ // deliberate exception: an older hub is reported before older peers. Both can
1633
+ // be true at once, and naming peers who are perfectly current would send the
1634
+ // reader after the wrong problem.
1635
+ groupSendStatus() {
1636
+ if (this.#deniableMode) {
1637
+ return { group: false, reason: 'deniable', blockers: [] };
1638
+ }
1639
+ if (this.#peers.size === 0) {
1640
+ return { group: false, reason: 'alone', blockers: [] };
1641
+ }
1642
+ if (!this.relaySupportsCapability(CAP.SENDER_KEYS)) {
1643
+ return { group: false, reason: 'relay', blockers: [] };
1644
+ }
1645
+ const blockers = [...this.#peers.values()]
1646
+ .filter((peer) => !peerSupports(peer, CAP.SENDER_KEYS))
1647
+ .map((peer) => peer.nickname);
1648
+ if (blockers.length > 0) {
1649
+ return { group: false, reason: 'peers', blockers };
1650
+ }
1651
+ // roomSupports() also requires *this* build to advertise the capability,
1652
+ // which is the one remaining way to land here with nobody to name.
1653
+ if (!this.roomSupportsCapability(CAP.SENDER_KEYS)) {
1654
+ return { group: false, reason: 'self', blockers: [] };
1655
+ }
1656
+ return { group: true, reason: null, blockers: [] };
1657
+ }
1658
+
1659
+ // Plain language, because the number is the whole point: a line costs one
1660
+ // encryption and one frame, or it costs one of each per person in the room.
1661
+ #describeSendPath() {
1662
+ const status = this.groupSendStatus();
1663
+ const size = this.#peers.size;
1664
+
1665
+ if (status.group) {
1666
+ return `one ciphertext to the room (sender keys), read by ${size}`;
1667
+ }
1668
+
1669
+ const cost = `${size} envelope${size === 1 ? '' : 's'} per message`;
1670
+ switch (status.reason) {
1671
+ case 'alone':
1672
+ return 'nothing yet — no one else is here';
1673
+ case 'deniable':
1674
+ return `${cost} — deniable mode is on, and deniability is pairwise`;
1675
+ case 'relay':
1676
+ return `${cost} — this relay cannot fan out a room-addressed message`;
1677
+ case 'peers':
1678
+ return `${cost} — ${status.blockers.join(', ')} ${
1679
+ status.blockers.length === 1 ? 'is' : 'are'
1680
+ } on a build without sender keys`;
1681
+ default:
1682
+ return `${cost} — this build is not advertising sender keys`;
1683
+ }
1684
+ }
1685
+
1686
+ // One encryption, one frame, the whole room. The payload arrives already
1687
+ // room-tagged and already through the private-room layer when there is one —
1688
+ // both happen before the path splits, so a group message and a pairwise one
1689
+ // carry exactly the same bytes inside.
1690
+ #sendRoomGroup(room, payload) {
1691
+ // Nobody can read a packet on a chain they were never given. The exchange
1692
+ // above covers every ordinary path; this covers the rest, and costs one
1693
+ // Set lookup per member when there is nothing to do.
1694
+ for (const [peerId] of this.#peers) {
1695
+ if (!this.#hasDistributedTo(room, peerId)) {
1696
+ this.#distributeSenderKey(room, peerId);
1697
+ }
1698
+ }
1699
+ const packet = this.#getGroup(room).encrypt(payload);
1700
+ this.#connection.send(createGroupMessage(room, packet));
954
1701
  }
955
1702
 
956
1703
  #onGroupMessage(msg) {
@@ -1175,6 +1922,13 @@ export class ChatController {
1175
1922
  return;
1176
1923
  }
1177
1924
 
1925
+ // A device list. Pairwise for the same reason a sender key is: the
1926
+ // channel is what says whose it is.
1927
+ if (data.action === 'device_list') {
1928
+ this.#onDeviceList(msg.from, data);
1929
+ return;
1930
+ }
1931
+
1178
1932
  // Which buffer this belongs to (the tag rides inside the E2EE envelope).
1179
1933
  const msgRoom = this.#roomForIncoming(data, msg.from);
1180
1934
  const roomActive = msgRoom === this.#currentRoom;
@@ -1478,9 +2232,18 @@ export class ChatController {
1478
2232
  });
1479
2233
  }
1480
2234
 
1481
- const watchHit = this.#matchedWatch(data.text);
1482
- // A watched keyword deserves the same attention a mention gets.
1483
- const mentioned = (this.#mentionsMe(data.text) || !!watchHit) && !data.isDM;
2235
+ // A line you sent from another device is yours. Attributing it to the
2236
+ // nickname would be technically true and would read as somebody else
2237
+ // talking; marking the device is what makes the transcript match what
2238
+ // happened.
2239
+ const fromOwnDevice = this.#isOwnDevice(peer);
2240
+
2241
+ const watchHit = fromOwnDevice ? null : this.#matchedWatch(data.text);
2242
+ // A watched keyword deserves the same attention a mention gets — but not
2243
+ // when you are the one who typed it. Your own nickname in your own line
2244
+ // is not somebody calling you, and a notification for it would train you
2245
+ // to ignore the ones that matter.
2246
+ const mentioned = (this.#mentionsMe(data.text) || !!watchHit) && !data.isDM && !fromOwnDevice;
1484
2247
  if (watchHit) {
1485
2248
  this.#ui.toBuffer(msgRoom, () => {
1486
2249
  this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${peer.nickname} in #${msgRoom}`);
@@ -1509,7 +2272,10 @@ export class ChatController {
1509
2272
  );
1510
2273
  }
1511
2274
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
1512
- const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
2275
+ const displayName = fromOwnDevice ? `${peer.nickname} (your other device)` : peer.nickname;
2276
+ const trust = fromOwnDevice
2277
+ ? null
2278
+ : trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
1513
2279
  // File the message into its buffer (live log when active, stored otherwise).
1514
2280
  let lineIndex = -1;
1515
2281
  let renderInfo = null;
@@ -1518,9 +2284,9 @@ export class ChatController {
1518
2284
  this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
1519
2285
  }
1520
2286
  ({ lineIndex, render: renderInfo } = data.isAction
1521
- ? this.#ui.addActionMessage(peer.nickname, data.text)
2287
+ ? this.#ui.addActionMessage(displayName, data.text)
1522
2288
  : this.#ui.addMessage(
1523
- peer.nickname,
2289
+ displayName,
1524
2290
  data.text,
1525
2291
  !!data.isDM,
1526
2292
  ephLabel,
@@ -1636,8 +2402,11 @@ export class ChatController {
1636
2402
  this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
1637
2403
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
1638
2404
  this.#ui.addInfoMessage(' /rooms - List available rooms');
1639
- this.#ui.addInfoMessage(' /room - Show the current room');
2405
+ this.#ui.addInfoMessage(
2406
+ ' /room - Current room, how it is sending, and your buffers',
2407
+ );
1640
2408
  this.#ui.addInfoMessage(' /fingerprint - Show your fingerprint');
2409
+ this.#ui.addInfoMessage(' /device [sub] - Your devices under one identity');
1641
2410
  this.#ui.addInfoMessage(" /fingerprint <nick> - Another user's fingerprint");
1642
2411
  this.#ui.addInfoMessage(' /verify <nick> - Show SAS code for verification');
1643
2412
  this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm peer verification');
@@ -1691,7 +2460,8 @@ export class ChatController {
1691
2460
  break;
1692
2461
 
1693
2462
  case '/users': {
1694
- const names = [...this.#peers.values()].map((p) => {
2463
+ const ownDevices = [...this.#peers.values()].filter((p) => this.#isOwnDevice(p)).length;
2464
+ const names = this.#otherPeople().map(([, p]) => {
1695
2465
  let label = p.nickname;
1696
2466
  const alias = this.#trustStore.getAlias(p.nickname);
1697
2467
  if (alias) {
@@ -1705,7 +2475,7 @@ export class ChatController {
1705
2475
  }
1706
2476
  return label;
1707
2477
  });
1708
- let me = `${this.#nickname} (you)`;
2478
+ let me = `${this.#nickname} (you${ownDevices > 0 ? `, on ${ownDevices + 1} devices` : ''})`;
1709
2479
  if (this.#away) {
1710
2480
  me += ` [away${this.#awayReason ? `: ${this.#awayReason}` : ''}]`;
1711
2481
  }
@@ -1722,6 +2492,10 @@ export class ChatController {
1722
2492
  const targetNick = parts[1];
1723
2493
  if (!targetNick) {
1724
2494
  this.#ui.addInfoMessage(`Your fingerprint: ${this.#keyManager.fingerprint}`);
2495
+ // Shown alongside, not instead. The device fingerprint is what every
2496
+ // existing verification was made against, and it stays the thing on
2497
+ // screen until there is nothing left comparing it.
2498
+ this.#ui.addInfoMessage(`Your identity: ${this.#keyManager.identityFingerprint}`);
1725
2499
  this.#ui.addPlainLines(
1726
2500
  keyArt(Buffer.from(this.#keyManager.publicKeyB64, 'base64'), this.#nickname).split(
1727
2501
  '\n',
@@ -1734,6 +2508,12 @@ export class ChatController {
1734
2508
  if (found) {
1735
2509
  const fp = KeyManager.computeFingerprint(Buffer.from(found.publicKey, 'base64'));
1736
2510
  this.#ui.addInfoMessage(`${found.nickname}'s fingerprint: ${fp}`);
2511
+ const boundIdentity = this.#trustStore.identityFor(found.nickname);
2512
+ if (boundIdentity) {
2513
+ this.#ui.addInfoMessage(
2514
+ `${found.nickname}'s identity: ${identityFingerprint(boundIdentity)}`,
2515
+ );
2516
+ }
1737
2517
  this.#ui.addPlainLines(
1738
2518
  keyArt(Buffer.from(found.publicKey, 'base64'), found.nickname).split('\n'),
1739
2519
  );
@@ -1777,9 +2557,21 @@ export class ChatController {
1777
2557
  this.#ui.addErrorMessage(`User "${verifyNick}" not found`);
1778
2558
  break;
1779
2559
  }
1780
- const sas = TrustStore.computeSAS(this.#keyManager.publicKeyB64, verifyPeer.publicKey);
2560
+ const verifySid = [...this.#peers.entries()].find(
2561
+ ([, p]) => p.nickname === verifyPeer.nickname,
2562
+ )?.[0];
2563
+ const { over, code: sas } = this.sasFor(verifySid);
1781
2564
  this.#auditLog.log(AuditEvent.SAS_VERIFY, { nickname: verifyPeer.nickname });
1782
2565
  this.#ui.addInfoMessage(`SAS code for ${verifyPeer.nickname}: ${sas}`);
2566
+ // Say which keys the code is over. Both of you compute it the same way —
2567
+ // the switch is symmetric — but a code that silently changed meaning
2568
+ // between two releases is the one thing that would make comparing them
2569
+ // worthless.
2570
+ this.#ui.addInfoMessage(
2571
+ over === 'identity'
2572
+ ? ' over both identity keys — survives a device key rotation'
2573
+ : ' over both device keys — this peer has no identity key yet',
2574
+ );
1783
2575
  this.#ui.addPlainLines(
1784
2576
  keyArt(Buffer.from(verifyPeer.publicKey, 'base64'), verifyPeer.nickname).split('\n'),
1785
2577
  );
@@ -2002,6 +2794,7 @@ export class ChatController {
2002
2794
  this.#ui.addInfoMessage(
2003
2795
  `Current room: #${this.#currentRoom}${this.#activeSecrets ? ' 🔒 (private)' : ''}`,
2004
2796
  );
2797
+ this.#ui.addInfoMessage(`Sending: ${this.#describeSendPath()}`);
2005
2798
  if (this.#bufferOrder.length > 1) {
2006
2799
  const list = this.#bufferOrder
2007
2800
  .map((r, i) => {
@@ -2016,6 +2809,98 @@ export class ChatController {
2016
2809
  break;
2017
2810
  }
2018
2811
 
2812
+ // ── Devices ──────────────────────────────────────────────
2813
+ //
2814
+ // Three hops, and it cannot be fewer: a new device has to say what its
2815
+ // key is before the identity can sign for it, and has to be told what
2816
+ // identity it belongs to afterwards. The identity secret never moves,
2817
+ // which is the whole point — see shared/deviceProvisioning.js.
2818
+ case '/device': {
2819
+ const sub = (parts[1] || '').toLowerCase();
2820
+
2821
+ if (!sub || sub === 'list') {
2822
+ const own = this.#ownDeviceListForDisplay();
2823
+ this.#ui.addInfoMessage(
2824
+ `This device: ${this.#keyManager.deviceId.slice(0, 8)} — ` +
2825
+ (this.#keyManager.isPrimaryDevice
2826
+ ? 'holds the identity key, so it can add and remove devices'
2827
+ : 'a secondary; only the device holding the identity key can change this list'),
2828
+ );
2829
+ this.#ui.addInfoMessage(`Identity: ${this.#keyManager.identityFingerprint}`);
2830
+ if (!own) {
2831
+ this.#ui.addInfoMessage('No device list yet.');
2832
+ break;
2833
+ }
2834
+ this.#ui.addInfoMessage(`Devices (list v${own.counter}):`);
2835
+ for (const device of own.devices) {
2836
+ const mine = device.deviceId === this.#keyManager.deviceId ? ' (this one)' : '';
2837
+ const label = device.label ? ` ${device.label}` : '';
2838
+ this.#ui.addInfoMessage(` ${device.deviceId.slice(0, 8)}${label}${mine}`);
2839
+ }
2840
+ break;
2841
+ }
2842
+
2843
+ if (sub === 'request') {
2844
+ const request = buildDeviceRequest({
2845
+ deviceId: this.#keyManager.deviceId,
2846
+ boxPk: this.#keyManager.publicKeyB64,
2847
+ label: parts.slice(2).join(' ').trim(),
2848
+ });
2849
+ this.#ui.addInfoMessage(
2850
+ 'Give this to the device that holds your identity key, with /device add:',
2851
+ );
2852
+ this.#ui.addPlainLines([request]);
2853
+ this.#ui.addInfoMessage(
2854
+ 'It is not a secret — it is a public key. Nothing happens until the grant comes back.',
2855
+ );
2856
+ break;
2857
+ }
2858
+
2859
+ if (sub === 'add') {
2860
+ if (!this.#keyManager.isPrimaryDevice) {
2861
+ this.#ui.addErrorMessage(
2862
+ 'Only the device holding the identity key can add another. Run this there.',
2863
+ );
2864
+ break;
2865
+ }
2866
+ const request = parseDeviceRequest(parts.slice(2).join(' ').trim());
2867
+ if (!request) {
2868
+ this.#ui.addErrorMessage('Usage: /device add ciphermesh-device://request/…');
2869
+ break;
2870
+ }
2871
+ const grant = this.#grantDevice(request);
2872
+ if (!grant) {
2873
+ break;
2874
+ }
2875
+ this.#ui.addInfoMessage(
2876
+ `Added ${request.deviceId.slice(0, 8)}. Give this back to it with /device accept:`,
2877
+ );
2878
+ this.#ui.addPlainLines([grant]);
2879
+ break;
2880
+ }
2881
+
2882
+ if (sub === 'remove') {
2883
+ if (!this.#keyManager.isPrimaryDevice) {
2884
+ this.#ui.addErrorMessage(
2885
+ 'Only the device holding the identity key can remove one. Run this there.',
2886
+ );
2887
+ break;
2888
+ }
2889
+ this.#revokeDevice((parts[2] || '').trim());
2890
+ break;
2891
+ }
2892
+
2893
+ if (sub === 'accept') {
2894
+ this.#acceptDeviceGrant(parts.slice(2).join(' ').trim());
2895
+ break;
2896
+ }
2897
+
2898
+ this.#ui.addErrorMessage(
2899
+ 'Usage: /device [list|request|add <req>|accept <grant>|remove <id>]',
2900
+ );
2901
+ break;
2902
+ }
2903
+
2019
2904
  case '/tips': {
2020
2905
  this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
2021
2906
  this.#ui.addTip(tipAt(this.#tipIndex));
@@ -2944,6 +3829,8 @@ export class ChatController {
2944
3829
  this.#keyManager.publicKeyB64,
2945
3830
  this.#keyManager.pqPublicKeyB64,
2946
3831
  OWN_CAPABILITIES,
3832
+ this.#keyManager.identityPublicKeyB64,
3833
+ this.#ownDeviceListForDisplay(),
2947
3834
  ),
2948
3835
  );
2949
3836
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
@@ -3170,15 +4057,28 @@ export class ChatController {
3170
4057
  this.#allPeers.set(peer.sessionId, {
3171
4058
  nickname: peer.nickname,
3172
4059
  publicKey: peer.publicKey,
4060
+ // The relay sends these here exactly as it does in join_ack. Dropping
4061
+ // them made every peer look incapable after a room switch, which is not
4062
+ // an error anywhere — it is a room that silently never turns the group
4063
+ // path on, because one absent capability is enough to hold all of it.
4064
+ caps: normalizeCaps(peer.caps),
3173
4065
  rooms: new Set([msg.room]),
3174
4066
  });
3175
4067
  if (!this.#handshake.getRatchet(peer.sessionId)) {
3176
4068
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
3177
4069
  }
3178
- this.#checkTrust(peer.nickname, peer.publicKey);
4070
+ if (!this.#isOwnDevice(peer)) {
4071
+ this.#checkTrust(peer.nickname, peer.publicKey);
4072
+ }
3179
4073
  }
3180
4074
 
3181
4075
  this.#rebuildActivePeers();
4076
+ // A switch drops every buffer, so it drops every chain with them. Carrying
4077
+ // one across would mean a chain drawn for one room's membership being used
4078
+ // against another's, and a keyId outliving the set of people it labelled.
4079
+ // The new room's chain is drawn on first use and distributed by the same
4080
+ // exchange as any other.
4081
+ this.#dropAllGroups();
3182
4082
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
3183
4083
  this.#announceJoinedRoom(
3184
4084
  msg.room,
@@ -3205,13 +4105,16 @@ export class ChatController {
3205
4105
  this.#allPeers.set(peer.sessionId, {
3206
4106
  nickname: peer.nickname,
3207
4107
  publicKey: peer.publicKey,
4108
+ caps: normalizeCaps(peer.caps),
3208
4109
  rooms: new Set([msg.room]),
3209
4110
  });
3210
4111
  if (!this.#handshake.getRatchet(peer.sessionId)) {
3211
4112
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
3212
4113
  }
3213
4114
  }
3214
- this.#checkTrust(peer.nickname, peer.publicKey);
4115
+ if (!this.#isOwnDevice(peer)) {
4116
+ this.#checkTrust(peer.nickname, peer.publicKey);
4117
+ }
3215
4118
  }
3216
4119
 
3217
4120
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room, additive: true });
@@ -3263,6 +4166,18 @@ export class ChatController {
3263
4166
 
3264
4167
  // ── Handle PEER_KICKED ────────────────────────────────────
3265
4168
  #onPeerKicked(msg) {
4169
+ // The relay sends this immediately before the peer_left for the same
4170
+ // session. Remember it so the departure is reported as a kick and not
4171
+ // announced twice; the state work still happens in #onPeerLeft, which is
4172
+ // the one place that knows how to unwind a member.
4173
+ if (typeof msg.sessionId === 'string') {
4174
+ // A kick whose peer_left never arrives (an older relay) must not park an
4175
+ // entry here forever.
4176
+ if (this.#kickedSessions.size >= 64) {
4177
+ this.#kickedSessions.clear();
4178
+ }
4179
+ this.#kickedSessions.add(msg.sessionId);
4180
+ }
3266
4181
  if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
3267
4182
  const reason = msg.reason ? ` (reason: ${msg.reason})` : '';
3268
4183
  this.#ui.addErrorMessage(`You were kicked from the room${reason}`);
@@ -3558,6 +4473,15 @@ export class ChatController {
3558
4473
  payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
3559
4474
  }
3560
4475
 
4476
+ // One ciphertext for the room, when the room and the relay can both take
4477
+ // one. Everything above this line has already run, so the bytes inside are
4478
+ // identical either way — including cover traffic, which has to travel the
4479
+ // path real messages travel or it stops resembling them.
4480
+ if (this.#canSendToGroup(deniable)) {
4481
+ this.#sendRoomGroup(this.#currentRoom, payload);
4482
+ return;
4483
+ }
4484
+
3561
4485
  for (const [peerId] of this.#peers) {
3562
4486
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
3563
4487
  if (!peerPublicKey) {
@@ -3810,11 +4734,7 @@ export class ChatController {
3810
4734
  this.#historyStore.destroy();
3811
4735
  }
3812
4736
  this.#fileTransfer.destroy();
3813
- for (const group of this.#groups.values()) {
3814
- group.destroy();
3815
- }
3816
- this.#groups.clear();
3817
- this.#groupBuffer.clear();
4737
+ this.#dropAllGroups();
3818
4738
  this.#handshake.destroy();
3819
4739
  this.#keyManager.destroy();
3820
4740
  this.#connection.close();