ciphermesh 2.12.0 → 2.14.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.
@@ -2,7 +2,6 @@ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { tmpdir } from 'node:os';
4
4
  import sodium from 'sodium-native';
5
- import notifier from 'node-notifier';
6
5
  import qrcode from 'qrcode-terminal';
7
6
  import {
8
7
  MSG,
@@ -38,6 +37,19 @@ import {
38
37
  CAP,
39
38
  } from '../shared/constants.js';
40
39
  import { normalizeCaps, peerSupports, roomSupports } from '../protocol/capabilities.js';
40
+ import {
41
+ buildDeviceGrant,
42
+ buildDeviceRequest,
43
+ parseDeviceGrant,
44
+ parseDeviceRequest,
45
+ } from '../shared/deviceProvisioning.js';
46
+ import {
47
+ DEVICE_LIMITS,
48
+ identityFingerprint,
49
+ isNewerList,
50
+ signDeviceList,
51
+ verifyDeviceList,
52
+ } from '../crypto/DeviceIdentity.js';
41
53
  import { GroupSession } from '../crypto/SenderKey.js';
42
54
  import { KeyManager } from '../crypto/KeyManager.js';
43
55
  import { Handshake } from '../crypto/Handshake.js';
@@ -71,6 +83,7 @@ import {
71
83
  import { saveLastSession } from '../shared/lastSession.js';
72
84
  import { diagnose, formatDiagnosis } from '../shared/doctor.js';
73
85
  import { pluginsCommand } from '../shared/pluginCommand.js';
86
+ import { DesktopNotifier } from '../shared/desktopNotify.js';
74
87
  import { COMMANDS } from './UI.js';
75
88
 
76
89
  const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
@@ -119,6 +132,7 @@ export class ChatController {
119
132
  #inviteRoom;
120
133
  #historyStore;
121
134
  #receiptsEnabled;
135
+ #desktopNotifier;
122
136
  #sentMessageLines; // Map<messageId, { lineIndex, baseLine }>
123
137
  #messageReaders; // Map<messageId, Set<nickname>>
124
138
  #away;
@@ -146,6 +160,17 @@ export class ChatController {
146
160
  #buffers = new Map(); // room → { unread, mentions, private, owner, secrets, pins }
147
161
  #bufferOrder = []; // Alt+1..9 order
148
162
  #allPeers = new Map(); // sessionId → { nickname, publicKey, rooms: Set } (all my rooms)
163
+ // Device lists, keyed by the identity that signed them. Multi-device step 3
164
+ // (docs/design/multi-device.md): received, verified and kept — and consulted
165
+ // by nothing. Every identity here has exactly one device today, because
166
+ // nothing can yet add a second.
167
+ #deviceLists = new Map(); // identityPk → verified list
168
+ #deviceListSentTo = new Set(); // sessionIds holding our *current* list
169
+ // `nickname:boxPk` pairs we have warned the user about. Kept so that a proof
170
+ // arriving later can close the warning it answers, rather than leaving the
171
+ // user with an unexplained alarm in their scrollback.
172
+ #warnedKeys = new Set();
173
+ #ownList = null; // cached signature; redrawn when the counter moves
149
174
  #pendingRoomSecrets = null; // derived while joining/creating, promoted on join
150
175
 
151
176
  constructor(
@@ -216,6 +241,9 @@ export class ChatController {
216
241
  this.#coverMode = 'off';
217
242
  this.#coverTimer = null;
218
243
  this.#paceQueue = [];
244
+ this.#desktopNotifier = new DesktopNotifier({
245
+ onUnavailable: (reason) => this.#onNotifierUnavailable(reason),
246
+ });
219
247
 
220
248
  this.#setupConnectionHandlers();
221
249
  this.#setupUIHandlers();
@@ -239,6 +267,11 @@ export class ChatController {
239
267
  this.#keyManager.publicKeyB64,
240
268
  this.#keyManager.pqPublicKeyB64,
241
269
  OWN_CAPABILITIES,
270
+ this.#keyManager.identityPublicKeyB64,
271
+ // Carried so the relay can let this session share a nickname another of
272
+ // our own devices already holds. Sent whenever there is one: a client
273
+ // cannot know in advance whether its other device is already online.
274
+ this.#ownDeviceListForDisplay(),
242
275
  ),
243
276
  );
244
277
  }
@@ -546,7 +579,14 @@ export class ChatController {
546
579
  case TrustResult.TRUSTED:
547
580
  break;
548
581
 
582
+ // Another of this peer's devices, signed by the identity bound to their
583
+ // record. Silent: the alarm below is for a key nobody vouched for, and
584
+ // this one has been vouched for by exactly what the user verified.
585
+ case TrustResult.KNOWN_DEVICE:
586
+ break;
587
+
549
588
  case TrustResult.MISMATCH:
589
+ this.#warnedKeys.add(`${nickname.toLowerCase()}:${publicKey}`);
550
590
  this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname });
551
591
  this.#ui.addErrorMessage(
552
592
  `WARNING: ${nickname}'s key changed! Possible MITM attack. Use /trust ${nickname} to accept or /verify ${nickname} to verify.`,
@@ -554,6 +594,7 @@ export class ChatController {
554
594
  break;
555
595
 
556
596
  case TrustResult.VERIFIED_MISMATCH:
597
+ this.#warnedKeys.add(`${nickname.toLowerCase()}:${publicKey}`);
557
598
  this.#auditLog.log(AuditEvent.TRUST_VERIFIED_MISMATCH, { nickname });
558
599
  this.#ui.addErrorMessage(
559
600
  `ALERT: ${nickname}'s VERIFIED key changed! This may indicate an attack. Use /verify ${nickname} to re-verify.`,
@@ -590,6 +631,7 @@ export class ChatController {
590
631
  nickname: peer.nickname,
591
632
  publicKey: peer.publicKey,
592
633
  caps: normalizeCaps(peer.caps),
634
+ identityKey: peer.identityKey ?? null,
593
635
  rooms: new Set([room]),
594
636
  });
595
637
 
@@ -601,7 +643,9 @@ export class ChatController {
601
643
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
602
644
  }
603
645
 
604
- this.#checkTrust(peer.nickname, peer.publicKey);
646
+ if (!this.#isOwnDevice(peer)) {
647
+ this.#checkTrust(peer.nickname, peer.publicKey);
648
+ }
605
649
  }
606
650
 
607
651
  // Initialize ratchets now that we have our session ID
