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.
- package/CHANGELOG.md +187 -0
- package/README.md +8 -6
- package/README.pt-BR.md +8 -6
- package/docs/ARCHITECTURE.md +332 -230
- package/docs/PROTOCOL.md +160 -5
- package/docs/SETUP.md +18 -0
- package/docs/commands.json +15 -7
- 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 +736 -26
- package/src/client/UI.js +617 -124
- package/src/client/keyboard.js +388 -0
- 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 +94 -4
- 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/desktopNotify.js +204 -0
- package/src/shared/deviceProvisioning.js +112 -0
- package/src/shared/notifyWorker.js +46 -0
- package/src/shared/tips.js +1 -0
|
@@ -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.#
|
|
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
|
-
|
|
738
|
-
this.#
|
|
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`?
|
|
742
|
-
// send path
|
|
743
|
-
//
|
|
744
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1681
|
-
//
|
|
1682
|
-
|
|
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
|
|
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(
|
|
2291
|
+
? this.#ui.addActionMessage(displayName, data.text)
|
|
1721
2292
|
: this.#ui.addMessage(
|
|
1722
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.#
|
|
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.#
|
|
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
|
|
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
|