ciphermesh 2.12.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.
@@ -38,6 +38,19 @@ import {
38
38
  CAP,
39
39
  } from '../shared/constants.js';
40
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';
41
54
  import { GroupSession } from '../crypto/SenderKey.js';
42
55
  import { KeyManager } from '../crypto/KeyManager.js';
43
56
  import { Handshake } from '../crypto/Handshake.js';
@@ -146,6 +159,17 @@ export class ChatController {
146
159
  #buffers = new Map(); // room → { unread, mentions, private, owner, secrets, pins }
147
160
  #bufferOrder = []; // Alt+1..9 order
148
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
149
173
  #pendingRoomSecrets = null; // derived while joining/creating, promoted on join
150
174
 
151
175
  constructor(
@@ -239,6 +263,11 @@ export class ChatController {
239
263
  this.#keyManager.publicKeyB64,
240
264
  this.#keyManager.pqPublicKeyB64,
241
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(),
242
271
  ),
243
272
  );
244
273
  }
@@ -546,7 +575,14 @@ export class ChatController {
546
575
  case TrustResult.TRUSTED:
547
576
  break;
548
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
+
549
584
  case TrustResult.MISMATCH:
585
+ this.#warnedKeys.add(`${nickname.toLowerCase()}:${publicKey}`);
550
586
  this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname });
551
587
  this.#ui.addErrorMessage(
552
588
  `WARNING: ${nickname}'s key changed! Possible MITM attack. Use /trust ${nickname} to accept or /verify ${nickname} to verify.`,
@@ -554,6 +590,7 @@ export class ChatController {
554
590
  break;
555
591
 
556
592
  case TrustResult.VERIFIED_MISMATCH:
593
+ this.#warnedKeys.add(`${nickname.toLowerCase()}:${publicKey}`);
557
594
  this.#auditLog.log(AuditEvent.TRUST_VERIFIED_MISMATCH, { nickname });
558
595
  this.#ui.addErrorMessage(
559
596
  `ALERT: ${nickname}'s VERIFIED key changed! This may indicate an attack. Use /verify ${nickname} to re-verify.`,
@@ -590,6 +627,7 @@ export class ChatController {
590
627
  nickname: peer.nickname,
591
628
  publicKey: peer.publicKey,
592
629
  caps: normalizeCaps(peer.caps),
630
+ identityKey: peer.identityKey ?? null,
593
631
  rooms: new Set([room]),
594
632
  });
595
633
 
@@ -601,7 +639,9 @@ export class ChatController {
601
639
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
602
640
  }
603
641
 
604
- this.#checkTrust(peer.nickname, peer.publicKey);
642
+ if (!this.#isOwnDevice(peer)) {
643
+ this.#checkTrust(peer.nickname, peer.publicKey);
644
+ }
605
645
  }
606
646
 
607
647
  // Initialize ratchets now that we have our session ID
@@ -731,18 +771,24 @@ export class ChatController {
731
771
  nickname: p.nickname,
732
772
  publicKey: p.publicKey,
733
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,
734
779
  });
735
780
  }
736
781
  }
737
- this.#ui.setOnlineCount(this.#peers.size + 1);
738
- 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));
739
786
  }
740
787
 
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.
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.
746
792
  roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
747
793
  return roomSupports(this.#peers.values(), cap, own);
748
794
  }
@@ -837,17 +883,26 @@ export class ChatController {
837
883
  nickname: peer.nickname,
838
884
  publicKey: peer.publicKey,
839
885
  caps: normalizeCaps(peer.caps),
886
+ identityKey: peer.identityKey ?? null,
840
887
  rooms: new Set([room]),
841
888
  });
842
889
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
843
890
  }
844
- 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
+ }
845
898
  this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
846
899
 
