ciphermesh 2.10.0 → 2.11.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 +85 -0
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PROTOCOL.md +486 -0
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +1 -1
- package/src/client/ChatController.js +231 -58
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +2 -0
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- 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 +66 -1
- package/src/shared/constants.js +35 -0
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
isRoomWrapped,
|
|
30
30
|
freeRoomSecrets,
|
|
31
31
|
} from '../crypto/RoomKey.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
KEY_ROTATION_INTERVAL_MS,
|
|
34
|
+
EMOJI_MAP,
|
|
35
|
+
COVER_CONSTANT_MS,
|
|
36
|
+
OWN_CAPABILITIES,
|
|
37
|
+
} from '../shared/constants.js';
|
|
38
|
+
import { normalizeCaps, roomSupports } from '../protocol/capabilities.js';
|
|
39
|
+
import { GroupSession } from '../crypto/SenderKey.js';
|
|
33
40
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
34
41
|
import { Handshake } from '../crypto/Handshake.js';
|
|
35
42
|
import { NonceManager } from '../crypto/NonceManager.js';
|
|
@@ -76,7 +83,10 @@ export class ChatController {
|
|
|
76
83
|
#handshake;
|
|
77
84
|
#nonceManager;
|
|
78
85
|
#sessionId;
|
|
79
|
-
#peers; // Map<sessionId, { nickname, publicKey }>
|
|
86
|
+
#peers; // Map<sessionId, { nickname, publicKey, caps }>
|
|
87
|
+
#groups = new Map(); // room -> GroupSession (sender keys; receive only for now)
|
|
88
|
+
#groupBuffer = new Map(); // keyId -> group msgs waiting on their sender key
|
|
89
|
+
#serverCaps = []; // what the relay advertised in join_ack
|
|
80
90
|
#lastTypingSent;
|
|
81
91
|
#peerTypingTimers; // Map<sessionId, timeoutId>
|
|
82
92
|
#fileTransfer;
|
|
@@ -220,7 +230,12 @@ export class ChatController {
|
|
|
220
230
|
this.#reconnectAttempts = 0;
|
|
221
231
|
this.#ui.setConnectionState('online');
|
|
222
232
|
this.#connection.send(
|
|
223
|
-
createJoin(
|
|
233
|
+
createJoin(
|
|
234
|
+
this.#nickname,
|
|
235
|
+
this.#keyManager.publicKeyB64,
|
|
236
|
+
this.#keyManager.pqPublicKeyB64,
|
|
237
|
+
OWN_CAPABILITIES,
|
|
238
|
+
),
|
|
224
239
|
);
|
|
225
240
|
}
|
|
226
241
|
|
|
@@ -454,6 +469,10 @@ export class ChatController {
|
|
|
454
469
|
this.#onEncryptedMessage(msg);
|
|
455
470
|
break;
|
|
456
471
|
|
|
472
|
+
case MSG.GROUP_MESSAGE:
|
|
473
|
+
this.#onGroupMessage(msg);
|
|
474
|
+
break;
|
|
475
|
+
|
|
457
476
|
case MSG.PEER_KEY_UPDATED:
|
|
458
477
|
this.#onPeerKeyUpdated(msg);
|
|
459
478
|
break;
|
|
@@ -542,6 +561,10 @@ export class ChatController {
|
|
|
542
561
|
// ── JOIN_ACK: registered with server ──────────────────────────
|
|
543
562
|
#onJoinAck(msg) {
|
|
544
563
|
this.#sessionId = msg.sessionId;
|
|
564
|
+
// What the relay itself can do. No client can advertise this on its behalf,
|
|
565
|
+
// and the fan-out for a room-addressed message is the relay's job — so a
|
|
566
|
+
// capable room on an older hub is still not a room that can switch paths.
|
|
567
|
+
this.#serverCaps = normalizeCaps(msg.serverCaps);
|
|
545
568
|
const room = msg.room || 'general';
|
|
546
569
|
const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
|
|
547
570
|
|
|
@@ -562,6 +585,7 @@ export class ChatController {
|
|
|
562
585
|
this.#allPeers.set(peer.sessionId, {
|
|
563
586
|
nickname: peer.nickname,
|
|
564
587
|
publicKey: peer.publicKey,
|
|
588
|
+
caps: normalizeCaps(peer.caps),
|
|
565
589
|
rooms: new Set([room]),
|
|
566
590
|
});
|
|
567
591
|
|
|
@@ -699,13 +723,33 @@ export class ChatController {
|
|
|
699
723
|
this.#peers.clear();
|
|
700
724
|
for (const [sid, p] of this.#allPeers) {
|
|
701
725
|
if (p.rooms.has(this.#currentRoom)) {
|
|
702
|
-
this.#peers.set(sid, {
|
|
726
|
+
this.#peers.set(sid, {
|
|
727
|
+
nickname: p.nickname,
|
|
728
|
+
publicKey: p.publicKey,
|
|
729
|
+
caps: p.caps || [],
|
|
730
|
+
});
|
|
703
731
|
}
|
|
704
732
|
}
|
|
705
733
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
706
734
|
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
707
735
|
}
|
|
708
736
|
|
|
737
|
+
// Can every member of the active room speak `cap`? Nothing calls this on a
|
|
738
|
+
// send path yet — group send/receive is the next step, and this is the switch
|
|
739
|
+
// it will be gated on. Kept here so the negotiation is testable before the
|
|
740
|
+
// feature that depends on it exists; `own` is overridable for exactly that,
|
|
741
|
+
// since this build advertises nothing yet.
|
|
742
|
+
roomSupportsCapability(cap, own = OWN_CAPABILITIES) {
|
|
743
|
+
return roomSupports(this.#peers.values(), cap, own);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Did the relay say it can do this? Separate from the room check on purpose:
|
|
747
|
+
// both have to hold before a send path may switch, and they fail for
|
|
748
|
+
// different reasons — an old peer versus an old hub.
|
|
749
|
+
relaySupportsCapability(cap) {
|
|
750
|
+
return this.#serverCaps.includes(cap);
|
|
751
|
+
}
|
|
752
|
+
|
|
709
753
|
#applyTopicToUI() {
|
|
710
754
|
const topic = this.#buffers.get(this.#currentRoom)?.topic;
|
|
711
755
|
this.#ui.setTopic(topic?.text || null);
|
|
@@ -788,6 +832,7 @@ export class ChatController {
|
|
|
788
832
|
this.#allPeers.set(peer.sessionId, {
|
|
789
833
|
nickname: peer.nickname,
|
|
790
834
|
publicKey: peer.publicKey,
|
|
835
|
+
caps: normalizeCaps(peer.caps),
|
|
791
836
|
rooms: new Set([room]),
|
|
792
837
|
});
|
|
793
838
|
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
@@ -851,11 +896,21 @@ export class ChatController {
|
|
|
851
896
|
}
|
|
852
897
|
const goneEntirely = !entry || !room || entry.rooms.size === 0;
|
|
853
898
|
|
|
899
|
+
// Drop their sender chain for the room they left. Their own rotation is what
|
|
900
|
+
// gives forward secrecy — this only stops us holding a key we can no longer
|
|
901
|
+
// be sent anything on.
|
|
902
|
+
if (room) {
|
|
903
|
+
this.#groups.get(room)?.removeMember(msg.sessionId);
|
|
904
|
+
}
|
|
905
|
+
|
|
854
906
|
if (goneEntirely) {
|
|
855
907
|
this.#hidePeerTyping(msg.sessionId, nickname);
|
|
856
908
|
this.#handshake.removePeer(msg.sessionId);
|
|
857
909
|
this.#nonceManager.removePeer(msg.sessionId);
|
|
858
910
|
this.#allPeers.delete(msg.sessionId);
|
|
911
|
+
for (const group of this.#groups.values()) {
|
|
912
|
+
group.removeMember(msg.sessionId);
|
|
913
|
+
}
|
|
859
914
|
}
|
|
860
915
|
|
|
861
916
|
if (!room || room === this.#currentRoom) {
|
|
@@ -869,8 +924,101 @@ export class ChatController {
|
|
|
869
924
|
this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
|
|
870
925
|
}
|
|
871
926
|
|
|
927
|
+
// ── Sender keys on the relay: the receive half ────────────────
|
|
928
|
+
//
|
|
929
|
+
// Sending is deliberately absent — #broadcastPayload still seals one envelope
|
|
930
|
+
// per peer. This is the half that has to be in the field first: a room only
|
|
931
|
+
// switches to group sending once every member advertises it, so the ability to
|
|
932
|
+
// read one must ship a release before the ability to write one, or the switch
|
|
933
|
+
// never becomes true for anybody.
|
|
934
|
+
|
|
935
|
+
#getGroup(room) {
|
|
936
|
+
let group = this.#groups.get(room);
|
|
937
|
+
if (!group) {
|
|
938
|
+
group = new GroupSession();
|
|
939
|
+
this.#groups.set(room, group);
|
|
940
|
+
}
|
|
941
|
+
return group;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// A member handed us their sender key. This arrives over the pairwise sealed
|
|
945
|
+
// channel, so `fromSessionId` was authenticated by opening the envelope —
|
|
946
|
+
// never asserted by the relay, which is the whole reason distribution does not
|
|
947
|
+
// ride on the group path itself.
|
|
948
|
+
#onSenderKeyDistribution(fromSessionId, data) {
|
|
949
|
+
if (typeof data.room !== 'string' || !data.dist || typeof data.dist !== 'object') {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
this.#getGroup(data.room).addMember(fromSessionId, data.dist);
|
|
953
|
+
this.#flushGroupBuffer(data.dist.keyId);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
#onGroupMessage(msg) {
|
|
957
|
+
const group = this.#groups.get(msg.room);
|
|
958
|
+
const from = group ? group.memberForKeyId(msg.keyId) : null;
|
|
959
|
+
|
|
960
|
+
// A label we hold no distribution for. Usually a race — the fan-out beat the
|
|
961
|
+
// sender key through a different path — so hold it rather than drop it.
|
|
962
|
+
if (!from || !this.#allPeers.has(from)) {
|
|
963
|
+
this.#bufferGroupMessage(msg);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const plaintext = group.decrypt(from, {
|
|
968
|
+
keyId: msg.keyId,
|
|
969
|
+
counter: msg.counter,
|
|
970
|
+
ciphertext: msg.ciphertext,
|
|
971
|
+
nonce: msg.nonce,
|
|
972
|
+
signature: msg.signature,
|
|
973
|
+
});
|
|
974
|
+
if (!plaintext) {
|
|
975
|
+
// A replayed counter, a chain that rotated without us being told yet, or a
|
|
976
|
+
// signature that does not check out. The first two resolve on the next
|
|
977
|
+
// distribution; the third never will, and buffering it costs one slot in a
|
|
978
|
+
// bounded map rather than a decision made on too little information here.
|
|
979
|
+
this.#bufferGroupMessage(msg);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Hand it to the one path that knows what a payload means. `payload` is
|
|
984
|
+
// empty because there was no pairwise envelope: the group chain replaced it.
|
|
985
|
+
this.#onEncryptedMessage({ from, payload: {} }, plaintext);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
#bufferGroupMessage(msg) {
|
|
989
|
+
// Bounded twice over: a hostile relay can invent keyIds all day, and each
|
|
990
|
+
// one must not become a place to park memory.
|
|
991
|
+
if (!this.#groupBuffer.has(msg.keyId) && this.#groupBuffer.size >= 32) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
let buf = this.#groupBuffer.get(msg.keyId);
|
|
995
|
+
if (!buf) {
|
|
996
|
+
buf = [];
|
|
997
|
+
this.#groupBuffer.set(msg.keyId, buf);
|
|
998
|
+
}
|
|
999
|
+
if (buf.length < 20) {
|
|
1000
|
+
buf.push(msg);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
#flushGroupBuffer(keyId) {
|
|
1005
|
+
const buf = this.#groupBuffer.get(keyId);
|
|
1006
|
+
if (!buf) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
this.#groupBuffer.delete(keyId);
|
|
1010
|
+
for (const msg of buf) {
|
|
1011
|
+
this.#onGroupMessage(msg);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
872
1015
|
// ── Received encrypted message ────────────────────────────────
|
|
873
|
-
|
|
1016
|
+
// `preDecrypted` is the group path handing over plaintext it already opened
|
|
1017
|
+
// with a sender chain (see #onGroupMessage). Everything after the decryption
|
|
1018
|
+
// step is shared on purpose: a group message must land in history, buffers,
|
|
1019
|
+
// receipts and the action dispatch exactly like a pairwise one, and the way to
|
|
1020
|
+
// guarantee that is for there to be only one copy of it.
|
|
1021
|
+
#onEncryptedMessage(msg, preDecrypted = null) {
|
|
874
1022
|
// Sealed sender: the relay handed us only `to` + an opaque blob. Open it
|
|
875
1023
|
// with our identity key to recover the real sender + payload; from here the
|
|
876
1024
|
// rest of the handler is unchanged. A blob that isn't for us (or is tampered)
|
|
@@ -904,51 +1052,74 @@ export class ChatController {
|
|
|
904
1052
|
return;
|
|
905
1053
|
}
|
|
906
1054
|
|
|
907
|
-
|
|
908
|
-
|
|
1055
|
+
let plaintext = preDecrypted;
|
|
1056
|
+
// A group message is never deniable — deniable sends stay pairwise, as they
|
|
1057
|
+
// do in P2P — and carries no pairwise envelope to inspect.
|
|
1058
|
+
const isDeniable = !preDecrypted && !!msg.payload.deniable;
|
|
909
1059
|
|
|
910
|
-
|
|
911
|
-
|
|
1060
|
+
if (!plaintext) {
|
|
1061
|
+
const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
|
|
1062
|
+
const nonce = Buffer.from(msg.payload.nonce, 'base64');
|
|
912
1063
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1064
|
+
// Deniable message path (symmetric crypto_secretbox)
|
|
1065
|
+
if (isDeniable) {
|
|
1066
|
+
// Anti-replay: deniable sends already use a structured NonceManager nonce.
|
|
1067
|
+
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
1068
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
|
|
1069
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
|
|
1073
|
+
plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
|
|
1074
|
+
if (!plaintext) {
|
|
1075
|
+
this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, {
|
|
1076
|
+
nickname: peer.nickname,
|
|
1077
|
+
deniable: true,
|
|
1078
|
+
});
|
|
1079
|
+
this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
927
1082
|
}
|
|
928
|
-
}
|
|
929
1083
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1084
|
+
// Ratcheted message path (has ephemeralPublicKey)
|
|
1085
|
+
if (!isDeniable && msg.payload.ephemeralPublicKey) {
|
|
1086
|
+
const ratchet = this.#handshake.getRatchet(msg.from);
|
|
1087
|
+
if (ratchet) {
|
|
1088
|
+
const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
|
|
1089
|
+
plaintext = ratchet.decrypt(
|
|
1090
|
+
ciphertext,
|
|
1091
|
+
nonce,
|
|
1092
|
+
ephPub,
|
|
1093
|
+
msg.payload.counter,
|
|
1094
|
+
msg.payload.previousCounter,
|
|
1095
|
+
msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
944
1098
|
|
|
945
|
-
|
|
946
|
-
|
|
1099
|
+
// Fallback to static decrypt if ratchet failed
|
|
1100
|
+
if (!plaintext) {
|
|
1101
|
+
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
1102
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
1103
|
+
this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
plaintext = MessageCrypto.decryptWithFallback(
|
|
1107
|
+
ciphertext,
|
|
1108
|
+
nonce,
|
|
1109
|
+
senderPublicKey,
|
|
1110
|
+
this.#handshake.secretKey,
|
|
1111
|
+
this.#handshake.getPreviousPeerPublicKey(msg.from),
|
|
1112
|
+
this.#handshake.previousSecretKey,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
} else if (!isDeniable) {
|
|
1116
|
+
// Static message path (no ephemeralPublicKey)
|
|
947
1117
|
if (!this.#nonceManager.validate(msg.from, nonce)) {
|
|
948
1118
|
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
|
|
949
|
-
this.#ui.addErrorMessage(`
|
|
1119
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
|
|
950
1120
|
return;
|
|
951
1121
|
}
|
|
1122
|
+
|
|
952
1123
|
plaintext = MessageCrypto.decryptWithFallback(
|
|
953
1124
|
ciphertext,
|
|
954
1125
|
nonce,
|
|
@@ -958,22 +1129,6 @@ export class ChatController {
|
|
|
958
1129
|
this.#handshake.previousSecretKey,
|
|
959
1130
|
);
|
|
960
1131
|
}
|
|
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
1132
|
}
|
|
978
1133
|
|
|
979
1134
|
if (!plaintext) {
|
|
@@ -1012,6 +1167,14 @@ export class ChatController {
|
|
|
1012
1167
|
return;
|
|
1013
1168
|
}
|
|
1014
1169
|
|
|
1170
|
+
// A sender key. Never surfaced to the user and never carried on the group
|
|
1171
|
+
// path itself — it has to arrive pairwise, where the envelope proves who
|
|
1172
|
+
// sent it.
|
|
1173
|
+
if (data.action === 'sk_dist') {
|
|
1174
|
+
this.#onSenderKeyDistribution(msg.from, data);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1015
1178
|
// Which buffer this belongs to (the tag rides inside the E2EE envelope).
|
|
1016
1179
|
const msgRoom = this.#roomForIncoming(data, msg.from);
|
|
1017
1180
|
const roomActive = msgRoom === this.#currentRoom;
|
|
@@ -2776,7 +2939,12 @@ export class ChatController {
|
|
|
2776
2939
|
this.#nickname = newNick;
|
|
2777
2940
|
this.#ui.setNickname(newNick);
|
|
2778
2941
|
this.#connection.send(
|
|
2779
|
-
createJoin(
|
|
2942
|
+
createJoin(
|
|
2943
|
+
newNick,
|
|
2944
|
+
this.#keyManager.publicKeyB64,
|
|
2945
|
+
this.#keyManager.pqPublicKeyB64,
|
|
2946
|
+
OWN_CAPABILITIES,
|
|
2947
|
+
),
|
|
2780
2948
|
);
|
|
2781
2949
|
this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
|
|
2782
2950
|
break;
|
|
@@ -3642,6 +3810,11 @@ export class ChatController {
|
|
|
3642
3810
|
this.#historyStore.destroy();
|
|
3643
3811
|
}
|
|
3644
3812
|
this.#fileTransfer.destroy();
|
|
3813
|
+
for (const group of this.#groups.values()) {
|
|
3814
|
+
group.destroy();
|
|
3815
|
+
}
|
|
3816
|
+
this.#groups.clear();
|
|
3817
|
+
this.#groupBuffer.clear();
|
|
3645
3818
|
this.#handshake.destroy();
|
|
3646
3819
|
this.#keyManager.destroy();
|
|
3647
3820
|
this.#connection.close();
|
package/src/crypto/SenderKey.js
CHANGED
|
@@ -14,6 +14,62 @@ const KEY_SIZE = 32;
|
|
|
14
14
|
const MSG_KEY_TAG = Buffer.from([0x01]);
|
|
15
15
|
const CHAIN_KEY_TAG = Buffer.from([0x02]);
|
|
16
16
|
const DEFAULT_MAX_SKIP = 1000; // bound out-of-order / skipped message keys
|
|
17
|
+
const KEY_ID_SIZE = 16;
|
|
18
|
+
|
|
19
|
+
// An opaque label for a sender chain, handed out with the distribution.
|
|
20
|
+
//
|
|
21
|
+
// On the relay path a group message arrives by fan-out with no sender on it —
|
|
22
|
+
// the relay must not stamp one, or it would be asserting an identity that today
|
|
23
|
+
// is only ever carried sealed inside the envelope. So the packet names its
|
|
24
|
+
// *chain* instead of its sender, and only members who hold the distribution can
|
|
25
|
+
// map the two. To the relay it is a random string; it already knows which socket
|
|
26
|
+
// sent the frame, so this tells it nothing new. It is drawn fresh on every
|
|
27
|
+
// rotate(), so it never outlives the chain it labels.
|
|
28
|
+
function newKeyId() {
|
|
29
|
+
const buf = Buffer.alloc(KEY_ID_SIZE);
|
|
30
|
+
sodium.randombytes_buf(buf);
|
|
31
|
+
return buf.toString('base64');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Per-sender signatures ───────────────────────────────────────
|
|
35
|
+
// A sender key is symmetric: every member of the room holds the chain that
|
|
36
|
+
// decrypts a given sender, which means every member can also *produce*
|
|
37
|
+
// ciphertext on it. Without something asymmetric on top, "Alice said this" is
|
|
38
|
+
// only ever "somebody in this room said this" — and on a public relay, where
|
|
39
|
+
// `general` has no owner and no admission control, that is a much wider set of
|
|
40
|
+
// somebodies than in a P2P mesh.
|
|
41
|
+
//
|
|
42
|
+
// So each sender also holds an Ed25519 keypair for the life of its chain. The
|
|
43
|
+
// public half travels in the distribution, over the pairwise sealed channel that
|
|
44
|
+
// already authenticates who sent it; every packet carries a detached signature.
|
|
45
|
+
// Forging a member now needs their signing key, not just membership.
|
|
46
|
+
function newSigningKeypair() {
|
|
47
|
+
const publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
|
48
|
+
const secretKey = sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES);
|
|
49
|
+
sodium.crypto_sign_keypair(publicKey, secretKey);
|
|
50
|
+
return { publicKey, secretKey };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// What the signature covers. Every field the relay could tamper with or replay
|
|
54
|
+
// across chains: the label, the position in the chain, and the box itself.
|
|
55
|
+
// Length-prefixed so no two different packets can serialise to the same bytes.
|
|
56
|
+
function signedBytes({ keyId, counter, ciphertext, nonce }) {
|
|
57
|
+
const parts = [keyId, String(counter), ciphertext, nonce];
|
|
58
|
+
return Buffer.from(parts.map((p) => `${p.length}:${p}`).join('|'), 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function decodeSignPk(b64) {
|
|
62
|
+
if (typeof b64 !== 'string') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
let buf;
|
|
66
|
+
try {
|
|
67
|
+
buf = Buffer.from(b64, 'base64');
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return buf.length === sodium.crypto_sign_PUBLICKEYBYTES ? buf : null;
|
|
72
|
+
}
|
|
17
73
|
|
|
18
74
|
// A single sender's ratchet chain. Used to *send* (deriveNext) when it's your
|
|
19
75
|
// own chain, or to *receive* (messageKeyFor) when it's a peer's distributed one.
|
|
@@ -129,28 +185,51 @@ export function groupDecrypt(messageKey, ciphertext, nonce) {
|
|
|
129
185
|
// member. encrypt() runs once; every member decrypt()s the same ciphertext.
|
|
130
186
|
export class GroupSession {
|
|
131
187
|
#own;
|
|
188
|
+
#ownKeyId;
|
|
189
|
+
#signPk; // Ed25519 — proves *which member* wrote a packet
|
|
190
|
+
#signSk;
|
|
132
191
|
#members; // Map<memberId, SenderChain>
|
|
192
|
+
#byKeyId; // Map<keyId, memberId> — which chain opens an incoming packet
|
|
193
|
+
#memberSignPk; // Map<memberId, Buffer> — who is allowed to have written it
|
|
133
194
|
|
|
134
195
|
constructor() {
|
|
135
196
|
this.#own = new SenderChain();
|
|
197
|
+
this.#ownKeyId = newKeyId();
|
|
198
|
+
({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
|
|
136
199
|
this.#members = new Map();
|
|
200
|
+
this.#byKeyId = new Map();
|
|
201
|
+
this.#memberSignPk = new Map();
|
|
137
202
|
}
|
|
138
203
|
|
|
139
204
|
encrypt(plaintext) {
|
|
140
205
|
const { messageKey, counter } = this.#own.deriveNext();
|
|
141
206
|
const { ciphertext, nonce } = groupEncrypt(messageKey, plaintext);
|
|
142
|
-
|
|
207
|
+
const packet = {
|
|
208
|
+
keyId: this.#ownKeyId,
|
|
143
209
|
counter,
|
|
144
210
|
ciphertext: ciphertext.toString('base64'),
|
|
145
211
|
nonce: nonce.toString('base64'),
|
|
146
212
|
};
|
|
213
|
+
const signature = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
214
|
+
sodium.crypto_sign_detached(signature, signedBytes(packet), this.#signSk);
|
|
215
|
+
packet.signature = signature.toString('base64');
|
|
216
|
+
return packet;
|
|
147
217
|
}
|
|
148
218
|
|
|
149
|
-
decrypt(memberId, { counter, ciphertext, nonce }) {
|
|
219
|
+
decrypt(memberId, { keyId, counter, ciphertext, nonce, signature }) {
|
|
150
220
|
const chain = this.#members.get(memberId);
|
|
151
221
|
if (!chain) {
|
|
152
222
|
return null;
|
|
153
223
|
}
|
|
224
|
+
|
|
225
|
+
// Verify BEFORE touching the chain. Two reasons, and the second is the one
|
|
226
|
+
// that is easy to miss: a bad signature must not be able to advance the
|
|
227
|
+
// ratchet or fill the skipped-key cache, or an unauthenticated packet with a
|
|
228
|
+
// large counter becomes a way to make the receiver derive a thousand keys.
|
|
229
|
+
if (!this.#verify(memberId, { keyId, counter, ciphertext, nonce, signature })) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
154
233
|
const messageKey = chain.messageKeyFor(counter);
|
|
155
234
|
if (!messageKey) {
|
|
156
235
|
return null;
|
|
@@ -162,9 +241,51 @@ export class GroupSession {
|
|
|
162
241
|
);
|
|
163
242
|
}
|
|
164
243
|
|
|
244
|
+
#verify(memberId, packet) {
|
|
245
|
+
const signPk = this.#memberSignPk.get(memberId);
|
|
246
|
+
if (!signPk || typeof packet.signature !== 'string' || typeof packet.keyId !== 'string') {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
// The label on the packet has to be the one this member distributed.
|
|
250
|
+
// Verifying against the stored label instead would leave keyId outside
|
|
251
|
+
// everything that protects it — the AEAD does not cover it either — so
|
|
252
|
+
// decrypt() could be talked into checking one member's signature against
|
|
253
|
+
// another member's label.
|
|
254
|
+
if (this.#byKeyId.get(packet.keyId) !== memberId) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
let sig;
|
|
258
|
+
try {
|
|
259
|
+
sig = Buffer.from(packet.signature, 'base64');
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
if (sig.length !== sodium.crypto_sign_BYTES) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
return sodium.crypto_sign_verify_detached(sig, signedBytes(packet), signPk);
|
|
267
|
+
}
|
|
268
|
+
|
|
165
269
|
// The distribution message to hand a (new) member so they can decrypt you.
|
|
270
|
+
// `keyId` labels the chain; `signPk` is what stops any *other* member using
|
|
271
|
+
// that chain to write in your name.
|
|
166
272
|
distribution() {
|
|
167
|
-
return
|
|
273
|
+
return {
|
|
274
|
+
...this.#own.serialize(),
|
|
275
|
+
keyId: this.#ownKeyId,
|
|
276
|
+
signPk: this.#signPk.toString('base64'),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Which member does an incoming packet's keyId belong to? Null for a label we
|
|
281
|
+
// were never given a distribution for — an unknown sender, or one that
|
|
282
|
+
// rotated without telling us yet.
|
|
283
|
+
memberForKeyId(keyId) {
|
|
284
|
+
return this.#byKeyId.get(keyId) ?? null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
get keyId() {
|
|
288
|
+
return this.#ownKeyId;
|
|
168
289
|
}
|
|
169
290
|
|
|
170
291
|
addMember(memberId, distribution) {
|
|
@@ -172,7 +293,23 @@ export class GroupSession {
|
|
|
172
293
|
if (existing) {
|
|
173
294
|
existing.destroy();
|
|
174
295
|
}
|
|
296
|
+
// Drop any label this member held before — a redistribution after rotate()
|
|
297
|
+
// replaces the chain, and leaving the old keyId mapped would route the
|
|
298
|
+
// member's next packet to a chain that can no longer open it.
|
|
299
|
+
this.#forgetKeyIdsOf(memberId);
|
|
175
300
|
this.#members.set(memberId, SenderChain.deserialize(distribution));
|
|
301
|
+
if (typeof distribution?.keyId === 'string') {
|
|
302
|
+
this.#byKeyId.set(distribution.keyId, memberId);
|
|
303
|
+
}
|
|
304
|
+
// A distribution without a usable signing key leaves the member registered
|
|
305
|
+
// but unreadable: #verify fails closed, so nothing they send is accepted.
|
|
306
|
+
// Better a member who cannot be heard than one who cannot be attributed.
|
|
307
|
+
const signPk = decodeSignPk(distribution?.signPk);
|
|
308
|
+
if (signPk) {
|
|
309
|
+
this.#memberSignPk.set(memberId, signPk);
|
|
310
|
+
} else {
|
|
311
|
+
this.#memberSignPk.delete(memberId);
|
|
312
|
+
}
|
|
176
313
|
}
|
|
177
314
|
|
|
178
315
|
removeMember(memberId) {
|
|
@@ -181,6 +318,16 @@ export class GroupSession {
|
|
|
181
318
|
chain.destroy();
|
|
182
319
|
this.#members.delete(memberId);
|
|
183
320
|
}
|
|
321
|
+
this.#forgetKeyIdsOf(memberId);
|
|
322
|
+
this.#memberSignPk.delete(memberId);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#forgetKeyIdsOf(memberId) {
|
|
326
|
+
for (const [id, owner] of this.#byKeyId) {
|
|
327
|
+
if (owner === memberId) {
|
|
328
|
+
this.#byKeyId.delete(id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
184
331
|
}
|
|
185
332
|
|
|
186
333
|
hasMember(memberId) {
|
|
@@ -192,13 +339,22 @@ export class GroupSession {
|
|
|
192
339
|
rotate() {
|
|
193
340
|
this.#own.destroy();
|
|
194
341
|
this.#own = new SenderChain();
|
|
342
|
+
this.#ownKeyId = newKeyId();
|
|
343
|
+
// The signing key rotates with the chain it authenticates. Keeping it would
|
|
344
|
+
// let anyone holding the old public key keep attributing new packets to a
|
|
345
|
+
// chain that was rotated precisely because the room membership changed.
|
|
346
|
+
sodium.sodium_memzero(this.#signSk);
|
|
347
|
+
({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
|
|
195
348
|
}
|
|
196
349
|
|
|
197
350
|
destroy() {
|
|
198
351
|
this.#own.destroy();
|
|
352
|
+
sodium.sodium_memzero(this.#signSk);
|
|
199
353
|
for (const chain of this.#members.values()) {
|
|
200
354
|
chain.destroy();
|
|
201
355
|
}
|
|
202
356
|
this.#members.clear();
|
|
357
|
+
this.#byKeyId.clear();
|
|
358
|
+
this.#memberSignPk.clear();
|
|
203
359
|
}
|
|
204
360
|
}
|
|
@@ -2392,9 +2392,11 @@ export class P2PChatController {
|
|
|
2392
2392
|
return;
|
|
2393
2393
|
}
|
|
2394
2394
|
const plaintext = this.#getGroup(msg.room).decrypt(fromNickname, {
|
|
2395
|
+
keyId: msg.keyId,
|
|
2395
2396
|
counter: msg.counter,
|
|
2396
2397
|
ciphertext: msg.ciphertext,
|
|
2397
2398
|
nonce: msg.nonce,
|
|
2399
|
+
signature: msg.signature,
|
|
2398
2400
|
});
|
|
2399
2401
|
if (!plaintext) {
|
|
2400
2402
|
// No sender key yet (rare race) — buffer until sk_dist arrives.
|