@@ -731,18 +775,24 @@ export class ChatController {
731
775
  nickname: p.nickname,
732
776
  publicKey: p.publicKey,
733
777
  caps: p.caps || [],
778
+ // Carried, not read. Step 3 is what gives it meaning; carrying it now
779
+ // is what lets step 3 be a small change instead of a wide one, and
780
+ // what makes "did the advertisement survive the trip" testable before
781
+ // anything depends on the answer.
782
+ identityKey: p.identityKey ?? null,
734
783
  });
735
784
  }
736
785
  }
737
- this.#ui.setOnlineCount(this.#peers.size + 1);
738
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
786
+ // People, not connections: your own other devices are you.
787
+ const people = this.#otherPeople();
788
+ this.#ui.setOnlineCount(people.length + 1);
789
+ this.#ui.setPeerNames(people.map(([, p]) => p.nickname));
739
790
  }
740
791
 
741
- // Can every member of the active room speak `cap`? Nothing calls this on a
742
- // send path yet group send/receive is the next step, and this is the switch
743
- // it will be gated on. Kept here so the negotiation is testable before the
744
- // feature that depends on it exists; `own` is overridable for exactly that,
745
- // since this build advertises nothing yet.
792
+ // Can every member of the active room speak `cap`? This is the switch the
793
+ // group send path is gated on, together with the relay check below see
794
+ // #canSendToGroup. `own` stays overridable so a test can ask the question as
795
+ // a build that advertises something else.
746
796
  roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
747
797
  return roomSupports(this.#peers.values(), cap, own);
748
798
  }
@@ -837,17 +887,26 @@ export class ChatController {
837
887
  nickname: peer.nickname,
838
888
  publicKey: peer.publicKey,
839
889
  caps: normalizeCaps(peer.caps),
890
+ identityKey: peer.identityKey ?? null,
840
891
  rooms: new Set([room]),
841
892
  });
842
893
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
843
894
  }
844
- this.#checkTrust(peer.nickname, peer.publicKey);
895
+ // Your own other device is not a peer to be trusted on first sight: a
896
+ // record for yourself would sit in the trust store forever, and the verify
897
+ // nudge would be asking you to compare digits with your own phone.
898
+ const ownDevice = this.#isOwnDevice(peer);
899
+ if (!ownDevice) {
900
+ this.#checkTrust(peer.nickname, peer.publicKey);
901
+ }
845
902
  this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
846
903
 