847
900
  if (room === this.#currentRoom) {
848
901
  this.#rebuildActivePeers();
849
902
  this.#ui.handshakeConnect(peer.nickname);
850
- this.#nudgeVerify(peer.nickname);
903
+ if (!ownDevice) {
904
+ this.#nudgeVerify(peer.nickname);
905
+ }
851
906
  } else {
852
907
  this.#ui.toBuffer(room, () => {
853
908
  this.#ui.addSystemMessage(`${peer.nickname} joined #${room}`);
@@ -864,6 +919,9 @@ export class ChatController {
864
919
  if (room === this.#currentRoom) {
865
920
  this.#distributeSenderKey(room, peer.sessionId);
866
921
  }
922
+ if (this.#wantsDeviceList(peer.sessionId)) {
923
+ this.#distributeDeviceList(peer.sessionId);
924
+ }
867
925
 
868
926
  // A newcomer doesn't know my presence — send only to them
869
927
  if (this.#away || this.#statusText) {
@@ -1117,6 +1175,426 @@ export class ChatController {
1117
1175
  }
1118
1176
  }
1119
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
+
1120
1598
  // Can this payload go out once, addressed to the room, instead of N times?
1121
1599
  #canSendToGroup(deniable) {
1122
1600
  // Deniability is a property of the pairwise construction — a symmetric key
@@ -1135,6 +1613,76 @@ export class ChatController {
1135
1613
  );
1136
1614
  }
1137
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
+
1138
1686
  // One encryption, one frame, the whole room. The payload arrives already
1139
1687
  // room-tagged and already through the private-room layer when there is one —
1140
1688
  // both happen before the path splits, so a group message and a pairwise one
@@ -1374,6 +1922,13 @@ export class ChatController {
1374
1922
  return;
1375
1923
  }
1376
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
+
1377
1932
  // Which buffer this belongs to (the tag rides inside the E2EE envelope).
1378
1933
  const msgRoom = this.#roomForIncoming(data, msg.from);
1379
1934
  const roomActive = msgRoom === this.#currentRoom;
@@ -1677,9 +2232,18 @@ export class ChatController {
1677
2232
  });
1678
2233
  }
1679
2234
 
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;
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;
1683
2247
  if (watchHit) {
1684
2248
  this.#ui.toBuffer(msgRoom, () => {
1685
2249
  this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${peer.nickname} in #${msgRoom}`);
@@ -1708,7 +2272,10 @@ export class ChatController {
1708
2272
  );
1709
2273
  }
1710
2274
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
1711
- 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);
1712
2279
  // File the message into its buffer (live log when active, stored otherwise).
1713
2280
  let lineIndex = -1;
1714
2281
  let renderInfo = null;
@@ -1717,9 +2284,9 @@ export class ChatController {
1717
2284
  this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
1718
2285
  }
