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.
@@ -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
- return {
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 this.#own.serialize();
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(' /plugins - List loaded plugins');
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
- if (!this.#pluginManager || this.#pluginManager.pluginCount === 0) {
2062
- this.#ui.addInfoMessage('No plugins loaded. Put .js files in ~/.ciphermesh/plugins/');
2063
- } else {
2064
- const names = this.#pluginManager.getPluginNames();
2065
- this.#ui.addInfoMessage(`Plugins loaded (${names.length}): ${names.join(', ')}`);
2066
- const cmds = this.#pluginManager.getCommandNames();
2067
- if (cmds.length > 0) {
2068
- this.#ui.addInfoMessage(`Commands: ${cmds.join(', ')}`);
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
- { label: 'Loading plugins', task: () => pluginManager.loadAll() },
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
+ }
@@ -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
- export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null) {
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
- export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general') {
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
- return { valid: true, nickname: nick, pqPublicKey: msg.pqPublicKey || null };
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)' };