847
904
  if (room === this.#currentRoom) {
848
905
  this.#rebuildActivePeers();
849
906
  this.#ui.handshakeConnect(peer.nickname);
850
- this.#nudgeVerify(peer.nickname);
907
+ if (!ownDevice) {
908
+ this.#nudgeVerify(peer.nickname);
909
+ }
851
910
  } else {
852
911
  this.#ui.toBuffer(room, () => {
853
912
  this.#ui.addSystemMessage(`${peer.nickname} joined #${room}`);
@@ -864,6 +923,9 @@ export class ChatController {
864
923
  if (room === this.#currentRoom) {
865
924
  this.#distributeSenderKey(room, peer.sessionId);
866
925
  }
926
+ if (this.#wantsDeviceList(peer.sessionId)) {
927
+ this.#distributeDeviceList(peer.sessionId);
928
+ }
867
929
 
868
930
  // A newcomer doesn't know my presence — send only to them
869
931
  if (this.#away || this.#statusText) {
@@ -1117,6 +1179,426 @@ export class ChatController {
1117
1179
  }
1118
1180
  }
1119
1181
 
1182
+ // ── Device lists ────────────────────────────────────────────────
1183
+ //
1184
+ // Step 3 of multi-device. A device list says "these are the keys that are me",
1185
+ // signed by the identity key advertised in JOIN. Today every list has exactly
1186
+ // one device in it, because nothing can add a second yet — the point of
1187
+ // landing it now is that the distribution, the verification and the replay
1188
+ // rule are all exercised before they carry weight.
1189
+ //
1190
+ // Nothing reads a stored list. That is deliberate and it is the last step at
1191
+ // which it is true: step 4 moves verification onto these keys.
1192
+
1193
+ // Our own list, signed once per counter. The counter moves when the
1194
+ // descriptor does — see KeyManager.rotate — so caching on it cannot serve a
1195
+ // signature for a device that has since changed its key.
1196
+ #ownDeviceList() {
1197
+ // A secondary holds no identity secret, so the only list it can publish is
1198
+ // the one it was granted. It is signed by the same identity and says the
1199
+ // same thing; it simply cannot be updated from here.
1200
+ if (!this.#keyManager.isPrimaryDevice) {
1201
+ return this.#keyManager.grantedList;
1202
+ }
1203
+ const counter = this.#keyManager.listCounter;
1204
+ if (!this.#ownList || this.#ownList.counter !== counter) {
1205
+ this.#ownList = signDeviceList(this.#keyManager.identity, counter, [
1206
+ this.#keyManager.deviceDescriptor(),
1207
+ ]);
1208
+ // A new list is a list nobody has.
1209
+ this.#deviceListSentTo.clear();
1210
+ }
1211
+ return this.#ownList;
1212
+ }
1213
+
1214
+ // Worth handing one to this peer? Same reasoning as #worthDistributingTo for
1215
+ // sender keys: a peer that cannot read it gains nothing and costs a round
1216
+ // trip. The relay is not consulted — a device list travels on the pairwise
1217
+ // channel the relay already carries, so there is nothing for it to agree to.
1218
+ #wantsDeviceList(peerId) {
1219
+ const peer = this.#peers.get(peerId);
1220
+ return peer ? peerSupports(peer, CAP.DEVICE_LIST) : false;
1221
+ }
1222
+
1223
+ // Hand our list to one peer, or to everyone who can take one.
1224
+ //
1225
+ // Never on join, for the reason sender keys are not: a newcomer learns the
1226
+ // room from its join_ack before the room learns of the newcomer, so a client
1227
+ // announcing itself on arrival is talking to peers who hold no key for it and
1228
+ // will drop the ciphertext. The peers who already know us speak first, and we
1229
+ // answer.
1230
+ #distributeDeviceList(toPeer = null) {
1231
+ const recipients = (toPeer ? [toPeer] : [...this.#peers.keys()]).filter((id) =>
1232
+ this.#wantsDeviceList(id),
1233
+ );
1234
+ if (recipients.length === 0) {
1235
+ return;
1236
+ }
1237
+ const list = this.#ownDeviceList();
1238
+ if (!list) {
1239
+ return;
1240
+ }
1241
+ const payload = JSON.stringify({ action: 'device_list', list, sentAt: Date.now() });
1242
+ // Record before sending, never after — the same re-entrancy that bit sender
1243
+ // key distribution. Sending re-enters this object: the peer receives our
1244
+ // list, finds it holds none of ours, and answers, and its answer can arrive
1245
+ // before #sendPayloadToPeer has returned. Marked afterwards, both sides
1246
+ // consult a record neither has written yet and each answers the other's
1247
+ // answer.
1248
+ for (const peerId of recipients) {
1249
+ this.#deviceListSentTo.add(peerId);
1250
+ this.#sendPayloadToPeer(peerId, payload);
1251
+ }
1252
+ }
1253
+
1254
+ // A peer handed us theirs.
1255
+ //
1256
+ // What authenticates it is not the seal — crypto_box_seal is anonymous, and
1257
+ // anyone can seal a blob to us claiming any sender. It is the layer under it:
1258
+ // this payload only decrypted because it was encrypted to us *by the holder
1259
+ // of this peer's box secret key*. So the pairwise channel is the authority on
1260
+ // whose list this is, and the identityKey the relay repeated in JOIN is only
1261
+ // ever a hint about whether distributing is worth the round trip. A relay that
1262
+ // tampers with that hint can stop us bothering; it cannot put words in a
1263
+ // peer's mouth, because it cannot produce this payload.
1264
+ #onDeviceList(fromSessionId, data) {
1265
+ const list = verifyDeviceList(data?.list);
1266
+ if (!list) {
1267
+ return;
1268
+ }
1269
+
1270
+ // Highest counter wins, enforced here rather than trusted from the sender:
1271
+ // a relay that kept a copy of an older list could otherwise replay it, and
1272
+ // once revocation exists that would put a removed device back.
1273
+ const held = this.#deviceLists.get(list.identityPk);
1274
+ if (isNewerList(list, held)) {
1275
+ this.#deviceLists.set(list.identityPk, list);
1276
+ this.#bindPeerIdentity(fromSessionId, list);
1277
+ }
1278
+
1279
+ // Answer with ours if they do not have it. Whoever knows the other first
1280
+ // speaks; the reply cannot race, because receiving this proves they already
1281
+ // hold our public key.
1282
+ if (!this.#deviceListSentTo.has(fromSessionId)) {
1283
+ this.#distributeDeviceList(fromSessionId);
1284
+ }
1285
+ }
1286
+
1287
+ /** The list held for an identity, or null. */
1288
+ deviceListFor(identityPk) {
1289
+ return this.#deviceLists.get(identityPk) ?? null;
1290
+ }
1291
+
1292
+ // Attach an identity to the trust record the user already has — step 4.
1293
+ //
1294
+ // Only when the binding is provable, which is a narrower condition than it
1295
+ // looks: the list has to *name the box key this peer is using*, and the list
1296
+ // only reached us because the holder of that box key encrypted it to us. So
1297
+ // the identity is vouched for by exactly the key the user compared digits
1298
+ // over. Anything else and we leave the record alone.
1299
+ //
1300
+ // This is the migration the design doc worried about, and it is silent on
1301
+ // purpose. Every already-verified record was verified against a box key; the
1302
+ // one thing that must not happen is thousands of clients simultaneously
1303
+ // telling their users that everyone they trust has been replaced.
1304
+ #bindPeerIdentity(fromSessionId, list) {
1305
+ const peer = this.#allPeers.get(fromSessionId);
1306
+ if (!peer) {
1307
+ return;
1308
+ }
1309
+ if (!list.devices.some((device) => device.boxPk === peer.publicKey)) {
1310
+ // A valid list that does not mention the key it arrived under. Not
1311
+ // necessarily an attack — a rotation can race a distribution — but it
1312
+ // proves nothing, so it binds nothing.
1313
+ return;
1314
+ }
1315
+
1316
+ const result = this.#trustStore.bindIdentity(peer.nickname, list.identityPk);
1317
+
1318
+ if (result === 'bound' || result === 'unchanged') {
1319
+ this.#recordPeerDevices(peer, list);
1320
+ return;
1321
+ }
1322
+
1323
+ // A second identity for a record that already had one. On an unverified
1324
+ // record this is worth a line; on a verified one it is the same class of
1325
+ // event as VERIFIED_MISMATCH and is said in the same voice.
1326
+ this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname: peer.nickname });
1327
+ if (this.#trustStore.isVerified(peer.nickname)) {
1328
+ this.#ui.addErrorMessage(
1329
+ `${peer.nickname} is presenting a different identity key than the one you verified. ` +
1330
+ 'Nothing has been changed. Verify again out of band before trusting this session.',
1331
+ );
1332
+ } else {
1333
+ this.#ui.addSystemMessage(
1334
+ `${peer.nickname} is presenting a different identity key than before.`,
1335
+ );
1336
+ }
1337
+ }
1338
+
1339
+ // Write the devices an identity has signed for onto the peer's trust record,
1340
+ // so the next time one of them shows up it is recognised instead of reported.
1341
+ //
1342
+ // A second device is otherwise indistinguishable from an attack: it arrives
1343
+ // under the same nickname with a box key the record has never seen, which is
1344
+ // precisely what checkPeer is built to shout about. It *should* shout — until
1345
+ // something proves otherwise, a new key under a known name is the shape of a
1346
+ // MITM. So the alarm is never suppressed in advance. It is answered, once the
1347
+ // proof exists, and the answer is said out loud rather than swallowed: the
1348
+ // user saw a warning and is owed the resolution.
1349
+ #recordPeerDevices(peer, list) {
1350
+ const { added, removed } = this.#trustStore.syncDevices(
1351
+ peer.nickname,
1352
+ list.identityPk,
1353
+ list.devices.map((device) => device.boxPk),
1354
+ );
1355
+
1356
+ // A device was revoked. It holds a sender chain, and a chain ratchets
1357
+ // forward — so being dropped from a list stops the relay delivering to it
1358
+ // and does not stop it reading. Rotation is what closes that, exactly as it
1359
+ // does for a member who leaves (#482).
1360
+ if (removed.length > 0) {
1361
+ this.#ui.addSystemMessage(
1362
+ `${peer.nickname} removed ${removed.length === 1 ? 'a device' : `${removed.length} devices`}. ` +
1363
+ 'Rotating this room, so nothing said from here reaches it.',
1364
+ );
1365
+ this.#rotateGroupFor(this.#currentRoom);
1366
+ }
1367
+
1368
+ // Only speak about the keys the user was actually warned about. A list that
1369
+ // simply happens to mention devices nobody has met is not news.
1370
+ const nick = peer.nickname.toLowerCase();
1371
+ const answered = added.filter((key) => this.#warnedKeys.has(`${nick}:${key}`));
1372
+ if (answered.length === 0) {
1373
+ return;
1374
+ }
1375
+ for (const key of answered) {
1376
+ this.#warnedKeys.delete(`${nick}:${key}`);
1377
+ }
1378
+ this.#ui.addSystemMessage(
1379
+ `The key you were warned about for ${peer.nickname} is another of their devices — ` +
1380
+ `signed by the identity you already ${
1381
+ this.#trustStore.isVerified(peer.nickname) ? 'verified' : 'know'
1382
+ }, so it is not a key that changed.`,
1383
+ );
1384
+ }
1385
+
1386
+ /** Whatever list this device would publish, primary or secondary. */
1387
+ #ownDeviceListForDisplay() {
1388
+ return this.#keyManager.isPrimaryDevice ? this.#ownDeviceList() : this.#keyManager.grantedList;
1389
+ }
1390
+
1391
+ // Sign a new list that includes the asking device, and hand back a grant.
1392
+ //
1393
+ // The counter moves because the list changed — that is what makes every peer
1394
+ // take the new one instead of keeping a list the new device is missing from.
1395
+ #grantDevice(request) {
1396
+ const current = this.#ownDeviceList();
1397
+ const devices = current ? [...current.devices] : [this.#keyManager.deviceDescriptor()];
1398
+
1399
+ if (devices.some((device) => device.deviceId === request.deviceId)) {
1400
+ this.#ui.addErrorMessage('That device is already on the list.');
1401
+ return null;
1402
+ }
1403
+ if (devices.some((device) => device.boxPk === request.boxPk)) {
1404
+ // One key under two device ids makes "which device is this" unanswerable
1405
+ // for every reader, and verifyDeviceList refuses such a list outright.
1406
+ this.#ui.addErrorMessage('That key is already on the list under another device.');
1407
+ return null;
1408
+ }
1409
+ if (devices.length >= DEVICE_LIMITS.MAX_DEVICES) {
1410
+ this.#ui.addErrorMessage(`A list holds at most ${DEVICE_LIMITS.MAX_DEVICES} devices.`);
1411
+ return null;
1412
+ }
1413
+
1414
+ devices.push({
1415
+ deviceId: request.deviceId,
1416
+ boxPk: request.boxPk,
1417
+ label: request.label,
1418
+ createdAt: Date.now(),
1419
+ });
1420
+
1421
+ this.#keyManager.bumpListCounter();
1422
+ const list = signDeviceList(this.#keyManager.identity, this.#keyManager.listCounter, devices);
1423
+ // A new list is a list nobody has; #ownDeviceList caches on the counter, so
1424
+ // resetting here is what makes the next distribution carry this one.
1425
+ this.#ownList = list;
1426
+ this.#deviceListSentTo.clear();
1427
+ this.#distributeDeviceList();
1428
+
1429
+ this.#auditLog.log(AuditEvent.KEY_ROTATION_OWN, { fingerprint: request.deviceId.slice(0, 8) });
1430
+ return buildDeviceGrant({ identityPk: list.identityPk, list });
1431
+ }
1432
+
1433
+ // Sign a list without a device, and rotate so it cannot read what comes next.
1434
+ //
1435
+ // The list alone is only half of revocation. A removed device still holds
1436
+ // every member's sender chain, and a chain ratchets forward — dropping it
1437
+ // from the list stops the relay delivering to it and does not stop it
1438
+ // reading. Rotating is what closes that, and it is the same reasoning that
1439
+ // made #482 rotate on a kick.
1440
+ #revokeDevice(prefix) {
1441
+ const current = this.#ownDeviceList();
1442
+ if (!prefix || !current) {
1443
+ this.#ui.addErrorMessage('Usage: /device remove <id> (the first 8 characters are enough)');
1444
+ return;
1445
+ }
1446
+
1447
+ const matches = current.devices.filter((device) => device.deviceId.startsWith(prefix));
1448
+ if (matches.length === 0) {
1449
+ this.#ui.addErrorMessage(`No device on the list starts with "${prefix}".`);
1450
+ return;
1451
+ }
1452
+ if (matches.length > 1) {
1453
+ this.#ui.addErrorMessage(
1454
+ `"${prefix}" matches ${matches.length} devices. Use more of the id.`,
1455
+ );
1456
+ return;
1457
+ }
1458
+ if (matches[0].deviceId === this.#keyManager.deviceId) {
1459
+ // Removing the device that holds the identity secret would leave a list
1460
+ // signed by a key no listed device holds — an identity that can still
1461
+ // sign but belongs to nobody in it.
1462
+ this.#ui.addErrorMessage('This device holds the identity key and cannot remove itself.');
1463
+ return;
1464
+ }
1465
+
1466
+ const remaining = current.devices.filter((device) => device.deviceId !== matches[0].deviceId);
1467
+ this.#keyManager.bumpListCounter();
1468
+ this.#ownList = signDeviceList(
1469
+ this.#keyManager.identity,
1470
+ this.#keyManager.listCounter,
1471
+ remaining,
1472
+ );
1473
+ this.#deviceListSentTo.clear();
1474
+ this.#distributeDeviceList();
1475
+ this.#rotateGroupFor(this.#currentRoom);
1476
+
1477
+ this.#auditLog.log(AuditEvent.KEY_ROTATION_OWN, {
1478
+ fingerprint: matches[0].deviceId.slice(0, 8),
1479
+ });
1480
+ this.#ui.addSystemMessage(
1481
+ `Removed device ${matches[0].deviceId.slice(0, 8)}. The room has been rotated, so nothing ` +
1482
+ 'said from now on reaches it. It keeps whatever it already received.',
1483
+ );
1484
+ }
1485
+
1486
+ // Adopt an identity this device does not hold the secret for.
1487
+ //
1488
+ // Three checks, and all three matter. The list has to verify under the
1489
+ // identity it claims, or a grant is just a JSON blob. It has to name *this*
1490
+ // device by both id and key, or a grant intended for somebody else — or
1491
+ // tampered with in transit — would be accepted. And this device must not
1492
+ // already be a secondary of something else, because there is no sensible
1493
+ // meaning for two.
1494
+ #acceptDeviceGrant(text) {
1495
+ const grant = parseDeviceGrant(text);
1496
+ if (!grant) {
1497
+ this.#ui.addErrorMessage('Usage: /device accept ciphermesh-device://grant/…');
1498
+ return;
1499
+ }
1500
+ if (!this.#keyManager.isPrimaryDevice) {
1501
+ this.#ui.addErrorMessage('This device already belongs to an identity.');
1502
+ return;
1503
+ }
1504
+
1505
+ const list = verifyDeviceList(grant.list);
1506
+ if (!list || list.identityPk !== grant.identityPk) {
1507
+ this.#ui.addErrorMessage('That grant is not signed by the identity it names.');
1508
+ return;
1509
+ }
1510
+
1511
+ const me = list.devices.find(
1512
+ (device) =>
1513
+ device.deviceId === this.#keyManager.deviceId &&
1514
+ device.boxPk === this.#keyManager.publicKeyB64,
1515
+ );
1516
+ if (!me) {
1517
+ this.#ui.addErrorMessage(
1518
+ 'That grant is for a different device. Run /device request here and use that.',
1519
+ );
1520
+ return;
1521
+ }
1522
+
1523
+ this.#keyManager.adoptIdentity(grant.identityPk, list);
1524
+ this.#ownList = null;
1525
+ this.#deviceListSentTo.clear();
1526
+ this.#ui.addSystemMessage(
1527
+ `This device now belongs to identity ${this.#keyManager.identityFingerprint}. ` +
1528
+ 'It cannot add or remove devices — only the device holding the identity key can. ' +
1529
+ 'Reconnect for peers to see it.',
1530
+ );
1531
+ }
1532
+
1533
+ /**
1534
+ * Is this peer one of *our* devices?
1535
+ *
1536
+ * Proven, never asserted. The obvious test — does the peer's `identityKey`
1537
+ * match ours — would be worse than useless: the relay forwards that field
1538
+ * unchecked, so a hostile one could label a stranger as your own device and
1539
+ * every protection below would be turned off for them. Their messages would
1540
+ * be attributed to you.
1541
+ *
1542
+ * So the answer comes from a list we hold the signature of: our own. A device
1543
+ * is ours if our list names its box key, which only the holder of the
1544
+ * identity secret could have arranged.
1545
+ */
1546
+ #isOwnDevice(peer) {
1547
+ const own = this.#ownDeviceListForDisplay();
1548
+ if (!own || !peer?.publicKey) {
1549
+ return false;
1550
+ }
1551
+ return own.devices.some(
1552
+ (device) => device.boxPk === peer.publicKey && device.boxPk !== this.#keyManager.publicKeyB64,
1553
+ );
1554
+ }
1555
+
1556
+ /** The peers in this room that are other people, rather than other devices. */
1557
+ #otherPeople() {
1558
+ return [...this.#peers.entries()].filter(([, peer]) => !this.#isOwnDevice(peer));
1559
+ }
1560
+
1561
+ // Can this pair compare identity keys instead of box keys?
1562
+ //
1563
+ // Both sides have to answer the same way or two people doing everything right
1564
+ // are shown two different codes and conclude they are under attack. So the
1565
+ // condition is symmetric by construction: each of us holds the other's list,
1566
+ // which is exactly the state the exchange leaves both in.
1567
+ #identitySasReady(peerId) {
1568
+ const peer = this.#peers.get(peerId);
1569
+ if (!peer?.identityKey || !peerSupports(peer, CAP.DEVICE_LIST)) {
1570
+ return false;
1571
+ }
1572
+ const bound = this.#trustStore.identityFor(peer.nickname);
1573
+ return Boolean(bound) && this.#deviceListSentTo.has(peerId);
1574
+ }
1575
+
1576
+ /**
1577
+ * The code to compare for a peer, and which keys it is over.
1578
+ *
1579
+ * Exposed rather than inlined into the command so a test can ask the question
1580
+ * without going through the UI.
1581
+ */
1582
+ sasFor(peerId) {
1583
+ const peer = this.#peers.get(peerId);
1584
+ if (!peer) {
1585
+ return null;
1586
+ }
1587
+ if (this.#identitySasReady(peerId)) {
1588
+ return {
1589
+ over: 'identity',
1590
+ code: TrustStore.computeIdentitySAS(
1591
+ this.#keyManager.identityPublicKeyB64,
1592
+ this.#trustStore.identityFor(peer.nickname),
1593
+ ),
1594
+ };
1595
+ }
1596
+ return {
1597
+ over: 'device',
1598
+ code: TrustStore.computeSAS(this.#keyManager.publicKeyB64, peer.publicKey),
1599
+ };
1600
+ }
1601
+
1120
1602
  // Can this payload go out once, addressed to the room, instead of N times?
1121
1603
  #canSendToGroup(deniable) {
1122
1604
  // Deniability is a property of the pairwise construction — a symmetric key
@@ -1135,6 +1617,76 @@ export class ChatController {
1135
1617
  );
1136
1618
  }
