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
package/src/crypto/TrustStore.js
CHANGED
|
@@ -9,6 +9,9 @@ const TRUST_FILE = 'trusted-peers.json';
|
|
|
9
9
|
export const TrustResult = {
|
|
10
10
|
NEW_PEER: 'new_peer',
|
|
11
11
|
TRUSTED: 'trusted',
|
|
12
|
+
// A key that is not the one on the record, but that the identity bound to
|
|
13
|
+
// this record has signed as another of its devices. Not a mismatch.
|
|
14
|
+
KNOWN_DEVICE: 'known_device',
|
|
12
15
|
MISMATCH: 'mismatch',
|
|
13
16
|
VERIFIED_MISMATCH: 'verified_mismatch',
|
|
14
17
|
};
|
|
@@ -75,6 +78,18 @@ export class TrustStore {
|
|
|
75
78
|
|
|
76
79
|
// Compare the FULL public key (256 bits), not the 64-bit fingerprint.
|
|
77
80
|
// Fall back to the fingerprint only for legacy records without publicKey.
|
|
81
|
+
// Once a synced device list exists, it is the authority on which keys are
|
|
82
|
+
// this person — including about the key the record was built on. A revoked
|
|
83
|
+
// primary that stayed TRUSTED here would make revocation decorative.
|
|
84
|
+
if (Array.isArray(record.devices)) {
|
|
85
|
+
if (record.devices.includes(publicKeyB64)) {
|
|
86
|
+
record.lastSeen = Date.now();
|
|
87
|
+
this.#save();
|
|
88
|
+
return record.publicKey === publicKeyB64 ? TrustResult.TRUSTED : TrustResult.KNOWN_DEVICE;
|
|
89
|
+
}
|
|
90
|
+
return record.verified ? TrustResult.VERIFIED_MISMATCH : TrustResult.MISMATCH;
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
const matches = record.publicKey
|
|
79
94
|
? record.publicKey === publicKeyB64
|
|
80
95
|
: record.fingerprint === KeyManager.computeFingerprint(Buffer.from(publicKeyB64, 'base64'));
|
|
@@ -85,6 +100,18 @@ export class TrustStore {
|
|
|
85
100
|
return TrustResult.TRUSTED;
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
// Another device of the same person is not a key that changed.
|
|
104
|
+
//
|
|
105
|
+
// Every key here was put there by a device list signed by the identity
|
|
106
|
+
// bound to this record — see syncDevices, which is the only writer. So this
|
|
107
|
+
// is not "a second key we have seen before", it is "a key the identity you
|
|
108
|
+
// verified says is also them".
|
|
109
|
+
if (record.devices?.includes(publicKeyB64)) {
|
|
110
|
+
record.lastSeen = Date.now();
|
|
111
|
+
this.#save();
|
|
112
|
+
return TrustResult.KNOWN_DEVICE;
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
if (record.verified) {
|
|
89
116
|
return TrustResult.VERIFIED_MISMATCH;
|
|
90
117
|
}
|
|
@@ -173,6 +200,132 @@ export class TrustStore {
|
|
|
173
200
|
return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
|
|
174
201
|
}
|
|
175
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The same construction over Ed25519 identity keys instead of box keys.
|
|
205
|
+
*
|
|
206
|
+
* A separate domain tag, not the same one with different inputs: two
|
|
207
|
+
* protocols that can produce the same digits from different material are two
|
|
208
|
+
* protocols one of them can be tricked into accepting. Same width, because
|
|
209
|
+
* the reason 40 bits replaced 20 has not changed.
|
|
210
|
+
*
|
|
211
|
+
* Only ever used when *both* sides will use it — see
|
|
212
|
+
* ChatController#identitySasReady. A pair where one side compares identity
|
|
213
|
+
* keys and the other compares box keys would show two different codes to two
|
|
214
|
+
* people who are doing everything right.
|
|
215
|
+
*/
|
|
216
|
+
static computeIdentitySAS(myIdentityPk, peerIdentityPk) {
|
|
217
|
+
const mine = Buffer.isBuffer(myIdentityPk) ? myIdentityPk : Buffer.from(myIdentityPk, 'base64');
|
|
218
|
+
const theirs = Buffer.isBuffer(peerIdentityPk)
|
|
219
|
+
? peerIdentityPk
|
|
220
|
+
: Buffer.from(peerIdentityPk, 'base64');
|
|
221
|
+
|
|
222
|
+
const [first, second] = Buffer.compare(mine, theirs) <= 0 ? [mine, theirs] : [theirs, mine];
|
|
223
|
+
const context = Buffer.from('CipherMesh-IdentitySAS-v1');
|
|
224
|
+
const hash = Buffer.alloc(32);
|
|
225
|
+
sodium.crypto_generichash(hash, Buffer.concat([first, second, context]));
|
|
226
|
+
|
|
227
|
+
let num = 0n;
|
|
228
|
+
for (let i = 0; i < 5; i++) {
|
|
229
|
+
num = (num << 8n) | BigInt(hash[i]);
|
|
230
|
+
}
|
|
231
|
+
const digits = num.toString().padStart(13, '0');
|
|
232
|
+
return `${digits.slice(0, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Bind an identity key to a peer's record, keeping whatever verification the
|
|
237
|
+
* record already carries.
|
|
238
|
+
*
|
|
239
|
+
* This is the migration, and it is only ever called when the binding is
|
|
240
|
+
* provable: the identity signed a device list naming the box key this record
|
|
241
|
+
* was built on, and that list arrived over a channel only the holder of that
|
|
242
|
+
* box key could have written to. So the identity is vouched for by exactly
|
|
243
|
+
* the thing the user already verified out of band — the same reasoning
|
|
244
|
+
* `autoUpdatePeer` uses to carry verification across a key rotation.
|
|
245
|
+
*
|
|
246
|
+
* Nothing here can *create* verification. A record that was never verified is
|
|
247
|
+
* bound and stays unverified.
|
|
248
|
+
*
|
|
249
|
+
* @returns {'bound'|'unchanged'|'conflict'|'unknown'}
|
|
250
|
+
*/
|
|
251
|
+
bindIdentity(nickname, identityPkB64) {
|
|
252
|
+
const record = this.#store.get(nickname.toLowerCase());
|
|
253
|
+
if (!record) {
|
|
254
|
+
return 'unknown';
|
|
255
|
+
}
|
|
256
|
+
if (record.identityPk === identityPkB64) {
|
|
257
|
+
return 'unchanged';
|
|
258
|
+
}
|
|
259
|
+
// A second identity claiming a record that already has one. Never silently
|
|
260
|
+
// overwritten: for a verified record this is the loud case, and it is the
|
|
261
|
+
// shape a takeover would have.
|
|
262
|
+
if (record.identityPk) {
|
|
263
|
+
return 'conflict';
|
|
264
|
+
}
|
|
265
|
+
record.identityPk = identityPkB64;
|
|
266
|
+
record.lastSeen = Date.now();
|
|
267
|
+
this.#save();
|
|
268
|
+
return 'bound';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The identity bound to a peer, or null. */
|
|
272
|
+
identityFor(nickname) {
|
|
273
|
+
return this.#store.get(nickname.toLowerCase())?.identityPk ?? null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Set the device keys an identity has signed for to exactly this set.
|
|
278
|
+
*
|
|
279
|
+
* A replacement, not an accumulation, and that is the whole of revocation on
|
|
280
|
+
* the receiving side: a device that has been removed from the list stops
|
|
281
|
+
* being one of this person's keys here too. Accumulating would leave every
|
|
282
|
+
* revoked key trusted forever, which is worse than not having revocation at
|
|
283
|
+
* all, because it would look like it worked.
|
|
284
|
+
*
|
|
285
|
+
* The only writer of `record.devices`, and it refuses unless the identity
|
|
286
|
+
* asking is the one already bound to the record. Without that check any
|
|
287
|
+
* signed list could write keys onto anybody's record, which is the attack
|
|
288
|
+
* this prevents rather than enables.
|
|
289
|
+
*
|
|
290
|
+
* The record's primary `publicKey` is left alone even when the list no longer
|
|
291
|
+
* names it: it is the historical fact of what the user compared digits over.
|
|
292
|
+
* `checkPeer` stops honouring it, which is the part that matters.
|
|
293
|
+
*
|
|
294
|
+
* @returns {{added: string[], removed: string[]}}
|
|
295
|
+
*/
|
|
296
|
+
syncDevices(nickname, identityPkB64, boxKeys) {
|
|
297
|
+
const record = this.#store.get(nickname.toLowerCase());
|
|
298
|
+
if (!record || !record.identityPk || record.identityPk !== identityPkB64) {
|
|
299
|
+
return { added: [], removed: [] };
|
|
300
|
+
}
|
|
301
|
+
const before = new Set(record.devices ?? []);
|
|
302
|
+
const after = new Set(boxKeys);
|
|
303
|
+
|
|
304
|
+
const added = [...after].filter((key) => !before.has(key));
|
|
305
|
+
const removed = [...before].filter((key) => !after.has(key));
|
|
306
|
+
|
|
307
|
+
if (added.length > 0 || removed.length > 0 || !record.devices) {
|
|
308
|
+
record.devices = [...after];
|
|
309
|
+
record.lastSeen = Date.now();
|
|
310
|
+
this.#save();
|
|
311
|
+
}
|
|
312
|
+
return { added, removed };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Every box key this peer is currently known by.
|
|
317
|
+
*
|
|
318
|
+
* Once a list has been synced it is the answer on its own; before that, the
|
|
319
|
+
* verified key is all there is.
|
|
320
|
+
*/
|
|
321
|
+
devicesFor(nickname) {
|
|
322
|
+
const record = this.#store.get(nickname.toLowerCase());
|
|
323
|
+
if (!record) {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
return record.devices ? [...record.devices] : [record.publicKey].filter(Boolean);
|
|
327
|
+
}
|
|
328
|
+
|
|
176
329
|
markVerified(nickname) {
|
|
177
330
|
const key = nickname.toLowerCase();
|
|
178
331
|
const record = this.#store.get(key);
|
|
@@ -47,6 +47,25 @@ import { COMMANDS } from '../client/UI.js';
|
|
|
47
47
|
const TYPING_SEND_INTERVAL = 2000;
|
|
48
48
|
const TYPING_EXPIRE_TIMEOUT = 3000;
|
|
49
49
|
|
|
50
|
+
// Commands that exist only on the relay side, and why — so the mesh can say so
|
|
51
|
+
// instead of guessing at a typo.
|
|
52
|
+
//
|
|
53
|
+
// `/device` is the interesting one. Multi-device is not merely unimplemented
|
|
54
|
+
// here: a mesh peer is keyed by *nickname* (`#peers` above is
|
|
55
|
+
// `Map<peerNickname, …>`) and has no session id, so two devices of one person
|
|
56
|
+
// collide on the very thing the peer map is indexed by. Supporting them means
|
|
57
|
+
// re-keying the mesh's whole model of who a peer is, which is a different
|
|
58
|
+
// design from the relay's and deliberately out of scope — see
|
|
59
|
+
// docs/design/multi-device.md.
|
|
60
|
+
const RELAY_ONLY = {
|
|
61
|
+
'/device':
|
|
62
|
+
'In the mesh a peer is known by nickname, which is exactly what two of ' +
|
|
63
|
+
'your devices would share, so it is a different design and not built yet.',
|
|
64
|
+
'/create': 'Rooms with an owner exist only where there is a relay to hold one.',
|
|
65
|
+
'/invite': 'There is no address to invite anyone to without a relay.',
|
|
66
|
+
'/nick': 'Your name here is the one you started with; there is no registry to change it in.',
|
|
67
|
+
};
|
|
68
|
+
|
|
50
69
|
export class P2PChatController {
|
|
51
70
|
#nickname;
|
|
52
71
|
#connManager;
|
|
@@ -1996,6 +2015,7 @@ export class P2PChatController {
|
|
|
1996
2015
|
|
|
1997
2016
|
case '/room':
|
|
1998
2017
|
this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
|
|
2018
|
+
this.#ui.addInfoMessage(`Sending: ${this.#describeSendPath()}`);
|
|
1999
2019
|
break;
|
|
2000
2020
|
|
|
2001
2021
|
case '/tips': {
|
|
@@ -2108,6 +2128,14 @@ export class P2PChatController {
|
|
|
2108
2128
|
break;
|
|
2109
2129
|
}
|
|
2110
2130
|
}
|
|
2131
|
+
// A relay-only command is not a typo, and guessing at one is worse than
|
|
2132
|
+
// saying nothing: `/device` currently suggests `/voice`, which sends
|
|
2133
|
+
// somebody looking in the wrong place entirely.
|
|
2134
|
+
const relayOnly = RELAY_ONLY[cmd];
|
|
2135
|
+
if (relayOnly) {
|
|
2136
|
+
this.#ui.addErrorMessage(`${cmd} needs a relay. ${relayOnly}`);
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2111
2139
|
const suggestion = suggestCommand(cmd, COMMANDS);
|
|
2112
2140
|
const hint = suggestion ? ` Did you mean ${suggestion}?` : ' Use /help';
|
|
2113
2141
|
this.#ui.addErrorMessage(`Unknown command: ${cmd}.${hint}`);
|
|
@@ -2375,6 +2403,51 @@ export class P2PChatController {
|
|
|
2375
2403
|
this.#broadcastPayload(payload, false, toPeer, room);
|
|
2376
2404
|
}
|
|
2377
2405
|
|
|
2406
|
+
// Which path the next line you type will take, and what is holding it there.
|
|
2407
|
+
//
|
|
2408
|
+
// The mesh half of #481. The saving here is not frames — there is no relay to
|
|
2409
|
+
// fan anything out, so a mesh sends one frame per peer either way — it is
|
|
2410
|
+
// encryptions: one for the room, or one per peer. That is the cost the room
|
|
2411
|
+
// silently pays when something has pushed it back onto the pairwise path, and
|
|
2412
|
+
// until now nothing said so.
|
|
2413
|
+
//
|
|
2414
|
+
// The order matches the decision in #sendMessage, which is the only place
|
|
2415
|
+
// that chooses. Two of the reasons are relay-side concerns that do not exist
|
|
2416
|
+
// here (no hub to be older, no capability negotiation); one is a reason the
|
|
2417
|
+
// relay does not have — constant cover paces messages through pairwise slots
|
|
2418
|
+
// on purpose, because the timing guarantee is what it is for.
|
|
2419
|
+
groupSendStatus() {
|
|
2420
|
+
const inRoom = [...this.#peers.keys()].filter(
|
|
2421
|
+
(nick) => (this.#peerRooms.get(nick) || 'general') === this.#currentRoom,
|
|
2422
|
+
);
|
|
2423
|
+
if (this.#deniableMode) {
|
|
2424
|
+
return { group: false, reason: 'deniable', peers: inRoom.length };
|
|
2425
|
+
}
|
|
2426
|
+
if (this.#coverMode === 'constant') {
|
|
2427
|
+
return { group: false, reason: 'cover', peers: inRoom.length };
|
|
2428
|
+
}
|
|
2429
|
+
if (inRoom.length === 0) {
|
|
2430
|
+
return { group: false, reason: 'alone', peers: 0 };
|
|
2431
|
+
}
|
|
2432
|
+
return { group: true, reason: null, peers: inRoom.length };
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
#describeSendPath() {
|
|
2436
|
+
const status = this.groupSendStatus();
|
|
2437
|
+
if (status.group) {
|
|
2438
|
+
return `one encryption for the room, sent to ${status.peers}`;
|
|
2439
|
+
}
|
|
2440
|
+
const cost = `${status.peers} encryption${status.peers === 1 ? '' : 's'} per message`;
|
|
2441
|
+
switch (status.reason) {
|
|
2442
|
+
case 'deniable':
|
|
2443
|
+
return `${cost} — deniable mode is on, and deniability is pairwise`;
|
|
2444
|
+
case 'cover':
|
|
2445
|
+
return `${cost} — constant cover paces messages through pairwise slots`;
|
|
2446
|
+
default:
|
|
2447
|
+
return 'nothing yet — no one else is in this room';
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2378
2451
|
// Encrypt a room message ONCE and send the same ciphertext to every online
|
|
2379
2452
|
// room peer — real group cryptography (O(1) encryption instead of O(N)).
|
|
2380
2453
|
#sendRoomGroup(room, payload) {
|
package/src/protocol/messages.js
CHANGED
|
@@ -50,7 +50,22 @@ function base(type) {
|
|
|
50
50
|
// post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
|
|
51
51
|
// caps (optional): what this client can do beyond the baseline protocol. Omitted
|
|
52
52
|
// when empty so the wire is byte-identical to a pre-capability client.
|
|
53
|
-
|
|
53
|
+
// identityKey: the Ed25519 key a device list will one day be signed with
|
|
54
|
+
// (docs/design/multi-device.md, step 2). Advertised now so the field is already
|
|
55
|
+
// in the field when something reads it, exactly as the capability list was.
|
|
56
|
+
//
|
|
57
|
+
// It is **unauthenticated here** and must stay that way in every reader's mind
|
|
58
|
+
// until step 3: the relay forwards it verbatim, so it can substitute one. What
|
|
59
|
+
// makes an identity key trustworthy is a signed device list checked against it,
|
|
60
|
+
// not its presence in a JOIN.
|
|
61
|
+
export function createJoin(
|
|
62
|
+
nickname,
|
|
63
|
+
publicKeyB64,
|
|
64
|
+
pqPublicKeyB64 = null,
|
|
65
|
+
caps = null,
|
|
66
|
+
identityKeyB64 = null,
|
|
67
|
+
deviceList = null,
|
|
68
|
+
) {
|
|
54
69
|
const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
55
70
|
if (pqPublicKeyB64) {
|
|
56
71
|
msg.pqPublicKey = pqPublicKeyB64;
|
|
@@ -58,6 +73,15 @@ export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps =
|
|
|
58
73
|
if (Array.isArray(caps) && caps.length > 0) {
|
|
59
74
|
msg.caps = [...caps];
|
|
60
75
|
}
|
|
76
|
+
if (identityKeyB64) {
|
|
77
|
+
msg.identityKey = identityKeyB64;
|
|
78
|
+
}
|
|
79
|
+
// Only needed when the nickname is already held by another of your devices.
|
|
80
|
+
// Sent always when there is one, because a client cannot know in advance
|
|
81
|
+
// whether its other device is already online.
|
|
82
|
+
if (deviceList) {
|
|
83
|
+
msg.deviceList = deviceList;
|
|
84
|
+
}
|
|
61
85
|
return msg;
|
|
62
86
|
}
|
|
63
87
|
|
|
@@ -249,8 +273,17 @@ export function createBanPeer(targetNickname, reason = '') {
|
|
|
249
273
|
return { ...base(MSG.BAN_PEER), targetNickname, reason };
|
|
250
274
|
}
|
|
251
275
|
|
|
252
|
-
|
|
253
|
-
|
|
276
|
+
// `sessionId` (optional) names *which* session was removed. `nickname` cannot:
|
|
277
|
+
// it is not unique over time — /nick reassigns it — and a client that removed a
|
|
278
|
+
// peer by name would drop the wrong session whenever two people had ever shared
|
|
279
|
+
// one. Older clients ignore the field; it exists so a kick can be matched to the
|
|
280
|
+
// `peer_left` that follows it and reported as a kick rather than a departure.
|
|
281
|
+
export function createPeerKicked(nickname, reason = '', sessionId = null) {
|
|
282
|
+
const msg = { ...base(MSG.PEER_KICKED), nickname, reason };
|
|
283
|
+
if (sessionId) {
|
|
284
|
+
msg.sessionId = sessionId;
|
|
285
|
+
}
|
|
286
|
+
return msg;
|
|
254
287
|
}
|
|
255
288
|
|
|
256
289
|
export function createPeerMuted(nickname, durationMs) {
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
PROTOCOL_VERSION,
|
|
3
3
|
MAX_NICKNAME_LENGTH,
|
|
4
4
|
MAX_PAYLOAD_SIZE,
|
|
5
|
+
IDENTITY_KEY_SIZE,
|
|
5
6
|
PUBLIC_KEY_SIZE,
|
|
6
7
|
ROOM_AUTH_PK_SIZE,
|
|
7
8
|
ROOM_AUTH_SIG_SIZE,
|
|
@@ -131,6 +132,19 @@ export function validateJoin(msg) {
|
|
|
131
132
|
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
132
133
|
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
133
134
|
}
|
|
135
|
+
// Optional Ed25519 identity key (multi-device). Absent = a client from before
|
|
136
|
+
// it existed. Checked for shape only: the relay cannot tell whose key it is,
|
|
137
|
+
// and is not supposed to be able to.
|
|
138
|
+
if (msg.identityKey !== undefined && !isValidBase64(msg.identityKey, IDENTITY_KEY_SIZE)) {
|
|
139
|
+
return { valid: false, error: 'Invalid identity key' };
|
|
140
|
+
}
|
|
141
|
+
// Optional signed device list, used only to claim a nickname another of your
|
|
142
|
+
// own devices already holds. Shape here; the signature is checked where the
|
|
143
|
+
// claim is decided, because only there is it known what identity to check it
|
|
144
|
+
// against.
|
|
145
|
+
if (msg.deviceList !== undefined && (typeof msg.deviceList !== 'object' || !msg.deviceList)) {
|
|
146
|
+
return { valid: false, error: 'Invalid device list' };
|
|
147
|
+
}
|
|
134
148
|
const capabilities = sanitizeCapabilities(msg.caps);
|
|
135
149
|
if (capabilities === null) {
|
|
136
150
|
return {
|
|
@@ -142,6 +156,8 @@ export function validateJoin(msg) {
|
|
|
142
156
|
valid: true,
|
|
143
157
|
nickname: nick,
|
|
144
158
|
pqPublicKey: msg.pqPublicKey || null,
|
|
159
|
+
identityKey: msg.identityKey || null,
|
|
160
|
+
deviceList: msg.deviceList || null,
|
|
145
161
|
capabilities,
|
|
146
162
|
};
|
|
147
163
|
}
|
|
@@ -5,7 +5,11 @@ const log = createLogger('session');
|
|
|
5
5
|
|
|
6
6
|
export class SessionManager {
|
|
7
7
|
#sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, rooms: Set }>
|
|
8
|
-
|
|
8
|
+
// lowercased nickname -> the sessions holding it. A Set of names was enough
|
|
9
|
+
// while a name meant one connection; multi-device means several sessions can
|
|
10
|
+
// share one, and releasing the name when the *first* of them leaves would
|
|
11
|
+
// hand it to a stranger while its owner is still in the room.
|
|
12
|
+
#nicknames; // Map<lowerNickname, Set<sessionId>>
|
|
9
13
|
#recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
|
|
10
14
|
#rooms; // Map<roomName, Set<sessionId>>
|
|
11
15
|
#roomOwners; // Map<roomName, sessionId>
|
|
@@ -15,7 +19,7 @@ export class SessionManager {
|
|
|
15
19
|
|
|
16
20
|
constructor() {
|
|
17
21
|
this.#sessions = new Map();
|
|
18
|
-
this.#nicknames = new
|
|
22
|
+
this.#nicknames = new Map();
|
|
19
23
|
this.#recentlyLeft = new Map();
|
|
20
24
|
this.#rooms = new Map();
|
|
21
25
|
this.#roomOwners = new Map();
|
|
@@ -27,23 +31,68 @@ export class SessionManager {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
isNicknameTaken(nickname) {
|
|
30
|
-
return this.#nicknames.
|
|
34
|
+
return (this.#nicknames.get(nickname.toLowerCase())?.size ?? 0) > 0;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
/**
|
|
38
|
+
* The identity key the sessions under this nickname are using, or null.
|
|
39
|
+
*
|
|
40
|
+
* Null when they disagree — which should not happen, since a second session
|
|
41
|
+
* is only admitted after matching, but a name whose holders cannot agree on
|
|
42
|
+
* an identity is not a name anything else should be admitted to.
|
|
43
|
+
*/
|
|
44
|
+
identityForNickname(nickname) {
|
|
45
|
+
const holders = this.#nicknames.get(nickname.toLowerCase());
|
|
46
|
+
if (!holders || holders.size === 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
let identity = null;
|
|
50
|
+
for (const sessionId of holders) {
|
|
51
|
+
const key = this.#sessions.get(sessionId)?.identityKey ?? null;
|
|
52
|
+
if (!key || (identity && identity !== key)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
identity = key;
|
|
56
|
+
}
|
|
57
|
+
return identity;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How many sessions currently answer to this nickname. */
|
|
61
|
+
sessionsForNickname(nickname) {
|
|
62
|
+
return this.#nicknames.get(nickname.toLowerCase())?.size ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
addSession(
|
|
66
|
+
ws,
|
|
67
|
+
nickname,
|
|
68
|
+
publicKey,
|
|
69
|
+
room = 'general',
|
|
70
|
+
pqPublicKey = null,
|
|
71
|
+
capabilities = [],
|
|
72
|
+
identityKey = null,
|
|
73
|
+
) {
|
|
34
74
|
const sessionId = randomUUID();
|
|
35
75
|
const session = {
|
|
36
76
|
ws,
|
|
37
77
|
nickname,
|
|
38
78
|
publicKey,
|
|
39
79
|
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
80
|
+
// Ed25519 identity key, relayed verbatim like the rest. The relay cannot
|
|
81
|
+
// check it belongs to this session and must not pretend to: a client
|
|
82
|
+
// trusts an identity key because a signed device list says so, never
|
|
83
|
+
// because a relay repeated it.
|
|
84
|
+
identityKey,
|
|
40
85
|
capabilities, // advertised in JOIN, relayed verbatim — the relay never acts on these
|
|
41
86
|
connectedAt: Date.now(),
|
|
42
87
|
rooms: new Set(),
|
|
43
88
|
};
|
|
44
89
|
|
|
45
90
|
this.#sessions.set(sessionId, session);
|
|
46
|
-
|
|
91
|
+
const lower = nickname.toLowerCase();
|
|
92
|
+
if (!this.#nicknames.has(lower)) {
|
|
93
|
+
this.#nicknames.set(lower, new Set());
|
|
94
|
+
}
|
|
95
|
+
this.#nicknames.get(lower).add(sessionId);
|
|
47
96
|
this.#joinRoom(sessionId, room);
|
|
48
97
|
|
|
49
98
|
log.info(`${nickname} connected (${sessionId.slice(0, 8)}) in room ${room}`);
|
|
@@ -59,7 +108,14 @@ export class SessionManager {
|
|
|
59
108
|
for (const room of [...session.rooms]) {
|
|
60
109
|
this.#leaveRoom(sessionId, room);
|
|
61
110
|
}
|
|
62
|
-
|
|
111
|
+
const lower = session.nickname.toLowerCase();
|
|
112
|
+
const holders = this.#nicknames.get(lower);
|
|
113
|
+
if (holders) {
|
|
114
|
+
holders.delete(sessionId);
|
|
115
|
+
if (holders.size === 0) {
|
|
116
|
+
this.#nicknames.delete(lower);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
63
119
|
this.#sessions.delete(sessionId);
|
|
64
120
|
this.#muteState.delete(sessionId);
|
|
65
121
|
|
|
@@ -105,6 +161,7 @@ export class SessionManager {
|
|
|
105
161
|
nickname: session.nickname,
|
|
106
162
|
publicKey: session.publicKey,
|
|
107
163
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
164
|
+
...(session.identityKey ? { identityKey: session.identityKey } : {}),
|
|
108
165
|
...(session.capabilities?.length ? { caps: [...session.capabilities] } : {}),
|
|
109
166
|
});
|
|
110
167
|
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
validateBanPeer,
|
|
45
45
|
} from '../protocol/validators.js';
|
|
46
46
|
import { verifyRoomChallenge } from '../crypto/RoomKey.js';
|
|
47
|
+
import { DEVICE_LIMITS, verifyDeviceList } from '../crypto/DeviceIdentity.js';
|
|
47
48
|
import { parseServerConfig, clientAddress, normalizeIp } from './config.js';
|
|
48
49
|
|
|
49
50
|
const log = createLogger('ws-server');
|
|
@@ -281,6 +282,37 @@ export class SecureWSServer {
|
|
|
281
282
|
}
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
/**
|
|
286
|
+
* May this JOIN share a nickname that is already in use?
|
|
287
|
+
*
|
|
288
|
+
* Only if it is another device of whoever holds it. The proof is the signed
|
|
289
|
+
* device list in the JOIN: it must be signed by the identity the existing
|
|
290
|
+
* sessions are using, and it must name *this* JOIN's public key.
|
|
291
|
+
*
|
|
292
|
+
* No challenge is needed and none is invented. Replaying a list somebody else
|
|
293
|
+
* published gets you a seat in a room under a name whose messages you cannot
|
|
294
|
+
* read, because you do not hold the box secret the list names — and the peers,
|
|
295
|
+
* who do their own checking, learn nothing from the relay having allowed it.
|
|
296
|
+
* The relay is doing admission control on a nickname here, not attesting to
|
|
297
|
+
* anyone's identity; clients never take its word for that.
|
|
298
|
+
*/
|
|
299
|
+
#isAnotherDeviceOf(validation, publicKey) {
|
|
300
|
+
const identityKey = this.#sessionManager.identityForNickname(validation.nickname);
|
|
301
|
+
if (!identityKey || !validation.identityKey || validation.identityKey !== identityKey) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
const list = verifyDeviceList(validation.deviceList);
|
|
305
|
+
if (!list || list.identityPk !== identityKey) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
this.#sessionManager.sessionsForNickname(validation.nickname) >= DEVICE_LIMITS.MAX_DEVICES
|
|
310
|
+
) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return list.devices.some((device) => device.boxPk === publicKey);
|
|
314
|
+
}
|
|
315
|
+
|
|
284
316
|
#handleJoin(ws, msg) {
|
|
285
317
|
if (ws.hasJoined) {
|
|
286
318
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Already in the chat')));
|
|
@@ -293,7 +325,10 @@ export class SecureWSServer {
|
|
|
293
325
|
return;
|
|
294
326
|
}
|
|
295
327
|
|
|
296
|
-
if (
|
|
328
|
+
if (
|
|
329
|
+
this.#sessionManager.isNicknameTaken(validation.nickname) &&
|
|
330
|
+
!this.#isAnotherDeviceOf(validation, msg.publicKey)
|
|
331
|
+
) {
|
|
297
332
|
ws.send(
|
|
298
333
|
JSON.stringify(
|
|
299
334
|
createError(ERR.NICKNAME_TAKEN, `Nickname "${validation.nickname}" is already in use`),
|
|
@@ -310,6 +345,7 @@ export class SecureWSServer {
|
|
|
310
345
|
room,
|
|
311
346
|
validation.pqPublicKey,
|
|
312
347
|
validation.capabilities,
|
|
348
|
+
validation.identityKey,
|
|
313
349
|
);
|
|
314
350
|
ws.sessionId = sessionId;
|
|
315
351
|
ws.hasJoined = true;
|
|
@@ -347,6 +383,7 @@ export class SecureWSServer {
|
|
|
347
383
|
nickname: validation.nickname,
|
|
348
384
|
publicKey: msg.publicKey,
|
|
349
385
|
...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
|
|
386
|
+
...(validation.identityKey ? { identityKey: validation.identityKey } : {}),
|
|
350
387
|
...(validation.capabilities.length ? { caps: [...validation.capabilities] } : {}),
|
|
351
388
|
}),
|
|
352
389
|
sessionId,
|
|
@@ -665,6 +702,7 @@ export class SecureWSServer {
|
|
|
665
702
|
nickname: session.nickname,
|
|
666
703
|
publicKey: session.publicKey,
|
|
667
704
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
705
|
+
...(session.identityKey ? { identityKey: session.identityKey } : {}),
|
|
668
706
|
},
|
|
669
707
|
room,
|
|
670
708
|
),
|
|
@@ -776,6 +814,27 @@ export class SecureWSServer {
|
|
|
776
814
|
this.#finishRoomSwitch(ws, session, result);
|
|
777
815
|
}
|
|
778
816
|
|
|
817
|
+
// One session stopped being in one room. Every way out of a room ends here:
|
|
818
|
+
// leaving, switching, disconnecting, and — since this was the gap — being
|
|
819
|
+
// kicked or banned.
|
|
820
|
+
//
|
|
821
|
+
// Kick and ban used to announce only `peer_kicked`, which carries a nickname
|
|
822
|
+
// and no session. Clients had nothing to remove a member *by*, so the removed
|
|
823
|
+
// peer stayed in every remaining roster. Harmless-looking while the relay path
|
|
824
|
+
// seals one envelope per peer; not harmless at all once a room encrypts once
|
|
825
|
+
// to a shared chain, because "who is still in this room" is then the input to
|
|
826
|
+
// when that chain must be rotated away from someone.
|
|
827
|
+
#emitRoomDeparture(room, sessionId, nickname) {
|
|
828
|
+
if (!room || !sessionId) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
this.#sessionManager.broadcastToRoom(
|
|
832
|
+
room,
|
|
833
|
+
createPeerLeft(sessionId, nickname || 'Unknown', room),
|
|
834
|
+
sessionId,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
779
838
|
// Shared tail of a successful room switch: notify every old room and send
|
|
780
839
|
// the ROOM_CHANGED (with the private flag) to the mover.
|
|
781
840
|
#finishRoomSwitch(ws, session, result) {
|
|
@@ -800,6 +859,7 @@ export class SecureWSServer {
|
|
|
800
859
|
nickname: session.nickname,
|
|
801
860
|
publicKey: session.publicKey,
|
|
802
861
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
862
|
+
...(session.identityKey ? { identityKey: session.identityKey } : {}),
|
|
803
863
|
},
|
|
804
864
|
result.newRoom,
|
|
805
865
|
),
|
|
@@ -902,11 +962,14 @@ export class SecureWSServer {
|
|
|
902
962
|
if (result) {
|
|
903
963
|
const targetSession = this.#sessionManager.getSession(targetSessionId);
|
|
904
964
|
|
|
905
|
-
// Notify old room
|
|
965
|
+
// Notify old room. The kick goes first so a client can mark the session
|
|
966
|
+
// before the peer_left lands and report it as a kick rather than a
|
|
967
|
+
// departure; both travel the same socket, so the order holds.
|
|
906
968
|
this.#sessionManager.broadcastToRoom(
|
|
907
969
|
room,
|
|
908
|
-
createPeerKicked(validation.targetNickname, validation.reason),
|
|
970
|
+
createPeerKicked(validation.targetNickname, validation.reason, targetSessionId),
|
|
909
971
|
);
|
|
972
|
+
this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
|
|
910
973
|
|
|
911
974
|
// Notify target with room change + kick reason
|
|
912
975
|
const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
|
|
@@ -1018,8 +1081,9 @@ export class SecureWSServer {
|
|
|
1018
1081
|
if (result) {
|
|
1019
1082
|
this.#sessionManager.broadcastToRoom(
|
|
1020
1083
|
room,
|
|
1021
|
-
createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
|
|
1084
|
+
createPeerKicked(validation.targetNickname, validation.reason || 'banned', targetSessionId),
|
|
1022
1085
|
);
|
|
1086
|
+
this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
|
|
1023
1087
|
|
|
1024
1088
|
const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
|
|
1025
1089
|
targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
|
package/src/shared/constants.js
CHANGED
|
@@ -17,11 +17,15 @@ export const CAP = {
|
|
|
17
17
|
// and fan-out land a release before anyone sends, so that by the time a sender
|
|
18
18
|
// exists, every advertised room can already read it.
|
|
19
19
|
SENDER_KEYS: 'sk1',
|
|
20
|
+
// I can read a signed device list handed to me over the pairwise channel.
|
|
21
|
+
// Client-only: distribution rides the sealed channel the relay already
|
|
22
|
+
// carries, so unlike sk1 there is nothing for the relay to agree to.
|
|
23
|
+
DEVICE_LIST: 'dl1',
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
// What this client advertises. SENDER_KEYS is honest here: the receive path
|
|
23
27
|
// exists. Nothing sends group messages yet.
|
|
24
|
-
export const OWN_CAPABILITIES = [CAP.SENDER_KEYS];
|
|
28
|
+
export const OWN_CAPABILITIES = [CAP.SENDER_KEYS, CAP.DEVICE_LIST];
|
|
25
29
|
|
|
26
30
|
// What the relay advertises, in join_ack. A client cannot promise this on the
|
|
27
31
|
// relay's behalf — the fan-out is the relay's job — so a sender has to check the
|
|
@@ -71,6 +75,10 @@ export const MAX_BYTES_BURST = 4_194_304;
|
|
|
71
75
|
// Crypto sizes (libsodium Curve25519 + XSalsa20-Poly1305)
|
|
72
76
|
export const NONCE_SIZE = 24;
|
|
73
77
|
export const PUBLIC_KEY_SIZE = 32;
|
|
78
|
+
// Ed25519, and the same 32 bytes as above by coincidence of the curves rather
|
|
79
|
+
// than by anything shared. Named separately so a future change to one does not
|
|
80
|
+
// silently resize the other.
|
|
81
|
+
export const IDENTITY_KEY_SIZE = 32;
|
|
74
82
|
export const SECRET_KEY_SIZE = 32;
|
|
75
83
|
export const MAC_SIZE = 16;
|
|
76
84
|
export const SHARED_KEY_SIZE = 32;
|