ciphermesh 2.9.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 +126 -0
- package/README.md +5 -3
- package/README.pt-BR.md +2 -2
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PLUGINS.md +53 -14
- package/docs/PROTOCOL.md +486 -0
- package/docs/commands.json +640 -0
- package/docs/demo.svg +2 -2
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +3 -2
- package/src/client/ChatController.js +245 -68
- package/src/client/UI.js +4 -1
- package/src/client/index.js +6 -1
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +16 -10
- package/src/p2p/index.js +6 -1
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- package/src/protocol/validators.js +73 -1
- package/src/server/ConnectionGuard.js +158 -0
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +101 -1
- package/src/server/config.js +15 -0
- package/src/server/index.js +18 -0
- package/src/server/preflight.js +96 -0
- package/src/shared/PluginManager.js +103 -30
- package/src/shared/config.js +12 -0
- package/src/shared/constants.js +50 -0
- package/src/shared/pluginCommand.js +106 -0
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
|
}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from '../shared/dnd.js';
|
|
42
42
|
import { trustBadge } from '../shared/trust.js';
|
|
43
43
|
import { tipAt, TIPS } from '../shared/tips.js';
|
|
44
|
+
import { pluginsCommand } from '../shared/pluginCommand.js';
|
|
44
45
|
import { COMMANDS } from '../client/UI.js';
|
|
45
46
|
|
|
46
47
|
const TYPING_SEND_INTERVAL = 2000;
|
|
@@ -1388,7 +1389,9 @@ export class P2PChatController {
|
|
|
1388
1389
|
this.#ui.addInfoMessage(
|
|
1389
1390
|
' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
|
|
1390
1391
|
);
|
|
1391
|
-
this.#ui.addInfoMessage(
|
|
1392
|
+
this.#ui.addInfoMessage(
|
|
1393
|
+
' /plugins [allow <file>] - List plugins; approve one before it runs',
|
|
1394
|
+
);
|
|
1392
1395
|
this.#ui.addInfoMessage(' /quit - Exit the chat');
|
|
1393
1396
|
break;
|
|
1394
1397
|
|
|
@@ -2058,16 +2061,17 @@ export class P2PChatController {
|
|
|
2058
2061
|
break;
|
|
2059
2062
|
|
|
2060
2063
|
case '/plugins': {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2064
|
+
pluginsCommand(this.#pluginManager, parts.slice(1)).then((lines) => {
|
|
2065
|
+
for (const { kind, text } of lines) {
|
|
2066
|
+
if (kind === 'error') {
|
|
2067
|
+
this.#ui.addErrorMessage(text);
|
|
2068
|
+
} else if (kind === 'system') {
|
|
2069
|
+
this.#ui.addSystemMessage(text);
|
|
2070
|
+
} else {
|
|
2071
|
+
this.#ui.addInfoMessage(text);
|
|
2072
|
+
}
|
|
2069
2073
|
}
|
|
2070
|
-
}
|
|
2074
|
+
});
|
|
2071
2075
|
break;
|
|
2072
2076
|
}
|
|
2073
2077
|
|
|
@@ -2388,9 +2392,11 @@ export class P2PChatController {
|
|
|
2388
2392
|
return;
|
|
2389
2393
|
}
|
|
2390
2394
|
const plaintext = this.#getGroup(msg.room).decrypt(fromNickname, {
|
|
2395
|
+
keyId: msg.keyId,
|
|
2391
2396
|
counter: msg.counter,
|
|
2392
2397
|
ciphertext: msg.ciphertext,
|
|
2393
2398
|
nonce: msg.nonce,
|
|
2399
|
+
signature: msg.signature,
|
|
2394
2400
|
});
|
|
2395
2401
|
if (!plaintext) {
|
|
2396
2402
|
// No sender key yet (rare race) — buffer until sk_dist arrives.
|
package/src/p2p/index.js
CHANGED
|
@@ -165,7 +165,12 @@ await bootSequence([
|
|
|
165
165
|
}
|
|
166
166
|
},
|
|
167
167
|
},
|
|
168
|
-
{
|
|
168
|
+
{
|
|
169
|
+
label: 'Loading plugins',
|
|
170
|
+
// Only what the user approved. An unapproved file is left alone —
|
|
171
|
+
// importing it would already be running it.
|
|
172
|
+
task: () => pluginManager.loadAll(undefined, config.pluginsAllowed),
|
|
173
|
+
},
|
|
169
174
|
]);
|
|
170
175
|
|
|
171
176
|
// ── Initialize components ──────────────────────────────────────
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { MAX_CAPABILITIES, MAX_CAPABILITY_LENGTH, OWN_CAPABILITIES } from '../shared/constants.js';
|
|
2
|
+
|
|
3
|
+
// ── Capability negotiation ─────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `PROTOCOL_VERSION` is checked for exact equality, so it can only say "same" or
|
|
6
|
+
// "refuse to talk". It cannot express a client that is newer but still willing
|
|
7
|
+
// to speak the old way, which is precisely what rolling a protocol change
|
|
8
|
+
// through a public hub needs. Capabilities carry that: advertised in JOIN,
|
|
9
|
+
// relayed verbatim with the peer list, and read here.
|
|
10
|
+
//
|
|
11
|
+
// The rule for turning a feature on is deliberately the strict one — *every*
|
|
12
|
+
// member of the room must advertise it. One peer on an older build is enough to
|
|
13
|
+
// keep the whole room on the old path, which is the outcome that keeps a
|
|
14
|
+
// half-upgraded room readable.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defensive read of a capability list that arrived over the wire.
|
|
18
|
+
*
|
|
19
|
+
* The relay validates what it accepts, but a client should not depend on the
|
|
20
|
+
* relay having done so — this list is used to decide how to encrypt.
|
|
21
|
+
*
|
|
22
|
+
* @returns {string[]} the valid entries, or an empty list
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeCaps(caps) {
|
|
25
|
+
if (!Array.isArray(caps)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const clean = [];
|
|
29
|
+
for (const cap of caps.slice(0, MAX_CAPABILITIES)) {
|
|
30
|
+
if (typeof cap !== 'string' || cap.length === 0 || cap.length > MAX_CAPABILITY_LENGTH) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cap) || clean.includes(cap)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
clean.push(cap);
|
|
37
|
+
}
|
|
38
|
+
return clean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does a single peer record advertise `cap`? */
|
|
42
|
+
export function peerSupports(peer, cap) {
|
|
43
|
+
return normalizeCaps(peer?.caps).includes(cap);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Can the whole room use `cap`?
|
|
48
|
+
*
|
|
49
|
+
* True only when this client advertises it and so does every peer. An empty
|
|
50
|
+
* room is vacuously true — there is nobody who could fail to understand.
|
|
51
|
+
*
|
|
52
|
+
* A hostile relay can edit these lists, so it is worth being explicit about what
|
|
53
|
+
* that buys it. Stripping capabilities forces the room back onto the per-peer
|
|
54
|
+
* path, which is the status quo and reveals nothing new. Adding a capability a
|
|
55
|
+
* peer never claimed makes senders encrypt in a form that peer cannot read —
|
|
56
|
+
* denial of service, visible immediately as a room that stopped working, and
|
|
57
|
+
* never a way to read plaintext. The strict-consensus rule is what keeps the
|
|
58
|
+
* damage on that side of the line.
|
|
59
|
+
*
|
|
60
|
+
* @param {Iterable<{caps?: string[]}>} peers - the room's peers, excluding self
|
|
61
|
+
* @param {string} cap
|
|
62
|
+
* @param {string[]} [own] - what this build advertises
|
|
63
|
+
*/
|
|
64
|
+
export function roomSupports(peers, cap, own = OWN_CAPABILITIES) {
|
|
65
|
+
if (!normalizeCaps(own).includes(cap)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
for (const peer of peers) {
|
|
69
|
+
if (!peerSupports(peer, cap)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
package/src/protocol/messages.js
CHANGED
|
@@ -7,6 +7,7 @@ export const MSG = {
|
|
|
7
7
|
PEER_JOINED: 'peer_joined',
|
|
8
8
|
PEER_LEFT: 'peer_left',
|
|
9
9
|
ENCRYPTED_MESSAGE: 'encrypted_message',
|
|
10
|
+
GROUP_MESSAGE: 'group_message',
|
|
10
11
|
ERROR: 'error',
|
|
11
12
|
KEY_UPDATE: 'key_update',
|
|
12
13
|
PEER_KEY_UPDATED: 'peer_key_updated',
|
|
@@ -47,19 +48,31 @@ function base(type) {
|
|
|
47
48
|
|
|
48
49
|
// pqPublicKey (v3, optional): ML-KEM-768 encapsulation key for the hybrid
|
|
49
50
|
// post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
|
|
50
|
-
|
|
51
|
+
// caps (optional): what this client can do beyond the baseline protocol. Omitted
|
|
52
|
+
// when empty so the wire is byte-identical to a pre-capability client.
|
|
53
|
+
export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps = null) {
|
|
51
54
|
const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
52
55
|
if (pqPublicKeyB64) {
|
|
53
56
|
msg.pqPublicKey = pqPublicKeyB64;
|
|
54
57
|
}
|
|
58
|
+
if (Array.isArray(caps) && caps.length > 0) {
|
|
59
|
+
msg.caps = [...caps];
|
|
60
|
+
}
|
|
55
61
|
return msg;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
// serverCaps: what the relay itself can do, as opposed to what each peer can.
|
|
65
|
+
// A client cannot advertise on the relay's behalf, and some features (group
|
|
66
|
+
// fan-out) need the relay to play along. Omitted when empty so an ack from a
|
|
67
|
+
// pre-capability relay and one from a relay with nothing to offer look alike.
|
|
68
|
+
export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general', caps = null) {
|
|
59
69
|
const ack = { ...base(MSG.JOIN_ACK), sessionId, peers, room };
|
|
60
70
|
if (queuedCount > 0) {
|
|
61
71
|
ack.queuedCount = queuedCount;
|
|
62
72
|
}
|
|
73
|
+
if (Array.isArray(caps) && caps.length > 0) {
|
|
74
|
+
ack.serverCaps = [...caps];
|
|
75
|
+
}
|
|
63
76
|
return ack;
|
|
64
77
|
}
|
|
65
78
|
|
|
@@ -81,6 +94,30 @@ export function createPeerLeft(sessionId, nickname, room = null) {
|
|
|
81
94
|
return msg;
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
// Room-addressed group message. One ciphertext for the whole room, fanned out by
|
|
98
|
+
// the relay to the room's members.
|
|
99
|
+
//
|
|
100
|
+
// There is deliberately no `to` and no `from`. The relay routes on `room`, which
|
|
101
|
+
// it already knows the sender is in, and it must not stamp a sender on the way
|
|
102
|
+
// out — identity on this project's wire is only ever carried sealed. Recipients
|
|
103
|
+
// resolve `keyId` to a member through the sender key they were handed over the
|
|
104
|
+
// pairwise channel, so the label means something to members and nothing to the
|
|
105
|
+
// relay.
|
|
106
|
+
// `signature` is what makes the sender a *person* rather than just a member: the
|
|
107
|
+
// chain is symmetric, so without it any member of the room could write in
|
|
108
|
+
// anyone's name. See src/crypto/SenderKey.js.
|
|
109
|
+
export function createGroupMessage(room, packet) {
|
|
110
|
+
return {
|
|
111
|
+
...base(MSG.GROUP_MESSAGE),
|
|
112
|
+
room,
|
|
113
|
+
keyId: packet.keyId,
|
|
114
|
+
counter: packet.counter,
|
|
115
|
+
ciphertext: packet.ciphertext,
|
|
116
|
+
nonce: packet.nonce,
|
|
117
|
+
signature: packet.signature,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
85
122
|
return {
|
|
86
123
|
...base(MSG.ENCRYPTED_MESSAGE),
|
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
ROOM_AUTH_PK_SIZE,
|
|
7
7
|
ROOM_AUTH_SIG_SIZE,
|
|
8
8
|
ROOM_CHALLENGE_NONCE_SIZE,
|
|
9
|
+
MAX_CAPABILITIES,
|
|
10
|
+
MAX_CAPABILITY_LENGTH,
|
|
11
|
+
SIGNATURE_SIZE,
|
|
9
12
|
} from '../shared/constants.js';
|
|
10
13
|
import { PQ_PUBLIC_KEY_SIZE } from '../crypto/PQHybrid.js';
|
|
11
14
|
|
|
@@ -49,6 +52,34 @@ function sanitizeNickname(nick) {
|
|
|
49
52
|
return clean;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
// Capability list off the wire. Absent is the normal case — every client before
|
|
56
|
+
// capabilities existed — and means "supports nothing extra", not "malformed".
|
|
57
|
+
// Anything present but misshapen is rejected rather than filtered: the relay
|
|
58
|
+
// stores this list and hands it to other clients, and quietly forwarding junk
|
|
59
|
+
// makes a peer look capable of something it never claimed.
|
|
60
|
+
// Returns an array on success, or null to signal rejection.
|
|
61
|
+
function sanitizeCapabilities(caps) {
|
|
62
|
+
if (caps === undefined || caps === null) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(caps) || caps.length > MAX_CAPABILITIES) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const clean = [];
|
|
69
|
+
for (const cap of caps) {
|
|
70
|
+
if (!isString(cap) || cap.length === 0 || cap.length > MAX_CAPABILITY_LENGTH) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cap)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!clean.includes(cap)) {
|
|
77
|
+
clean.push(cap);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return clean;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
// ── Parse + validate incoming JSON ─────────────────────────────
|
|
53
84
|
export function parseMessage(raw) {
|
|
54
85
|
if (typeof raw === 'string' && raw.length > MAX_PAYLOAD_SIZE) {
|
|
@@ -100,7 +131,19 @@ export function validateJoin(msg) {
|
|
|
100
131
|
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
101
132
|
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
102
133
|
}
|
|
103
|
-
|
|
134
|
+
const capabilities = sanitizeCapabilities(msg.caps);
|
|
135
|
+
if (capabilities === null) {
|
|
136
|
+
return {
|
|
137
|
+
valid: false,
|
|
138
|
+
error: `Invalid capability list (max ${MAX_CAPABILITIES} entries of up to ${MAX_CAPABILITY_LENGTH} lowercase chars)`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
valid: true,
|
|
143
|
+
nickname: nick,
|
|
144
|
+
pqPublicKey: msg.pqPublicKey || null,
|
|
145
|
+
capabilities,
|
|
146
|
+
};
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
// Sealed sender (protocol v2): the relay only ever sees the recipient and an
|
|
@@ -125,6 +168,35 @@ export function validateKeyUpdate(msg) {
|
|
|
125
168
|
return { valid: true };
|
|
126
169
|
}
|
|
127
170
|
|
|
171
|
+
// Room-addressed group message. Like validateEncryptedMessage, the relay cannot
|
|
172
|
+
// and must not inspect the content — it checks only what it needs to route:
|
|
173
|
+
// a well-formed room, an opaque chain label, a sane counter, and a non-empty
|
|
174
|
+
// envelope. Membership of `room` is the caller's check, not this one's.
|
|
175
|
+
export function validateGroupMessage(msg) {
|
|
176
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
177
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
178
|
+
}
|
|
179
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
|
|
180
|
+
return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
|
|
181
|
+
}
|
|
182
|
+
if (!isString(msg.keyId) || msg.keyId.length === 0 || msg.keyId.length > 64) {
|
|
183
|
+
return { valid: false, error: 'Invalid sender key id' };
|
|
184
|
+
}
|
|
185
|
+
if (!isNumber(msg.counter) || !Number.isInteger(msg.counter) || msg.counter < 0) {
|
|
186
|
+
return { valid: false, error: 'Invalid counter' };
|
|
187
|
+
}
|
|
188
|
+
if (!isValidBase64(msg.ciphertext) || !isValidBase64(msg.nonce)) {
|
|
189
|
+
return { valid: false, error: 'Missing or invalid group ciphertext' };
|
|
190
|
+
}
|
|
191
|
+
// Presence and size only. The relay holds no signing keys and could not
|
|
192
|
+
// verify this if it wanted to — that is the recipient's job, and the recipient
|
|
193
|
+
// does it before touching the ratchet.
|
|
194
|
+
if (!isValidBase64(msg.signature, SIGNATURE_SIZE)) {
|
|
195
|
+
return { valid: false, error: 'Missing or invalid group signature' };
|
|
196
|
+
}
|
|
197
|
+
return { valid: true, room: msg.room.toLowerCase() };
|
|
198
|
+
}
|
|
199
|
+
|
|
128
200
|
export function validateChangeRoom(msg) {
|
|
129
201
|
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
130
202
|
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|