1137
1619
 
1620
+ // Which path the next line you type will take out of this room, and — when it
1621
+ // is the expensive one — what is holding it there.
1622
+ //
1623
+ // The fallback is invisible today. A room quietly sends N envelopes per line
1624
+ // instead of one and nobody can tell whether that is one peer on an older
1625
+ // build, an older hub, or deniable mode left on an hour ago. That is the same
1626
+ // shape as the three bugs this feature already shipped with: nothing errors,
1627
+ // nothing is logged, the room is just paying fifty times over and no one
1628
+ // knows.
1629
+ //
1630
+ // #481 asks whether the per-peer loop can be retired. It cannot — see the
1631
+ // decision recorded in docs/design/sender-keys-on-relay.md — so the useful
1632
+ // thing is being able to see when it runs and why, which is also what turns
1633
+ // "consider retiring it" into a question answerable with data.
1634
+ //
1635
+ // The order matches #canSendToGroup so the two cannot disagree, with one
1636
+ // deliberate exception: an older hub is reported before older peers. Both can
1637
+ // be true at once, and naming peers who are perfectly current would send the
1638
+ // reader after the wrong problem.
1639
+ groupSendStatus() {
1640
+ if (this.#deniableMode) {
1641
+ return { group: false, reason: 'deniable', blockers: [] };
1642
+ }
1643
+ if (this.#peers.size === 0) {
1644
+ return { group: false, reason: 'alone', blockers: [] };
1645
+ }
1646
+ if (!this.relaySupportsCapability(CAP.SENDER_KEYS)) {
1647
+ return { group: false, reason: 'relay', blockers: [] };
1648
+ }
1649
+ const blockers = [...this.#peers.values()]
1650
+ .filter((peer) => !peerSupports(peer, CAP.SENDER_KEYS))
1651
+ .map((peer) => peer.nickname);
1652
+ if (blockers.length > 0) {
1653
+ return { group: false, reason: 'peers', blockers };
1654
+ }
1655
+ // roomSupports() also requires *this* build to advertise the capability,
1656
+ // which is the one remaining way to land here with nobody to name.
1657
+ if (!this.roomSupportsCapability(CAP.SENDER_KEYS)) {
1658
+ return { group: false, reason: 'self', blockers: [] };
1659
+ }
1660
+ return { group: true, reason: null, blockers: [] };
1661
+ }
1662
+
1663
+ // Plain language, because the number is the whole point: a line costs one
1664
+ // encryption and one frame, or it costs one of each per person in the room.
1665
+ #describeSendPath() {
1666
+ const status = this.groupSendStatus();
1667
+ const size = this.#peers.size;
1668
+
1669
+ if (status.group) {
1670
+ return `one ciphertext to the room (sender keys), read by ${size}`;
1671
+ }
1672
+
1673
+ const cost = `${size} envelope${size === 1 ? '' : 's'} per message`;
1674
+ switch (status.reason) {
1675
+ case 'alone':
1676
+ return 'nothing yet — no one else is here';
1677
+ case 'deniable':
1678
+ return `${cost} — deniable mode is on, and deniability is pairwise`;
1679
+ case 'relay':
1680
+ return `${cost} — this relay cannot fan out a room-addressed message`;
1681
+ case 'peers':
1682
+ return `${cost} — ${status.blockers.join(', ')} ${
1683
+ status.blockers.length === 1 ? 'is' : 'are'
1684
+ } on a build without sender keys`;
1685
+ default:
1686
+ return `${cost} — this build is not advertising sender keys`;
1687
+ }
1688
+ }
1689
+
1138
1690
  // One encryption, one frame, the whole room. The payload arrives already
