ciphermesh 2.10.0 → 2.12.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 +142 -0
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PROTOCOL.md +556 -0
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +2 -2
- package/src/client/ChatController.js +461 -60
- package/src/crypto/SenderKey.js +201 -10
- package/src/p2p/P2PChatController.js +2 -0
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +50 -4
- package/src/protocol/validators.js +73 -1
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +94 -4
- package/src/shared/constants.js +35 -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';
|
|
@@ -29,7 +30,15 @@ import {
|
|
|
29
30
|
isRoomWrapped,
|
|
30
31
|
freeRoomSecrets,
|
|
31
32
|
} from '../crypto/RoomKey.js';
|
|
32
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
KEY_ROTATION_INTERVAL_MS,
|
|
35
|
+
EMOJI_MAP,
|
|
36
|
+
COVER_CONSTANT_MS,
|
|
37
|
+
OWN_CAPABILITIES,
|
|
38
|
+
CAP,
|
|
39
|
+
} from '../shared/constants.js';
|
|
40
|
+
import { normalizeCaps, peerSupports, roomSupports } from '../protocol/capabilities.js';
|
|
41
|
+
import { GroupSession } from '../crypto/SenderKey.js';
|
|
33
42
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
34
43
|
import { Handshake } from '../crypto/Handshake.js';
|
|
35
44
|
import { NonceManager } from '../crypto/NonceManager.js';
|
|
@@ -76,7 +85,12 @@ export class ChatController {
|
|
|
76
85
|
#handshake;
|
|
77
86
|
#nonceManager;
|
|
78
87
|
#sessionId;
|
|
79
|
-
#peers; // Map<sessionId, { nickname, publicKey }>
|
|
88
|
+
#peers; // Map<sessionId, { nickname, publicKey, caps }>
|
|
89
|
+
#groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
|
|
90
|
+
#groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
|
|
91
|
+
#distributed = new Map(); // room -> Set<sessionId> holding our current chain
|
|
92
|
+
#serverCaps = []; // what the relay advertised in join_ack
|
|
93
|
+
#kickedSessions = new Set(); // sessionIds announced kicked, awaiting their peer_left
|
|
80
94
|
#lastTypingSent;
|
|
81
95
|
#peerTypingTimers; // Map<sessionId, timeoutId>
|
|
82
96
|
#fileTransfer;
|
|
@@ -220,7 +234,12 @@ export class ChatController {
|
|
|
220
234
|
this.#reconnectAttempts = 0;
|
|
221
235
|
this.#ui.setConnectionState('online');
|
|
222
236
|
this.#connection.send(
|
|
223
|
-
createJoin(
|
|
237
|
+
createJoin(
|
|
238
|
+
this.#nickname,
|
|
239
|
+
this.#keyManager.publicKeyB64,
|
|
240
|
+
this.#keyManager.pqPublicKeyB64,
|
|
241
|
+
OWN_CAPABILITIES,
|
|
242
|
+
),
|
|
224
243
|
);
|
|
225
244
|
}
|
|
226
245
|
|
|
@@ -454,6 +473,10 @@ export class ChatController {
|
|
|
454
473
|
this.#onEncryptedMessage(msg);
|
|
455
474
|
break;
|
|
456
475
|
|
|
476
|
+
case MSG.GROUP_MESSAGE:
|
|
477
|
+
this.#onGroupMessage(msg);
|
|
478
|
+
break;
|
|
479
|
+
|
|
457
480
|
case MSG.PEER_KEY_UPDATED:
|
|
458
481
|
this.#onPeerKeyUpdated(msg);
|
|
459
482
|
break;
|
|
@@ -542,6 +565,10 @@ export class ChatController {
|
|
|
542
565
|
// ── JOIN_ACK: registered with server ──────────────────────────
|
|
543
566
|
#onJoinAck(msg) {
|
|
544
567
|
this.#sessionId = msg.sessionId;
|
|
568
|
+
// What the relay itself can do. No client can advertise this on its behalf,
|
|
569
|
+
// and the fan-out for a room-addressed message is the relay's job — so a
|
|
570
|
+
// capable room on an older hub is still not a room that can switch paths.
|
|
571
|
+
this.#serverCaps = normalizeCaps(msg.serverCaps);
|
|
545
572
|
const room = msg.room || 'general';
|
|
546
573
|
const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
|
|
547
574
|
|
|
@@ -562,6 +589,7 @@ export class ChatController {
|
|
|
562
589
|
this.#allPeers.set(peer.sessionId, {
|
|
563
590
|
nickname: peer.nickname,
|
|
564
591
|
publicKey: peer.publicKey,
|
|
592
|
+
caps: normalizeCaps(peer.caps),
|
|
565
593
|
rooms: new Set([room]),
|
|
566
594
|
});
|
|
567
595
|
|
|
@@ -699,13 +727,33 @@ export class ChatController {
|
|
|
699
727
|
this.#peers.clear();
|
|
700
728
|
for (const [sid, p] of this.#allPeers) {
|
|
701
729
|
if (p.rooms.has(this.#currentRoom)) {
|
|
702
|
-
this.#peers.set(sid, {
|
|
730
|
+
this.#peers.set(sid, {
|
|
731
|
+
nickname: p.nickname,
|
|
732
|
+
publicKey: p.publicKey,
|
|
733
|
+
caps: p.caps || [],
|
|
734
|
+
});
|
|
703
735
|
}
|
|
704
736
|
}
|
|
705
737
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
706
738
|
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
707
739
|
}
|
|
708
740
|
|
|
741
|
+
// Can every member of the active room speak `cap`? Nothing calls this on a
|
|
742
|
+
// send path yet — group send/receive is the next step, and this is the switch
|
|
743
|
+
// it will be gated on. Kept here so the negotiation is testable before the
|
|
744
|
+
// feature that depends on it exists; `own` is overridable for exactly that,
|
|
745
|
+
// since this build advertises nothing yet.
|
|
746
|
+
roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
|
|
747
|
+
return roomSupports(this.#peers.values(), cap, own);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Did the relay say it can do this? Separate from the room check on purpose:
|
|
751
|
+
// both have to hold before a send path may switch, and they fail for
|
|
752
|
+
// different reasons — an old peer versus an old hub.
|
|
753
|
+
relaySupportsCapability(cap) {
|
|
754
|
+
return this.#serverCaps.includes(cap);
|
|
755
|
+
}
|
|
756
|
+
|
|
709
757
|
#applyTopicToUI() {
|
|
710
758
|
const topic = this.#buffers.get(this.#currentRoom)?.topic;
|
|
711
759
|
this.#ui.setTopic(topic?.text || null);
|
|
@@ -788,6 +836,7 @@ export class ChatController {
|
|
|
788
836
|
this.#allPeers.set(peer.sessionId, {
|
|
789
837
|
nickname: peer.nickname,
|
|
790
838
|
publicKey: peer.publicKey,
|
|
839
|
+
caps: normalizeCaps(peer.caps),
|
|
791
840
|
rooms: new Set([room]),
|
|
792
841
|
});
|
|
793
842
|
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
@@ -805,6 +854,17 @@ export class ChatController {
|
|
|
805
854
|
});
|
|
806
855
|
}
|
|
807
856
|
|
|
857
|
+
// A newcomer holds no chain of ours, so give them one before anything is
|
|
858
|
+
// sent on it. Ordered ahead of the presence and topic sends below because
|
|
859
|
+
// those may themselves go out on the group path.
|
|
860
|
+
//
|
|
861
|
+
// No rotation here. Someone arriving is not someone gaining access to the
|
|
862
|
+
// past: a serialised chain carries its *current* counter, so what they are
|
|
863
|
+
// handed opens what comes next and nothing before it.
|
|
864
|
+
if (room === this.#currentRoom) {
|
|
865
|
+
this.#distributeSenderKey(room, peer.sessionId);
|
|
866
|
+
}
|
|
867
|
+
|
|
808
868
|
// A newcomer doesn't know my presence — send only to them
|
|
809
869
|
if (this.#away || this.#statusText) {
|
|
810
870
|
this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
|
|
@@ -851,17 +911,47 @@ export class ChatController {
|
|
|
851
911
|
}
|
|
852
912
|
const goneEntirely = !entry || !room || entry.rooms.size === 0;
|
|
853
913
|
|
|
914
|
+
// Two separate things, and only the second is forward secrecy.
|
|
915
|
+
//
|
|
916
|
+
// Dropping *their* chain stops us holding a key we can no longer be sent
|
|
917
|
+
// anything on. Rotating *ours* is what stops them reading what the room says
|
|
918
|
+
// next — a chain ratchets forward, so the copy they were handed opens every
|
|
919
|
+
// message after it until we draw a new one.
|
|
920
|
+
//
|
|
921
|
+
// Which rooms rotate is decided by which ones actually lost a member, not by
|
|
922
|
+
// which ones we happen to hold a session for: every rotation costs a
|
|
923
|
+
// redistribution to everyone remaining.
|
|
924
|
+
const lostFrom = new Set();
|
|
925
|
+
if (room && this.#groups.get(room)?.removeMember(msg.sessionId)) {
|
|
926
|
+
lostFrom.add(room);
|
|
927
|
+
}
|
|
928
|
+
|
|
854
929
|
if (goneEntirely) {
|
|
855
930
|
this.#hidePeerTyping(msg.sessionId, nickname);
|
|
856
931
|
this.#handshake.removePeer(msg.sessionId);
|
|
857
932
|
this.#nonceManager.removePeer(msg.sessionId);
|
|
858
933
|
this.#allPeers.delete(msg.sessionId);
|
|
934
|
+
for (const [groupRoom, group] of this.#groups) {
|
|
935
|
+
if (group.removeMember(msg.sessionId)) {
|
|
936
|
+
lostFrom.add(groupRoom);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
for (const lost of lostFrom) {
|
|
942
|
+
this.#rotateGroupFor(lost);
|
|
859
943
|
}
|
|
860
944
|
|
|
945
|
+
// A kick already announced itself. Do every bit of the state work, and say
|
|
946
|
+
// nothing — "was kicked" followed by "left" describes one event twice.
|
|
947
|
+
const wasKicked = this.#kickedSessions.delete(msg.sessionId);
|
|
948
|
+
|
|
861
949
|
if (!room || room === this.#currentRoom) {
|
|
862
950
|
this.#rebuildActivePeers();
|
|
863
|
-
|
|
864
|
-
|
|
951
|
+
if (!wasKicked) {
|
|
952
|
+
this.#ui.handshakeDisconnect(nickname);
|
|
953
|
+
}
|
|
954
|
+
} else if (!wasKicked) {
|
|
865
955
|
this.#ui.toBuffer(room, () => {
|
|
866
956
|
this.#ui.addSystemMessage(`${nickname} left #${room}`);
|
|
867
957
|
});
|
|
@@ -869,8 +959,265 @@ export class ChatController {
|
|
|
869
959
|
this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
|
|
870
960
|
}
|
|
871
961
|
|
|
962
|
+
// ── Sender keys on the relay ──────────────────────────────────
|
|
963
|
+
//
|
|
964
|
+
// The receive half shipped a release ahead of this one, on purpose: a room
|
|
965
|
+
// switches to group sending only once every member advertises that it can
|
|
966
|
+
// read one, so the readers had to be in the field before the writers or the
|
|
967
|
+
// switch would never have become true for anybody.
|
|
968
|
+
//
|
|
969
|
+
// Three things have to hold before a line goes out once instead of N times,
|
|
970
|
+
// and they fail for different reasons:
|
|
971
|
+
//
|
|
972
|
+
// 1. every member of the room advertises `sk1` — an old peer
|
|
973
|
+
// 2. the relay advertises `sk1` — an old hub
|
|
974
|
+
// 3. the message is not deniable — see #canSendToGroup
|
|
975
|
+
//
|
|
976
|
+
// Any one of them false and the per-peer loop runs, unchanged.
|
|
977
|
+
|
|
978
|
+
#getGroup(room) {
|
|
979
|
+
let group = this.#groups.get(room);
|
|
980
|
+
if (!group) {
|
|
981
|
+
group = new GroupSession();
|
|
982
|
+
this.#groups.set(room, group);
|
|
983
|
+
}
|
|
984
|
+
return group;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// A member handed us their sender key. This arrives over the pairwise sealed
|
|
988
|
+
// channel, so `fromSessionId` was authenticated by opening the envelope —
|
|
989
|
+
// never asserted by the relay, which is the whole reason distribution does not
|
|
990
|
+
// ride on the group path itself.
|
|
991
|
+
#onSenderKeyDistribution(fromSessionId, data) {
|
|
992
|
+
if (typeof data.room !== 'string' || !data.dist || typeof data.dist !== 'object') {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
this.#getGroup(data.room).addMember(fromSessionId, data.dist);
|
|
996
|
+
this.#flushGroupBuffer(data.dist.keyId);
|
|
997
|
+
|
|
998
|
+
// Answer with ours if they do not have it. This is what makes distribution
|
|
999
|
+
// reliable without anything having to know who joined in which order:
|
|
1000
|
+
// whoever knows the other first speaks, and the reply cannot race, because
|
|
1001
|
+
// receiving this proves they already hold our public key.
|
|
1002
|
+
if (!this.#hasDistributedTo(data.room, fromSessionId)) {
|
|
1003
|
+
this.#distributeSenderKey(data.room, fromSessionId);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// A full room switch: every buffer is dropped and we exist only in `room`.
|
|
1008
|
+
// The chains follow. Carrying one across would mean a chain drawn for one
|
|
1009
|
+
// room's membership being used against another's, and a keyId that outlives
|
|
1010
|
+
// the set of people it was ever meant to label.
|
|
1011
|
+
#dropAllGroups() {
|
|
1012
|
+
for (const group of this.#groups.values()) {
|
|
1013
|
+
group.destroy();
|
|
1014
|
+
}
|
|
1015
|
+
this.#groups.clear();
|
|
1016
|
+
this.#groupBuffer.clear();
|
|
1017
|
+
this.#distributed.clear();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Hand our sender key for `room` to one peer, or to everyone in it.
|
|
1021
|
+
//
|
|
1022
|
+
// Always pairwise. The envelope is what proves who the key belongs to; a
|
|
1023
|
+
// distribution arriving on the group path would be a chain vouching for
|
|
1024
|
+
// itself, and the relay would be the only thing asserting whose it was.
|
|
1025
|
+
//
|
|
1026
|
+
// Never sent to a peer that may not know us yet. A client drops a ciphertext
|
|
1027
|
+
// from a session it has no public key for, and it learns ours from the
|
|
1028
|
+
// `peer_joined` the relay sends *after* our `join_ack` — so a newcomer
|
|
1029
|
+
// announcing itself into the room on arrival is talking to people who cannot
|
|
1030
|
+
// hear it. That is why nothing distributes on join: the peers who already
|
|
1031
|
+
// know us distribute to us (#onPeerJoined), and we answer (#onSenderKeyDistribution).
|
|
1032
|
+
#distributeSenderKey(room, toPeer = null) {
|
|
1033
|
+
const recipients = (toPeer ? [toPeer] : [...this.#peers.keys()]).filter((id) =>
|
|
1034
|
+
this.#worthDistributingTo(id),
|
|
1035
|
+
);
|
|
1036
|
+
if (recipients.length === 0) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Only now: distribution() is what draws the chain, and a chain is guarded
|
|
1041
|
+
// memory. See #worthDistributingTo.
|
|
1042
|
+
const payload = JSON.stringify({
|
|
1043
|
+
action: 'sk_dist',
|
|
1044
|
+
room,
|
|
1045
|
+
dist: this.#getGroup(room).distribution(),
|
|
1046
|
+
sentAt: Date.now(),
|
|
1047
|
+
});
|
|
1048
|
+
let sent = this.#distributed.get(room);
|
|
1049
|
+
if (!sent) {
|
|
1050
|
+
sent = new Set();
|
|
1051
|
+
this.#distributed.set(room, sent);
|
|
1052
|
+
}
|
|
1053
|
+
// Record before sending, never after.
|
|
1054
|
+
//
|
|
1055
|
+
// Sending re-enters this object. The peer receives the distribution, finds
|
|
1056
|
+
// it holds none of ours, and answers — and its answer can arrive before
|
|
1057
|
+
// #sendPayloadToPeer has returned. Marking afterwards means both sides
|
|
1058
|
+
// consult a record neither has written yet, each answers the other's
|
|
1059
|
+
// answer, and the exchange never converges.
|
|
1060
|
+
//
|
|
1061
|
+
// On a real socket that is a burst of duplicate distributions rather than a
|
|
1062
|
+
// hang, which is why it is worth stating: the bug is re-entrancy, and the
|
|
1063
|
+
// synchronous case is only the one that makes it obvious.
|
|
1064
|
+
for (const peerId of recipients) {
|
|
1065
|
+
sent.add(peerId);
|
|
1066
|
+
}
|
|
1067
|
+
for (const peerId of recipients) {
|
|
1068
|
+
this.#sendPayloadToPeer(peerId, payload);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// A sender key is only ever useful to a peer that can read a group message,
|
|
1073
|
+
// on a hub that can fan one out. Handing one to anybody else is a wasted
|
|
1074
|
+
// round trip — and, less obviously, a wasted allocation.
|
|
1075
|
+
//
|
|
1076
|
+
// Chains live in sodium_malloc'd memory, which is mlock'd. Linux caps how much
|
|
1077
|
+
// a process may lock (RLIMIT_MEMLOCK), and the cap is small; drawing a chain
|
|
1078
|
+
// per room per peer regardless of whether it could ever be used exhausted it,
|
|
1079
|
+
// and sodium_malloc then returns NULL. That surfaced as a SIGABRT in an
|
|
1080
|
+
// unrelated ratchet call — the first allocation to fail, not the one at fault.
|
|
1081
|
+
#worthDistributingTo(peerId) {
|
|
1082
|
+
if (!this.relaySupportsCapability(CAP.SENDER_KEYS)) {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
const peer = this.#peers.get(peerId);
|
|
1086
|
+
return peer ? peerSupports(peer, CAP.SENDER_KEYS) : false;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Has this peer been given our *current* chain for this room? Reset by
|
|
1090
|
+
// rotate(), because after one the answer is no for everybody.
|
|
1091
|
+
#hasDistributedTo(room, peerId) {
|
|
1092
|
+
return this.#distributed.get(room)?.has(peerId) ?? false;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Someone is no longer in `room`: draw a new chain and hand it to whoever is
|
|
1096
|
+
// left.
|
|
1097
|
+
//
|
|
1098
|
+
// This is the forward secrecy the design promises, and the reason the relay
|
|
1099
|
+
// now reports a kick as a departure (#482). Without it a removed member keeps
|
|
1100
|
+
// the chain they were given, and a chain ratchets *forward* — holding it at
|
|
1101
|
+
// counter N opens every counter after N. Being removed from a room would stop
|
|
1102
|
+
// the relay delivering to them and would not stop them reading.
|
|
1103
|
+
//
|
|
1104
|
+
// rotate() has no failure to check and no value to return. The distribution
|
|
1105
|
+
// that follows is the whole point, so the two must not drift apart: a rotation
|
|
1106
|
+
// whose redistribution never happens is a room that quietly stopped being able
|
|
1107
|
+
// to read this client, with nothing raised anywhere.
|
|
1108
|
+
#rotateGroupFor(room) {
|
|
1109
|
+
const group = this.#groups.get(room);
|
|
1110
|
+
if (!group) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
group.rotate();
|
|
1114
|
+
this.#distributed.delete(room); // a new chain: nobody has it
|
|
1115
|
+
if (room === this.#currentRoom) {
|
|
1116
|
+
this.#distributeSenderKey(room);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Can this payload go out once, addressed to the room, instead of N times?
|
|
1121
|
+
#canSendToGroup(deniable) {
|
|
1122
|
+
// Deniability is a property of the pairwise construction — a symmetric key
|
|
1123
|
+
// both sides could have derived, so neither can prove the other wrote it. A
|
|
1124
|
+
// group packet is signed by exactly one sender for exactly that reason, so
|
|
1125
|
+
// sending a deniable message on it would publish the opposite of what was
|
|
1126
|
+
// asked for.
|
|
1127
|
+
if (deniable) {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
if (this.#peers.size === 0) {
|
|
1131
|
+
return false;
|
|
1132
|
+
}
|
|
1133
|
+
return (
|
|
1134
|
+
this.roomSupportsCapability(CAP.SENDER_KEYS) && this.relaySupportsCapability(CAP.SENDER_KEYS)
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// One encryption, one frame, the whole room. The payload arrives already
|
|
1139
|
+
// room-tagged and already through the private-room layer when there is one —
|
|
1140
|
+
// both happen before the path splits, so a group message and a pairwise one
|
|
1141
|
+
// carry exactly the same bytes inside.
|
|
1142
|
+
#sendRoomGroup(room, payload) {
|
|
1143
|
+
// Nobody can read a packet on a chain they were never given. The exchange
|
|
1144
|
+
// above covers every ordinary path; this covers the rest, and costs one
|
|
1145
|
+
// Set lookup per member when there is nothing to do.
|
|
1146
|
+
for (const [peerId] of this.#peers) {
|
|
1147
|
+
if (!this.#hasDistributedTo(room, peerId)) {
|
|
1148
|
+
this.#distributeSenderKey(room, peerId);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
const packet = this.#getGroup(room).encrypt(payload);
|
|
1152
|
+
this.#connection.send(createGroupMessage(room, packet));
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
#onGroupMessage(msg) {
|
|
1156
|
+
const group = this.#groups.get(msg.room);
|
|
1157
|
+
const from = group ? group.memberForKeyId(msg.keyId) : null;
|
|
1158
|
+
|
|
1159
|
+
// A label we hold no distribution for. Usually a race — the fan-out beat the
|
|
1160
|
+
// sender key through a different path — so hold it rather than drop it.
|
|
1161
|
+
if (!from || !this.#allPeers.has(from)) {
|
|
1162
|
+
this.#bufferGroupMessage(msg);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const plaintext = group.decrypt(from, {
|
|
1167
|
+
keyId: msg.keyId,
|
|
1168
|
+
counter: msg.counter,
|
|
1169
|
+
ciphertext: msg.ciphertext,
|
|
1170
|
+
nonce: msg.nonce,
|
|
1171
|
+
signature: msg.signature,
|
|
1172
|
+
});
|
|
1173
|
+
if (!plaintext) {
|
|
1174
|
+
// A replayed counter, a chain that rotated without us being told yet, or a
|
|
1175
|
+
// signature that does not check out. The first two resolve on the next
|
|
1176
|
+
// distribution; the third never will, and buffering it costs one slot in a
|
|
1177
|
+
// bounded map rather than a decision made on too little information here.
|
|
1178
|
+
this.#bufferGroupMessage(msg);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Hand it to the one path that knows what a payload means. `payload` is
|
|
1183
|
+
// empty because there was no pairwise envelope: the group chain replaced it.
|
|
1184
|
+
this.#onEncryptedMessage({ from, payload: {} }, plaintext);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
#bufferGroupMessage(msg) {
|
|
1188
|
+
// Bounded twice over: a hostile relay can invent keyIds all day, and each
|
|
1189
|
+
// one must not become a place to park memory.
|
|
1190
|
+
if (!this.#groupBuffer.has(msg.keyId) && this.#groupBuffer.size >= 32) {
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
let buf = this.#groupBuffer.get(msg.keyId);
|
|
1194
|
+
if (!buf) {
|
|
1195
|
+
buf = [];
|
|
1196
|
+
this.#groupBuffer.set(msg.keyId, buf);
|
|
1197
|
+
}
|
|
1198
|
+
if (buf.length < 20) {
|
|
1199
|
+
buf.push(msg);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
#flushGroupBuffer(keyId) {
|
|
1204
|
+
const buf = this.#groupBuffer.get(keyId);
|
|
1205
|
+
if (!buf) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
this.#groupBuffer.delete(keyId);
|
|
1209
|
+
for (const msg of buf) {
|
|
1210
|
+
this.#onGroupMessage(msg);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
872
1214
|
// ── Received encrypted message ────────────────────────────────
|
|
873
|
-
|
|
1215
|
+
// `preDecrypted` is the group path handing over plaintext it already opened
|
|
1216
|
+
// with a sender chain (see #onGroupMessage). Everything after the decryption
|
|
1217
|
+
// step is shared on purpose: a group message must land in history, buffers,
|
|
1218
|
+
// receipts and the action dispatch exactly like a pairwise one, and the way to
|
|
1219
|
+
// guarantee that is for there to be only one copy of it.
|
|
1220
|
+
#onEncryptedMessage(msg, preDecrypted = null) {
|
|
874
1221
|
// Sealed sender: the relay handed us only `to` + an opaque blob. Open it
|
|
875
1222
|
// with our identity key to recover the real sender + payload; from here the
|
|
876
1223
|
// rest of the handler is unchanged. A blob that isn't for us (or is tampered)
|
|
@@ -904,51 +1251,74 @@ export class ChatController {
|
|
|
904
1251
|
return;
|
|
905
1252
|
}
|
|
906
1253
|
|
|
907
|
-
|
|
908
|
-
|
|
1254
|
+
let plaintext = preDecrypted;
|
|
1255
|
+
// A group message is never deniable — deniable sends stay pairwise, as they
|
|
1256
|
+
// do in P2P — and carries no pairwise envelope to inspect.
|
|
1257
|
+
const isDeniable = !preDecrypted && !!msg.payload.deniable;
|
|
909
1258
|
|
|
910
|
-
|
|
911
|
-
|
|
1259
|
+
if (!plaintext) {
|
|
1260
|
+
const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
|
|
1261
|
+
const nonce = Buffer.from(msg.payload.nonce, 'base64');
|
|
912
1262
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1263
|
+
// Deniable message path (symmetric crypto_secretbox)
|
|
1264
|
+
if (isDeniable) {
|
|
1265
|
+
// Anti-replay: deniable sends already use a structured NonceManager nonce.
|
|
1266
|
+
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
1267
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
|
|
1268
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
|
|
1272
|
+
plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
|
|
1273
|
+
if (!plaintext) {
|
|
1274
|
+
this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, {
|
|
1275
|
+
nickname: peer.nickname,
|
|
1276
|
+
deniable: true,
|
|
1277
|
+
});
|
|
1278
|
+
this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
927
1281
|
}
|
|
928
|
-
}
|
|
929
1282
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1283
|
+
// Ratcheted message path (has ephemeralPublicKey)
|
|
1284
|
+
if (!isDeniable && msg.payload.ephemeralPublicKey) {
|
|
1285
|
+
const ratchet = this.#handshake.getRatchet(msg.from);
|
|
1286
|
+
if (ratchet) {
|
|
1287
|
+
const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
|
|
1288
|
+
plaintext = ratchet.decrypt(
|
|
1289
|
+
ciphertext,
|
|
1290
|
+
nonce,
|
|
1291
|
+
ephPub,
|
|
1292
|
+
msg.payload.counter,
|
|
1293
|
+
msg.payload.previousCounter,
|
|
1294
|
+
msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
944
1297
|
|
|
945
|
-
|
|
946
|
-
|
|
1298
|
+
// Fallback to static decrypt if ratchet failed
|
|
1299
|
+
if (!plaintext) {
|
|
1300
|
+
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
1301
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
1302
|
+
this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
plaintext = MessageCrypto.decryptWithFallback(
|
|
1306
|
+
ciphertext,
|
|
1307
|
+
nonce,
|
|
1308
|
+
senderPublicKey,
|
|
1309
|
+
this.#handshake.secretKey,
|
|
1310
|
+
this.#handshake.getPreviousPeerPublicKey(msg.from),
|
|
1311
|
+
this.#handshake.previousSecretKey,
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
} else if (!isDeniable) {
|
|
1315
|
+
// Static message path (no ephemeralPublicKey)
|
|
947
1316
|
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
948
1317
|
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
949
|
-
this.#ui.addErrorMessage(`
|
|
1318
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
950
1319
|
return;
|
|
951
1320
|
}
|
|
1321
|
+
|
|
952
1322
|
plaintext = MessageCrypto.decryptWithFallback(
|
|
953
1323
|
ciphertext,
|
|
954
1324
|
nonce,
|
|
@@ -958,22 +1328,6 @@ export class ChatController {
|
|
|
958
1328
|
this.#handshake.previousSecretKey,
|
|
959
1329
|
);
|
|
960
1330
|
}
|
|
961
|
-
} else if (!isDeniable) {
|
|
962
|
-
// Static message path (no ephemeralPublicKey)
|
|
963
|
-
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
964
|
-
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
965
|
-
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
966
|
-
return;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
plaintext = MessageCrypto.decryptWithFallback(
|
|
970
|
-
ciphertext,
|
|
971
|
-
nonce,
|
|
972
|
-
senderPublicKey,
|
|
973
|
-
this.#handshake.secretKey,
|
|
974
|
-
this.#handshake.getPreviousPeerPublicKey(msg.from),
|
|
975
|
-
this.#handshake.previousSecretKey,
|
|
976
|
-
);
|
|
977
1331
|
}
|
|
978
1332
|
|
|
979
1333
|
if (!plaintext) {
|
|
@@ -1012,6 +1366,14 @@ export class ChatController {
|
|
|
1012
1366
|
return;
|
|
1013
1367
|
}
|
|
1014
1368
|
|
|
1369
|
+
// A sender key. Never surfaced to the user and never carried on the group
|
|
1370
|
+
// path itself — it has to arrive pairwise, where the envelope proves who
|
|
1371
|
+
// sent it.
|
|
1372
|
+
if (data.action === 'sk_dist') {
|
|
1373
|
+
this.#onSenderKeyDistribution(msg.from, data);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1015
1377
|
// Which buffer this belongs to (the tag rides inside the E2EE envelope).
|
|
1016
1378
|
const msgRoom = this.#roomForIncoming(data, msg.from);
|
|
1017
1379
|
const roomActive = msgRoom === this.#currentRoom;
|
|
@@ -2776,7 +3138,12 @@ export class ChatController {
|
|
|
2776
3138
|
this.#nickname = newNick;
|
|
2777
3139
|
this.#ui.setNickname(newNick);
|
|
2778
3140
|
this.#connection.send(
|
|
2779
|
-
createJoin(
|
|
3141
|
+
createJoin(
|
|
3142
|
+
newNick,
|
|
3143
|
+
this.#keyManager.publicKeyB64,
|
|
3144
|
+
this.#keyManager.pqPublicKeyB64,
|
|
3145
|
+
OWN_CAPABILITIES,
|
|
3146
|
+
),
|
|
2780
3147
|
);
|
|
2781
3148
|
this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
|
|
2782
3149
|
break;
|
|
@@ -3002,6 +3369,11 @@ export class ChatController {
|
|
|
3002
3369
|
this.#allPeers.set(peer.sessionId, {
|
|
3003
3370
|
nickname: peer.nickname,
|
|
3004
3371
|
publicKey: peer.publicKey,
|
|
3372
|
+
// The relay sends these here exactly as it does in join_ack. Dropping
|
|
3373
|
+
// them made every peer look incapable after a room switch, which is not
|
|
3374
|
+
// an error anywhere — it is a room that silently never turns the group
|
|
3375
|
+
// path on, because one absent capability is enough to hold all of it.
|
|
3376
|
+
caps: normalizeCaps(peer.caps),
|
|
3005
3377
|
rooms: new Set([msg.room]),
|
|
3006
3378
|
});
|
|
3007
3379
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
@@ -3011,6 +3383,12 @@ export class ChatController {
|
|
|
3011
3383
|
}
|
|
3012
3384
|
|
|
3013
3385
|
this.#rebuildActivePeers();
|
|
3386
|
+
// A switch drops every buffer, so it drops every chain with them. Carrying
|
|
3387
|
+
// one across would mean a chain drawn for one room's membership being used
|
|
3388
|
+
// against another's, and a keyId outliving the set of people it labelled.
|
|
3389
|
+
// The new room's chain is drawn on first use and distributed by the same
|
|
3390
|
+
// exchange as any other.
|
|
3391
|
+
this.#dropAllGroups();
|
|
3014
3392
|
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
|
|
3015
3393
|
this.#announceJoinedRoom(
|
|
3016
3394
|
msg.room,
|
|
@@ -3037,6 +3415,7 @@ export class ChatController {
|
|
|
3037
3415
|
this.#allPeers.set(peer.sessionId, {
|
|
3038
3416
|
nickname: peer.nickname,
|
|
3039
3417
|
publicKey: peer.publicKey,
|
|
3418
|
+
caps: normalizeCaps(peer.caps),
|
|
3040
3419
|
rooms: new Set([msg.room]),
|
|
3041
3420
|
});
|
|
3042
3421
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
@@ -3095,6 +3474,18 @@ export class ChatController {
|
|
|
3095
3474
|
|
|
3096
3475
|
// ── Handle PEER_KICKED ────────────────────────────────────
|
|
3097
3476
|
#onPeerKicked(msg) {
|
|
3477
|
+
// The relay sends this immediately before the peer_left for the same
|
|
3478
|
+
// session. Remember it so the departure is reported as a kick and not
|
|
3479
|
+
// announced twice; the state work still happens in #onPeerLeft, which is
|
|
3480
|
+
// the one place that knows how to unwind a member.
|
|
3481
|
+
if (typeof msg.sessionId === 'string') {
|
|
3482
|
+
// A kick whose peer_left never arrives (an older relay) must not park an
|
|
3483
|
+
// entry here forever.
|
|
3484
|
+
if (this.#kickedSessions.size >= 64) {
|
|
3485
|
+
this.#kickedSessions.clear();
|
|
3486
|
+
}
|
|
3487
|
+
this.#kickedSessions.add(msg.sessionId);
|
|
3488
|
+
}
|
|
3098
3489
|
if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
|
|
3099
3490
|
const reason = msg.reason ? ` (reason: ${msg.reason})` : '';
|
|
3100
3491
|
this.#ui.addErrorMessage(`You were kicked from the room${reason}`);
|
|
@@ -3390,6 +3781,15 @@ export class ChatController {
|
|
|
3390
3781
|
payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
|
|
3391
3782
|
}
|
|
3392
3783
|
|
|
3784
|
+
// One ciphertext for the room, when the room and the relay can both take
|
|
3785
|
+
// one. Everything above this line has already run, so the bytes inside are
|
|
3786
|
+
// identical either way — including cover traffic, which has to travel the
|
|
3787
|
+
// path real messages travel or it stops resembling them.
|
|
3788
|
+
if (this.#canSendToGroup(deniable)) {
|
|
3789
|
+
this.#sendRoomGroup(this.#currentRoom, payload);
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3393
3793
|
for (const [peerId] of this.#peers) {
|
|
3394
3794
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
|
|
3395
3795
|
if (!peerPublicKey) {
|
|
@@ -3642,6 +4042,7 @@ export class ChatController {
|
|
|
3642
4042
|
this.#historyStore.destroy();
|
|
3643
4043
|
}
|
|
3644
4044
|
this.#fileTransfer.destroy();
|
|
4045
|
+
this.#dropAllGroups();
|
|
3645
4046
|
this.#handshake.destroy();
|
|
3646
4047
|
this.#keyManager.destroy();
|
|
3647
4048
|
this.#connection.close();
|