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.
- package/CHANGELOG.md +118 -0
- package/README.md +4 -3
- package/README.pt-BR.md +4 -3
- package/docs/ARCHITECTURE.md +12 -6
- package/docs/PROTOCOL.md +160 -5
- package/docs/commands.json +13 -5
- package/docs/design/multi-device.md +290 -0
- package/docs/design/sender-keys-on-relay.md +34 -3
- package/package.json +3 -3
- package/src/client/ChatController.js +714 -22
- package/src/crypto/DeviceIdentity.js +307 -0
- package/src/crypto/KeyManager.js +176 -4
- package/src/crypto/TrustStore.js +153 -0
- package/src/p2p/P2PChatController.js +73 -0
- package/src/protocol/messages.js +25 -1
- package/src/protocol/validators.js +16 -0
- package/src/server/SessionManager.js +63 -6
- package/src/server/WebSocketServer.js +40 -1
- package/src/shared/constants.js +9 -1
- package/src/shared/deviceProvisioning.js +112 -0
|
@@ -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.#
|
|
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
|
-
|
|
738
|
-
this.#
|
|
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`?
|
|
742
|
-
// send path
|
|
743
|
-
//
|
|
744
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1681
|
-
//
|
|
1682
|
-
|
|
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
|
|
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(
|
|
2287
|
+
? this.#ui.addActionMessage(displayName, data.text)
|
|
1721
2288
|
: this.#ui.addMessage(
|
|
1722
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.#
|
|
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.#
|
|
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 });
|