1719
2286
  ({ lineIndex, render: renderInfo } = data.isAction
1720
- ? this.#ui.addActionMessage(peer.nickname, data.text)
2287
+ ? this.#ui.addActionMessage(displayName, data.text)
1721
2288
  : this.#ui.addMessage(
1722
- peer.nickname,
2289
+ displayName,
1723
2290
  data.text,
1724
2291
  !!data.isDM,
1725
2292
  ephLabel,
@@ -1835,8 +2402,11 @@ export class ChatController {
1835
2402
  this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
1836
2403
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
1837
2404
  this.#ui.addInfoMessage(' /rooms - List available rooms');
1838
- this.#ui.addInfoMessage(' /room - Show the current room');
2405
+ this.#ui.addInfoMessage(
2406
+ ' /room - Current room, how it is sending, and your buffers',
2407
+ );
1839
2408
  this.#ui.addInfoMessage(' /fingerprint - Show your fingerprint');
2409
+ this.#ui.addInfoMessage(' /device [sub] - Your devices under one identity');
1840
2410
  this.#ui.addInfoMessage(" /fingerprint <nick> - Another user's fingerprint");
1841
2411
  this.#ui.addInfoMessage(' /verify <nick> - Show SAS code for verification');
1842
2412
  this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm peer verification');
@@ -1890,7 +2460,8 @@ export class ChatController {
1890
2460
  break;
1891
2461
 
1892
2462
  case '/users': {
1893
- 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]) => {
1894
2465
  let label = p.nickname;
1895
2466
  const alias = this.#trustStore.getAlias(p.nickname);
1896
2467
  if (alias) {
@@ -1904,7 +2475,7 @@ export class ChatController {
1904
2475
  }
1905
2476
  return label;
1906
2477
  });
1907
- let me = `${this.#nickname} (you)`;
2478
+ let me = `${this.#nickname} (you${ownDevices > 0 ? `, on ${ownDevices + 1} devices` : ''})`;
1908
2479
  if (this.#away) {
1909
2480
  me += ` [away${this.#awayReason ? `: ${this.#awayReason}` : ''}]`;
1910
2481
  }
@@ -1921,6 +2492,10 @@ export class ChatController {
1921
2492
  const targetNick = parts[1];
1922
2493
  if (!targetNick) {
1923
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}`);
1924
2499
  this.#ui.addPlainLines(
1925
2500
  keyArt(Buffer.from(this.#keyManager.publicKeyB64, 'base64'), this.#nickname).split(
1926
2501
  '\n',
@@ -1933,6 +2508,12 @@ export class ChatController {
1933
2508
  if (found) {
1934
2509
  const fp = KeyManager.computeFingerprint(Buffer.from(found.publicKey, 'base64'));
1935
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
+ }
1936
2517
  this.#ui.addPlainLines(
1937
2518
  keyArt(Buffer.from(found.publicKey, 'base64'), found.nickname).split('\n'),
1938
2519
  );
@@ -1976,9 +2557,21 @@ export class ChatController {
1976
2557
  this.#ui.addErrorMessage(`User "${verifyNick}" not found`);
1977
2558
  break;
1978
2559
  }
1979
- 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);
1980
2564
  this.#auditLog.log(AuditEvent.SAS_VERIFY, { nickname: verifyPeer.nickname });
1981
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
+ );
1982
2575
  this.#ui.addPlainLines(
1983
2576
  keyArt(Buffer.from(verifyPeer.publicKey, 'base64'), verifyPeer.nickname).split('\n'),
1984
2577
  );
@@ -2201,6 +2794,7 @@ export class ChatController {
2201
2794
  this.#ui.addInfoMessage(
2202
2795
  `Current room: #${this.#currentRoom}${this.#activeSecrets ? ' 🔒 (private)' : ''}`,
2203
2796
  );
2797
+ this.#ui.addInfoMessage(`Sending: ${this.#describeSendPath()}`);
2204
2798
  if (this.#bufferOrder.length > 1) {
2205
2799
  const list = this.#bufferOrder
2206
2800
  .map((r, i) => {
@@ -2215,6 +2809,98 @@ export class ChatController {
2215
2809
  break;
2216
2810
  }
2217
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
+
2218
2904
  case '/tips': {
2219
2905
  this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
2220
2906
  this.#ui.addTip(tipAt(this.#tipIndex));
@@ -3143,6 +3829,8 @@ export class ChatController {
3143
3829
  this.#keyManager.publicKeyB64,
3144
3830
  this.#keyManager.pqPublicKeyB64,
3145
3831
  OWN_CAPABILITIES,
3832
+ this.#keyManager.identityPublicKeyB64,
3833
+ this.#ownDeviceListForDisplay(),
3146
3834
  ),
3147
3835
  );
3148
3836
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
@@ -3379,7 +4067,9 @@ export class ChatController {
3379
4067
  if (!this.#handshake.getRatchet(peer.sessionId)) {
3380
4068
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
3381
4069
  }
3382
- this.#checkTrust(peer.nickname, peer.publicKey);
4070
+ if (!this.#isOwnDevice(peer)) {
4071
+ this.#checkTrust(peer.nickname, peer.publicKey);
4072
+ }
3383
4073
  }
3384
4074
 
3385
4075
  this.#rebuildActivePeers();
@@ -3422,7 +4112,9 @@ export class ChatController {
3422
4112
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
3423
4113
  }
3424
4114
  }
3425
- this.#checkTrust(peer.nickname, peer.publicKey);
4115
+ if (!this.#isOwnDevice(peer)) {
4116
+ this.#checkTrust(peer.nickname, peer.publicKey);
4117
+ }
3426
4118
  }
3427
4119
 
3428
4120
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room, additive: true });