1139
1691
  // room-tagged and already through the private-room layer when there is one —
1140
1692
  // both happen before the path splits, so a group message and a pairwise one
@@ -1374,6 +1926,13 @@ export class ChatController {
1374
1926
  return;
1375
1927
  }
1376
1928
 
1929
+ // A device list. Pairwise for the same reason a sender key is: the
1930
+ // channel is what says whose it is.
1931
+ if (data.action === 'device_list') {
1932
+ this.#onDeviceList(msg.from, data);
1933
+ return;
1934
+ }
1935
+
1377
1936
  // Which buffer this belongs to (the tag rides inside the E2EE envelope).
1378
1937
  const msgRoom = this.#roomForIncoming(data, msg.from);
1379
1938
  const roomActive = msgRoom === this.#currentRoom;
@@ -1677,9 +2236,18 @@ export class ChatController {
1677
2236
  });
1678
2237
  }
1679
2238
 
1680
- const watchHit = this.#matchedWatch(data.text);
1681
- // A watched keyword deserves the same attention a mention gets.
1682
- const mentioned = (this.#mentionsMe(data.text) || !!watchHit) && !data.isDM;
2239
+ // A line you sent from another device is yours. Attributing it to the
2240
+ // nickname would be technically true and would read as somebody else
2241
+ // talking; marking the device is what makes the transcript match what
2242
+ // happened.
2243
+ const fromOwnDevice = this.#isOwnDevice(peer);
2244
+
2245
+ const watchHit = fromOwnDevice ? null : this.#matchedWatch(data.text);
2246
+ // A watched keyword deserves the same attention a mention gets — but not
2247
+ // when you are the one who typed it. Your own nickname in your own line
2248
+ // is not somebody calling you, and a notification for it would train you
2249
+ // to ignore the ones that matter.
2250
+ const mentioned = (this.#mentionsMe(data.text) || !!watchHit) && !data.isDM && !fromOwnDevice;
1683
2251
  if (watchHit) {
1684
2252
  this.#ui.toBuffer(msgRoom, () => {
1685
2253
  this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${peer.nickname} in #${msgRoom}`);
@@ -1708,7 +2276,10 @@ export class ChatController {
1708
2276
  );
1709
2277
  }
1710
2278
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
1711
- const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
2279
+ const displayName = fromOwnDevice ? `${peer.nickname} (your other device)` : peer.nickname;
2280
+ const trust = fromOwnDevice
2281
+ ? null
2282
+ : trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
1712
2283
  // File the message into its buffer (live log when active, stored otherwise).
1713
2284
  let lineIndex = -1;
1714
2285
  let renderInfo = null;
@@ -1717,9 +2288,9 @@ export class ChatController {
1717
2288
  this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
1718
2289
  }
1719
2290
  ({ lineIndex, render: renderInfo } = data.isAction
1720
- ? this.#ui.addActionMessage(peer.nickname, data.text)
2291
+ ? this.#ui.addActionMessage(displayName, data.text)
1721
2292
  : this.#ui.addMessage(
1722
- peer.nickname,
2293
+ displayName,
1723
2294
  data.text,
1724
2295
  !!data.isDM,
1725
2296
  ephLabel,
@@ -1759,7 +2330,7 @@ export class ChatController {
1759
2330
 
1760
2331
  // DND / mentions-only gates desktop notifications too.
1761
2332
  if (notify && (this.#ui.notifyEnabled || mentioned)) {
1762
- notifier.notify({
2333
+ this.#desktopNotifier.notify({
1763
2334
  title: mentioned
1764
2335
  ? `🔔 ${peer.nickname} mentioned you`
1765
2336
  : data.isDM
@@ -1779,6 +2350,17 @@ export class ChatController {
1779
2350
  }
1780
2351
  }
1781
2352
 
2353
+ // The OS refused a desktop notification (Windows with notifications turned
2354
+ // off for the app is the common case). Say it once, then stay quiet — the
2355
+ // notifier has already stopped trying, so the chat never sees it again.
2356
+ #onNotifierUnavailable(reason) {
2357
+ this.#ui.setNotifyEnabled(false);
2358
+ this.#ui.addInfoMessage(
2359
+ `Desktop notifications unavailable — ${reason}. Muted for this session; ` +
2360
+ 'sound alerts still work. Use /notify on to retry.',
2361
+ );
2362
+ }
2363
+
1782
2364
  // True if an incoming message references my nickname (@nick or standalone word).
1783
2365
  #mentionsMe(text) {
1784
2366
  return mentionsMe(text, this.#nickname);
@@ -1835,8 +2417,11 @@ export class ChatController {
1835
2417
  this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
1836
2418
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
1837
2419
  this.#ui.addInfoMessage(' /rooms - List available rooms');
1838
- this.#ui.addInfoMessage(' /room - Show the current room');
2420
+ this.#ui.addInfoMessage(
2421
+ ' /room - Current room, how it is sending, and your buffers',
2422
+ );
1839
2423
  this.#ui.addInfoMessage(' /fingerprint - Show your fingerprint');
2424
+ this.#ui.addInfoMessage(' /device [sub] - Your devices under one identity');
1840
2425
  this.#ui.addInfoMessage(" /fingerprint <nick> - Another user's fingerprint");
1841
2426
  this.#ui.addInfoMessage(' /verify <nick> - Show SAS code for verification');
1842
2427
  this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm peer verification');
@@ -1890,7 +2475,8 @@ export class ChatController {
1890
2475
  break;
1891
2476
 
1892
2477
  case '/users': {
1893
- const names = [...this.#peers.values()].map((p) => {
2478
+ const ownDevices = [...this.#peers.values()].filter((p) => this.#isOwnDevice(p)).length;
2479
+ const names = this.#otherPeople().map(([, p]) => {
1894
2480
  let label = p.nickname;
1895
2481
  const alias = this.#trustStore.getAlias(p.nickname);
1896
2482
  if (alias) {
@@ -1904,7 +2490,7 @@ export class ChatController {
1904
2490
  }
1905
2491
  return label;
1906
2492
  });
1907
- let me = `${this.#nickname} (you)`;
2493
+ let me = `${this.#nickname} (you${ownDevices > 0 ? `, on ${ownDevices + 1} devices` : ''})`;
1908
2494
  if (this.#away) {
1909
2495
  me += ` [away${this.#awayReason ? `: ${this.#awayReason}` : ''}]`;
1910
2496
  }
@@ -1921,6 +2507,10 @@ export class ChatController {
1921
2507
  const targetNick = parts[1];
1922
2508
  if (!targetNick) {
1923
2509
  this.#ui.addInfoMessage(`Your fingerprint: ${this.#keyManager.fingerprint}`);
2510
+ // Shown alongside, not instead. The device fingerprint is what every
2511
+ // existing verification was made against, and it stays the thing on
2512
+ // screen until there is nothing left comparing it.
2513
+ this.#ui.addInfoMessage(`Your identity: ${this.#keyManager.identityFingerprint}`);
1924
2514
  this.#ui.addPlainLines(
1925
2515
  keyArt(Buffer.from(this.#keyManager.publicKeyB64, 'base64'), this.#nickname).split(
1926
2516
  '\n',
@@ -1933,6 +2523,12 @@ export class ChatController {
1933
2523
  if (found) {
1934
2524
  const fp = KeyManager.computeFingerprint(Buffer.from(found.publicKey, 'base64'));
1935
2525
  this.#ui.addInfoMessage(`${found.nickname}'s fingerprint: ${fp}`);
2526
+ const boundIdentity = this.#trustStore.identityFor(found.nickname);
2527
+ if (boundIdentity) {
2528
+ this.#ui.addInfoMessage(
2529
+ `${found.nickname}'s identity: ${identityFingerprint(boundIdentity)}`,
2530
+ );
2531
+ }
1936
2532
  this.#ui.addPlainLines(
1937
2533
  keyArt(Buffer.from(found.publicKey, 'base64'), found.nickname).split('\n'),
1938
2534
  );
@@ -1976,9 +2572,21 @@ export class ChatController {
1976
2572
  this.#ui.addErrorMessage(`User "${verifyNick}" not found`);
1977
2573
  break;
1978
2574
  }
1979
- const sas = TrustStore.computeSAS(this.#keyManager.publicKeyB64, verifyPeer.publicKey);
2575
+ const verifySid = [...this.#peers.entries()].find(
2576
+ ([, p]) => p.nickname === verifyPeer.nickname,
2577
+ )?.[0];
2578
+ const { over, code: sas } = this.sasFor(verifySid);
1980
2579
  this.#auditLog.log(AuditEvent.SAS_VERIFY, { nickname: verifyPeer.nickname });
1981
2580
  this.#ui.addInfoMessage(`SAS code for ${verifyPeer.nickname}: ${sas}`);
2581
+ // Say which keys the code is over. Both of you compute it the same way —
2582
+ // the switch is symmetric — but a code that silently changed meaning
2583
+ // between two releases is the one thing that would make comparing them
2584
+ // worthless.
2585
+ this.#ui.addInfoMessage(
2586
+ over === 'identity'
2587
+ ? ' over both identity keys — survives a device key rotation'
2588
+ : ' over both device keys — this peer has no identity key yet',
2589
+ );
1982
2590
  this.#ui.addPlainLines(
1983
2591
  keyArt(Buffer.from(verifyPeer.publicKey, 'base64'), verifyPeer.nickname).split('\n'),
1984
2592
  );
@@ -2071,14 +2679,17 @@ export class ChatController {
2071
2679
  const notifyArg = parts[1]?.toLowerCase();
2072
2680
  if (notifyArg === 'off') {
2073
2681
  this.#ui.setNotifyEnabled(false);
2682
+ this.#desktopNotifier.disable();
2074
2683
  this.#ui.addInfoMessage('Desktop notifications disabled');
2075
2684
  } else if (notifyArg === 'on') {
2076
2685
  this.#ui.setNotifyEnabled(true);
2686
+ this.#desktopNotifier.reset(); // give a previously refusing OS another go
2077
2687
  this.#ui.addInfoMessage('Desktop notifications enabled');
2078
2688
  } else {
2079
2689
  const status = this.#ui.notifyEnabled ? 'enabled' : 'disabled';
2690
+ const blocked = this.#desktopNotifier.available ? '' : ' (blocked by the OS)';
2080
2691
  this.#ui.addInfoMessage(
2081
- `Desktop notifications: ${status}. Use /notify on or /notify off`,
2692
+ `Desktop notifications: ${status}${blocked}. Use /notify on or /notify off`,
2082
2693
  );
2083
2694
  }
2084
2695
  break;
@@ -2201,6 +2812,7 @@ export class ChatController {
2201
2812
  this.#ui.addInfoMessage(
2202
2813
  `Current room: #${this.#currentRoom}${this.#activeSecrets ? ' 🔒 (private)' : ''}`,
2203
2814
  );
2815
+ this.#ui.addInfoMessage(`Sending: ${this.#describeSendPath()}`);
2204
2816
  if (this.#bufferOrder.length > 1) {
2205
2817
  const list = this.#bufferOrder
2206
2818
  .map((r, i) => {
@@ -2215,6 +2827,98 @@ export class ChatController {
2215
2827
  break;
2216
2828
  }
2217
2829
 
2830
+ // ── Devices ──────────────────────────────────────────────
2831
+ //
2832
+ // Three hops, and it cannot be fewer: a new device has to say what its
2833
+ // key is before the identity can sign for it, and has to be told what
2834
+ // identity it belongs to afterwards. The identity secret never moves,
2835
+ // which is the whole point — see shared/deviceProvisioning.js.
2836
+ case '/device': {
2837
+ const sub = (parts[1] || '').toLowerCase();
2838
+
2839
+ if (!sub || sub === 'list') {
2840
+ const own = this.#ownDeviceListForDisplay();
2841
+ this.#ui.addInfoMessage(
2842
+ `This device: ${this.#keyManager.deviceId.slice(0, 8)} — ` +
2843
+ (this.#keyManager.isPrimaryDevice
2844
+ ? 'holds the identity key, so it can add and remove devices'
2845
+ : 'a secondary; only the device holding the identity key can change this list'),
2846
+ );
2847
+ this.#ui.addInfoMessage(`Identity: ${this.#keyManager.identityFingerprint}`);
2848
+ if (!own) {
2849
+ this.#ui.addInfoMessage('No device list yet.');
2850
+ break;
2851
+ }
2852
+ this.#ui.addInfoMessage(`Devices (list v${own.counter}):`);
2853
+ for (const device of own.devices) {
2854
+ const mine = device.deviceId === this.#keyManager.deviceId ? ' (this one)' : '';
2855
+ const label = device.label ? ` ${device.label}` : '';
2856
+ this.#ui.addInfoMessage(` ${device.deviceId.slice(0, 8)}${label}${mine}`);
2857
+ }
2858
+ break;
2859
+ }
2860
+
2861
+ if (sub === 'request') {
2862
+ const request = buildDeviceRequest({
2863
+ deviceId: this.#keyManager.deviceId,
2864
+ boxPk: this.#keyManager.publicKeyB64,
2865
+ label: parts.slice(2).join(' ').trim(),
2866
+ });
2867
+ this.#ui.addInfoMessage(
2868
+ 'Give this to the device that holds your identity key, with /device add:',
2869
+ );
2870
+ this.#ui.addPlainLines([request]);
2871
+ this.#ui.addInfoMessage(
2872
+ 'It is not a secret — it is a public key. Nothing happens until the grant comes back.',
2873
+ );
2874
+ break;
2875
+ }
2876
+
2877
+ if (sub === 'add') {
2878
+ if (!this.#keyManager.isPrimaryDevice) {
2879
+ this.#ui.addErrorMessage(
2880
+ 'Only the device holding the identity key can add another. Run this there.',
2881
+ );
2882
+ break;
2883
+ }
2884
+ const request = parseDeviceRequest(parts.slice(2).join(' ').trim());
2885
+ if (!request) {
2886
+ this.#ui.addErrorMessage('Usage: /device add ciphermesh-device://request/…');
2887
+ break;
2888
+ }
2889
+ const grant = this.#grantDevice(request);
2890
+ if (!grant) {
2891
+ break;
2892
+ }
2893
+ this.#ui.addInfoMessage(
2894
+ `Added ${request.deviceId.slice(0, 8)}. Give this back to it with /device accept:`,
2895
+ );
2896
+ this.#ui.addPlainLines([grant]);
2897
+ break;
2898
+ }
2899
+
2900
+ if (sub === 'remove') {
2901
+ if (!this.#keyManager.isPrimaryDevice) {
2902
+ this.#ui.addErrorMessage(
2903
+ 'Only the device holding the identity key can remove one. Run this there.',
2904
+ );
2905
+ break;
2906
+ }
2907
+ this.#revokeDevice((parts[2] || '').trim());
2908
+ break;
2909
+ }
2910
+
2911
+ if (sub === 'accept') {
2912
+ this.#acceptDeviceGrant(parts.slice(2).join(' ').trim());
2913
+ break;
2914
+ }
2915
+
2916
+ this.#ui.addErrorMessage(
2917
+ 'Usage: /device [list|request|add <req>|accept <grant>|remove <id>]',
2918
+ );
2919
+ break;
2920
+ }
2921
+
2218
2922
  case '/tips': {
2219
2923
  this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
2220
2924
  this.#ui.addTip(tipAt(this.#tipIndex));
@@ -3143,6 +3847,8 @@ export class ChatController {
3143
3847
  this.#keyManager.publicKeyB64,
3144
3848
  this.#keyManager.pqPublicKeyB64,
3145
3849
  OWN_CAPABILITIES,
3850
+ this.#keyManager.identityPublicKeyB64,
3851
+ this.#ownDeviceListForDisplay(),
3146
3852
  ),
3147
3853
  );
3148
3854
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
@@ -3379,7 +4085,9 @@ export class ChatController {
3379
4085
  if (!this.#handshake.getRatchet(peer.sessionId)) {
3380
4086
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
3381
4087
  }
3382
- this.#checkTrust(peer.nickname, peer.publicKey);
4088
+ if (!this.#isOwnDevice(peer)) {
4089
+ this.#checkTrust(peer.nickname, peer.publicKey);
4090
+ }
3383
4091
  }
3384
4092
 
3385
4093
  this.#rebuildActivePeers();
@@ -3422,7 +4130,9 @@ export class ChatController {
3422
4130
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
3423
4131
  }
3424
4132
  }
3425
- this.#checkTrust(peer.nickname, peer.publicKey);
4133
+ if (!this.#isOwnDevice(peer)) {
4134
+ this.#checkTrust(peer.nickname, peer.publicKey);
4135
+ }
3426
4136
  }
3427
4137
 
3428
4138
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room, additive: true });
@@ -3913,7 +4623,7 @@ export class ChatController {
3913
4623
 
3914
4624
  // Show own message locally
3915
4625
  if (replyTo) {
3916
- this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
4626
+ this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt);
3917
4627
  }
3918
4628
  const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
3919
4629
  const { lineIndex, render } = isAction