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