@meshwhisper/sdk 0.1.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.
Files changed (163) hide show
  1. package/README.md +138 -0
  2. package/dist/browser/index.d.ts +4 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +19 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/chaff/index.d.ts +91 -0
  7. package/dist/chaff/index.d.ts.map +1 -0
  8. package/dist/chaff/index.js +268 -0
  9. package/dist/chaff/index.js.map +1 -0
  10. package/dist/cluster/index.d.ts +159 -0
  11. package/dist/cluster/index.d.ts.map +1 -0
  12. package/dist/cluster/index.js +393 -0
  13. package/dist/cluster/index.js.map +1 -0
  14. package/dist/compliance/index.d.ts +129 -0
  15. package/dist/compliance/index.d.ts.map +1 -0
  16. package/dist/compliance/index.js +315 -0
  17. package/dist/compliance/index.js.map +1 -0
  18. package/dist/crypto/index.d.ts +65 -0
  19. package/dist/crypto/index.d.ts.map +1 -0
  20. package/dist/crypto/index.js +146 -0
  21. package/dist/crypto/index.js.map +1 -0
  22. package/dist/group/index.d.ts +155 -0
  23. package/dist/group/index.d.ts.map +1 -0
  24. package/dist/group/index.js +560 -0
  25. package/dist/group/index.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/namespace/index.d.ts +155 -0
  31. package/dist/namespace/index.d.ts.map +1 -0
  32. package/dist/namespace/index.js +278 -0
  33. package/dist/namespace/index.js.map +1 -0
  34. package/dist/node/index.d.ts +4 -0
  35. package/dist/node/index.d.ts.map +1 -0
  36. package/dist/node/index.js +19 -0
  37. package/dist/node/index.js.map +1 -0
  38. package/dist/packet/index.d.ts +63 -0
  39. package/dist/packet/index.d.ts.map +1 -0
  40. package/dist/packet/index.js +244 -0
  41. package/dist/packet/index.js.map +1 -0
  42. package/dist/permissions/index.d.ts +107 -0
  43. package/dist/permissions/index.d.ts.map +1 -0
  44. package/dist/permissions/index.js +282 -0
  45. package/dist/permissions/index.js.map +1 -0
  46. package/dist/persistence/idb-storage.d.ts +27 -0
  47. package/dist/persistence/idb-storage.d.ts.map +1 -0
  48. package/dist/persistence/idb-storage.js +75 -0
  49. package/dist/persistence/idb-storage.js.map +1 -0
  50. package/dist/persistence/index.d.ts +4 -0
  51. package/dist/persistence/index.d.ts.map +1 -0
  52. package/dist/persistence/index.js +3 -0
  53. package/dist/persistence/index.js.map +1 -0
  54. package/dist/persistence/node-storage.d.ts +33 -0
  55. package/dist/persistence/node-storage.d.ts.map +1 -0
  56. package/dist/persistence/node-storage.js +90 -0
  57. package/dist/persistence/node-storage.js.map +1 -0
  58. package/dist/persistence/serialization.d.ts +4 -0
  59. package/dist/persistence/serialization.d.ts.map +1 -0
  60. package/dist/persistence/serialization.js +49 -0
  61. package/dist/persistence/serialization.js.map +1 -0
  62. package/dist/persistence/types.d.ts +29 -0
  63. package/dist/persistence/types.d.ts.map +1 -0
  64. package/dist/persistence/types.js +5 -0
  65. package/dist/persistence/types.js.map +1 -0
  66. package/dist/ratchet/index.d.ts +80 -0
  67. package/dist/ratchet/index.d.ts.map +1 -0
  68. package/dist/ratchet/index.js +259 -0
  69. package/dist/ratchet/index.js.map +1 -0
  70. package/dist/reciprocity/index.d.ts +109 -0
  71. package/dist/reciprocity/index.d.ts.map +1 -0
  72. package/dist/reciprocity/index.js +311 -0
  73. package/dist/reciprocity/index.js.map +1 -0
  74. package/dist/relay/index.d.ts +87 -0
  75. package/dist/relay/index.d.ts.map +1 -0
  76. package/dist/relay/index.js +286 -0
  77. package/dist/relay/index.js.map +1 -0
  78. package/dist/routing/index.d.ts +136 -0
  79. package/dist/routing/index.d.ts.map +1 -0
  80. package/dist/routing/index.js +478 -0
  81. package/dist/routing/index.js.map +1 -0
  82. package/dist/sdk/index.d.ts +322 -0
  83. package/dist/sdk/index.d.ts.map +1 -0
  84. package/dist/sdk/index.js +1530 -0
  85. package/dist/sdk/index.js.map +1 -0
  86. package/dist/sybil/index.d.ts +123 -0
  87. package/dist/sybil/index.d.ts.map +1 -0
  88. package/dist/sybil/index.js +491 -0
  89. package/dist/sybil/index.js.map +1 -0
  90. package/dist/transport/browser/index.d.ts +34 -0
  91. package/dist/transport/browser/index.d.ts.map +1 -0
  92. package/dist/transport/browser/index.js +176 -0
  93. package/dist/transport/browser/index.js.map +1 -0
  94. package/dist/transport/local/index.d.ts +57 -0
  95. package/dist/transport/local/index.d.ts.map +1 -0
  96. package/dist/transport/local/index.js +442 -0
  97. package/dist/transport/local/index.js.map +1 -0
  98. package/dist/transport/negotiator/index.d.ts +79 -0
  99. package/dist/transport/negotiator/index.d.ts.map +1 -0
  100. package/dist/transport/negotiator/index.js +289 -0
  101. package/dist/transport/negotiator/index.js.map +1 -0
  102. package/dist/transport/node/index.d.ts +56 -0
  103. package/dist/transport/node/index.d.ts.map +1 -0
  104. package/dist/transport/node/index.js +209 -0
  105. package/dist/transport/node/index.js.map +1 -0
  106. package/dist/transport/noop/index.d.ts +11 -0
  107. package/dist/transport/noop/index.d.ts.map +1 -0
  108. package/dist/transport/noop/index.js +20 -0
  109. package/dist/transport/noop/index.js.map +1 -0
  110. package/dist/transport/p2p/index.d.ts +109 -0
  111. package/dist/transport/p2p/index.d.ts.map +1 -0
  112. package/dist/transport/p2p/index.js +237 -0
  113. package/dist/transport/p2p/index.js.map +1 -0
  114. package/dist/transport/websocket/index.d.ts +89 -0
  115. package/dist/transport/websocket/index.d.ts.map +1 -0
  116. package/dist/transport/websocket/index.js +498 -0
  117. package/dist/transport/websocket/index.js.map +1 -0
  118. package/dist/transport/websocket/serialize.d.ts +5 -0
  119. package/dist/transport/websocket/serialize.d.ts.map +1 -0
  120. package/dist/transport/websocket/serialize.js +55 -0
  121. package/dist/transport/websocket/serialize.js.map +1 -0
  122. package/dist/types.d.ts +215 -0
  123. package/dist/types.d.ts.map +1 -0
  124. package/dist/types.js +15 -0
  125. package/dist/types.js.map +1 -0
  126. package/dist/x3dh/index.d.ts +120 -0
  127. package/dist/x3dh/index.d.ts.map +1 -0
  128. package/dist/x3dh/index.js +290 -0
  129. package/dist/x3dh/index.js.map +1 -0
  130. package/package.json +59 -0
  131. package/src/browser/index.ts +19 -0
  132. package/src/chaff/index.ts +340 -0
  133. package/src/cluster/index.ts +482 -0
  134. package/src/compliance/index.ts +407 -0
  135. package/src/crypto/index.ts +193 -0
  136. package/src/group/index.ts +719 -0
  137. package/src/index.ts +87 -0
  138. package/src/lz4js.d.ts +58 -0
  139. package/src/namespace/index.ts +336 -0
  140. package/src/node/index.ts +19 -0
  141. package/src/packet/index.ts +326 -0
  142. package/src/permissions/index.ts +405 -0
  143. package/src/persistence/idb-storage.ts +83 -0
  144. package/src/persistence/index.ts +3 -0
  145. package/src/persistence/node-storage.ts +96 -0
  146. package/src/persistence/serialization.ts +75 -0
  147. package/src/persistence/types.ts +33 -0
  148. package/src/ratchet/index.ts +363 -0
  149. package/src/reciprocity/index.ts +371 -0
  150. package/src/relay/index.ts +382 -0
  151. package/src/routing/index.ts +577 -0
  152. package/src/sdk/index.ts +1994 -0
  153. package/src/sybil/index.ts +661 -0
  154. package/src/transport/browser/index.ts +201 -0
  155. package/src/transport/local/index.ts +540 -0
  156. package/src/transport/negotiator/index.ts +397 -0
  157. package/src/transport/node/index.ts +234 -0
  158. package/src/transport/noop/index.ts +22 -0
  159. package/src/transport/p2p/index.ts +345 -0
  160. package/src/transport/websocket/index.ts +660 -0
  161. package/src/transport/websocket/serialize.ts +68 -0
  162. package/src/types.ts +275 -0
  163. package/src/x3dh/index.ts +388 -0
@@ -0,0 +1,719 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Group Messaging Module
3
+ // Dynamic Relay Trees (§10.1) and Sender Key Management (§10.2)
4
+ // ============================================================
5
+
6
+ import { gcm } from '@noble/ciphers/aes';
7
+ import { randomBytes } from '../crypto/index.js';
8
+ import type { Group, GroupMember, PermissionModel } from '../types.js';
9
+
10
+ // ---- Constants ----
11
+
12
+ /** Sender key length in bytes. */
13
+ const SENDER_KEY_LENGTH = 32;
14
+
15
+ /** AES-GCM nonce length in bytes (96-bit). */
16
+ const GCM_NONCE_LENGTH = 12;
17
+
18
+ /** AES-GCM authentication tag length in bytes. */
19
+ const GCM_TAG_LENGTH = 16;
20
+
21
+ /** Group ID length in bytes (128-bit random). */
22
+ const GROUP_ID_LENGTH = 16;
23
+
24
+ /** Activity window for relay tree scoring, in milliseconds (5 minutes). */
25
+ const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
26
+
27
+ /** Minimum interval between relay tree rebuilds, in milliseconds. */
28
+ const TREE_REBUILD_INTERVAL_MS = 60 * 1000;
29
+
30
+ // ---- Exported Interfaces ----
31
+
32
+ /** Relay tree node representing a group member's position in the relay topology. */
33
+ export interface TreeNode {
34
+ peerId: string;
35
+ children: TreeNode[];
36
+ activityScore: number;
37
+ lastActive: number;
38
+ }
39
+
40
+ /** Invitation payload to join a group, distributed via pairwise encrypted channels. */
41
+ export interface GroupInvite {
42
+ groupId: string;
43
+ groupName: string;
44
+ invitedBy: string;
45
+ senderKeys: Map<string, Uint8Array>;
46
+ members: string[];
47
+ }
48
+
49
+ /** Distribution message for a single sender key. */
50
+ export interface SenderKeyDistribution {
51
+ groupId: string;
52
+ senderId: string;
53
+ senderKey: Uint8Array;
54
+ iteration: number;
55
+ }
56
+
57
+ // ---- Internal Types ----
58
+
59
+ /** Per-member activity tracking entry. */
60
+ interface ActivityRecord {
61
+ timestamps: number[];
62
+ lastActive: number;
63
+ }
64
+
65
+ /** Internal state associated with each group. */
66
+ interface GroupState {
67
+ group: Group;
68
+ /** Sender keys indexed by member peer ID. */
69
+ senderKeys: Map<string, Uint8Array>;
70
+ /** Key iteration counters indexed by member peer ID. */
71
+ keyIterations: Map<string, number>;
72
+ /** Activity history indexed by member peer ID. */
73
+ activity: Map<string, ActivityRecord>;
74
+ /** Cached relay tree root (rebuilt periodically). */
75
+ relayTree: TreeNode | null;
76
+ /** Timestamp of last relay tree rebuild. */
77
+ lastTreeBuild: number;
78
+ }
79
+
80
+ // ============================================================
81
+ // GroupManager
82
+ // ============================================================
83
+
84
+ export class GroupManager {
85
+ private readonly localPeerId: string;
86
+ private readonly groups: Map<string, GroupState> = new Map();
87
+
88
+ constructor(localPeerId: string) {
89
+ this.localPeerId = localPeerId;
90
+ }
91
+
92
+ // ---- Group Lifecycle ----
93
+
94
+ /**
95
+ * Creates a new group. The local peer is the initial admin and tree root.
96
+ * Generates sender keys for the local peer and all initial members.
97
+ */
98
+ createGroup(name: string, members: string[], permissionModel: PermissionModel): Group {
99
+ const groupId = this.generateGroupId();
100
+ const now = Date.now();
101
+
102
+ const memberMap = new Map<string, GroupMember>();
103
+ const senderKeys = new Map<string, Uint8Array>();
104
+ const keyIterations = new Map<string, number>();
105
+ const activity = new Map<string, ActivityRecord>();
106
+
107
+ // Add self as admin
108
+ const selfKey = this.generateSenderKey();
109
+ memberMap.set(this.localPeerId, {
110
+ id: this.localPeerId,
111
+ senderKey: selfKey,
112
+ role: 'admin',
113
+ joinedAt: now,
114
+ });
115
+ senderKeys.set(this.localPeerId, selfKey);
116
+ keyIterations.set(this.localPeerId, 0);
117
+ activity.set(this.localPeerId, { timestamps: [], lastActive: now });
118
+
119
+ // Add initial members
120
+ for (const peerId of members) {
121
+ if (peerId === this.localPeerId) continue;
122
+
123
+ const memberKey = this.generateSenderKey();
124
+ memberMap.set(peerId, {
125
+ id: peerId,
126
+ senderKey: memberKey,
127
+ role: 'member',
128
+ joinedAt: now,
129
+ });
130
+ senderKeys.set(peerId, memberKey);
131
+ keyIterations.set(peerId, 0);
132
+ activity.set(peerId, { timestamps: [], lastActive: now });
133
+ }
134
+
135
+ const group: Group = {
136
+ id: groupId,
137
+ name,
138
+ members: memberMap,
139
+ treeRoot: this.localPeerId,
140
+ permissionModel,
141
+ createdAt: now,
142
+ };
143
+
144
+ this.groups.set(groupId, {
145
+ group,
146
+ senderKeys,
147
+ keyIterations,
148
+ activity,
149
+ relayTree: null,
150
+ lastTreeBuild: 0,
151
+ });
152
+
153
+ return group;
154
+ }
155
+
156
+ /**
157
+ * Joins an existing group using invite data received via a pairwise channel.
158
+ * Stores all distributed sender keys from the invite.
159
+ */
160
+ joinGroup(groupId: string, inviteData: GroupInvite): Group {
161
+ const now = Date.now();
162
+
163
+ const memberMap = new Map<string, GroupMember>();
164
+ const senderKeys = new Map<string, Uint8Array>();
165
+ const keyIterations = new Map<string, number>();
166
+ const activity = new Map<string, ActivityRecord>();
167
+
168
+ // Populate members from the invite
169
+ for (const peerId of inviteData.members) {
170
+ const existingKey = inviteData.senderKeys.get(peerId);
171
+ memberMap.set(peerId, {
172
+ id: peerId,
173
+ senderKey: existingKey ?? new Uint8Array(SENDER_KEY_LENGTH),
174
+ role: peerId === inviteData.invitedBy ? 'admin' : 'member',
175
+ joinedAt: now,
176
+ });
177
+ if (existingKey) {
178
+ senderKeys.set(peerId, existingKey);
179
+ }
180
+ keyIterations.set(peerId, 0);
181
+ activity.set(peerId, { timestamps: [], lastActive: now });
182
+ }
183
+
184
+ // Add self if not already in the member list
185
+ if (!memberMap.has(this.localPeerId)) {
186
+ const selfKey = this.generateSenderKey();
187
+ memberMap.set(this.localPeerId, {
188
+ id: this.localPeerId,
189
+ senderKey: selfKey,
190
+ role: 'member',
191
+ joinedAt: now,
192
+ });
193
+ senderKeys.set(this.localPeerId, selfKey);
194
+ keyIterations.set(this.localPeerId, 0);
195
+ activity.set(this.localPeerId, { timestamps: [], lastActive: now });
196
+ }
197
+
198
+ const group: Group = {
199
+ id: groupId,
200
+ name: inviteData.groupName,
201
+ members: memberMap,
202
+ treeRoot: inviteData.invitedBy,
203
+ permissionModel: 'open',
204
+ createdAt: now,
205
+ };
206
+
207
+ this.groups.set(groupId, {
208
+ group,
209
+ senderKeys,
210
+ keyIterations,
211
+ activity,
212
+ relayTree: null,
213
+ lastTreeBuild: 0,
214
+ });
215
+
216
+ return group;
217
+ }
218
+
219
+ /**
220
+ * Leaves a group, removing all local state for it.
221
+ */
222
+ leaveGroup(groupId: string): void {
223
+ const state = this.requireGroupState(groupId);
224
+
225
+ // Remove self from the member list
226
+ state.group.members.delete(this.localPeerId);
227
+ state.senderKeys.delete(this.localPeerId);
228
+ state.keyIterations.delete(this.localPeerId);
229
+ state.activity.delete(this.localPeerId);
230
+
231
+ // Discard all local state for this group
232
+ this.groups.delete(groupId);
233
+ }
234
+
235
+ /**
236
+ * Returns the group with the given ID, or null if not found.
237
+ */
238
+ getGroup(groupId: string): Group | null {
239
+ const state = this.groups.get(groupId);
240
+ return state?.group ?? null;
241
+ }
242
+
243
+ /**
244
+ * Returns all groups the local peer is participating in.
245
+ */
246
+ getGroups(): Group[] {
247
+ return Array.from(this.groups.values()).map((s) => s.group);
248
+ }
249
+
250
+ // ---- Member Management ----
251
+
252
+ /**
253
+ * Adds a new member to the group and generates a sender key for them.
254
+ */
255
+ addMember(groupId: string, peerId: string): void {
256
+ const state = this.requireGroupState(groupId);
257
+
258
+ if (state.group.members.has(peerId)) {
259
+ return; // Already a member
260
+ }
261
+
262
+ const now = Date.now();
263
+ const memberKey = this.generateSenderKey();
264
+
265
+ state.group.members.set(peerId, {
266
+ id: peerId,
267
+ senderKey: memberKey,
268
+ role: 'member',
269
+ joinedAt: now,
270
+ });
271
+ state.senderKeys.set(peerId, memberKey);
272
+ state.keyIterations.set(peerId, 0);
273
+ state.activity.set(peerId, { timestamps: [], lastActive: now });
274
+
275
+ // Invalidate cached tree
276
+ state.relayTree = null;
277
+ }
278
+
279
+ /**
280
+ * Removes a member from the group and triggers rotation of all sender keys
281
+ * to maintain forward secrecy — the removed member must not be able to
282
+ * decrypt future messages.
283
+ */
284
+ removeMember(groupId: string, peerId: string): void {
285
+ const state = this.requireGroupState(groupId);
286
+
287
+ if (!state.group.members.has(peerId)) {
288
+ return; // Not a member
289
+ }
290
+
291
+ state.group.members.delete(peerId);
292
+ state.senderKeys.delete(peerId);
293
+ state.keyIterations.delete(peerId);
294
+ state.activity.delete(peerId);
295
+
296
+ // Rotate all remaining sender keys (Signal protocol requirement)
297
+ this.rotateAllSenderKeys(groupId);
298
+
299
+ // Invalidate cached tree
300
+ state.relayTree = null;
301
+ }
302
+
303
+ /**
304
+ * Returns the list of all members in a group.
305
+ */
306
+ getMembers(groupId: string): GroupMember[] {
307
+ const state = this.requireGroupState(groupId);
308
+ return Array.from(state.group.members.values());
309
+ }
310
+
311
+ /**
312
+ * Returns the role of a member in a group, or null if not a member.
313
+ */
314
+ getMemberRole(groupId: string, peerId: string): 'admin' | 'member' | null {
315
+ const state = this.groups.get(groupId);
316
+ if (!state) return null;
317
+ const member = state.group.members.get(peerId);
318
+ return member?.role ?? null;
319
+ }
320
+
321
+ /**
322
+ * Updates a member's role within the group.
323
+ */
324
+ setMemberRole(groupId: string, peerId: string, role: 'admin' | 'member'): void {
325
+ const state = this.requireGroupState(groupId);
326
+ const member = state.group.members.get(peerId);
327
+ if (!member) {
328
+ throw new Error(`Peer ${peerId} is not a member of group ${groupId}`);
329
+ }
330
+ member.role = role;
331
+ }
332
+
333
+ // ---- Sender Keys ----
334
+
335
+ /**
336
+ * Generates a new 32-byte sender key using cryptographically secure randomness.
337
+ */
338
+ generateSenderKey(): Uint8Array {
339
+ return randomBytes(SENDER_KEY_LENGTH);
340
+ }
341
+
342
+ /**
343
+ * Creates a sender key distribution message for a specific recipient.
344
+ * This message should be encrypted via the pairwise channel before transmission.
345
+ */
346
+ distributeSenderKey(
347
+ groupId: string,
348
+ senderKey: Uint8Array,
349
+ recipientId: string,
350
+ ): SenderKeyDistribution {
351
+ const state = this.requireGroupState(groupId);
352
+ const iteration = state.keyIterations.get(this.localPeerId) ?? 0;
353
+
354
+ // Store locally
355
+ state.senderKeys.set(this.localPeerId, senderKey);
356
+ const member = state.group.members.get(this.localPeerId);
357
+ if (member) {
358
+ member.senderKey = senderKey;
359
+ }
360
+
361
+ return {
362
+ groupId,
363
+ senderId: this.localPeerId,
364
+ senderKey,
365
+ iteration,
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Processes a received sender key distribution message from another member.
371
+ */
372
+ receiveSenderKey(distribution: SenderKeyDistribution): void {
373
+ const state = this.groups.get(distribution.groupId);
374
+ if (!state) {
375
+ throw new Error(`Unknown group ${distribution.groupId}`);
376
+ }
377
+
378
+ state.senderKeys.set(distribution.senderId, distribution.senderKey);
379
+ state.keyIterations.set(distribution.senderId, distribution.iteration);
380
+
381
+ const member = state.group.members.get(distribution.senderId);
382
+ if (member) {
383
+ member.senderKey = distribution.senderKey;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Rotates all sender keys in a group. Called when a member is removed
389
+ * to ensure forward secrecy.
390
+ */
391
+ rotateAllSenderKeys(groupId: string): void {
392
+ const state = this.requireGroupState(groupId);
393
+
394
+ for (const [peerId, member] of state.group.members) {
395
+ const newKey = this.generateSenderKey();
396
+ const currentIteration = state.keyIterations.get(peerId) ?? 0;
397
+
398
+ member.senderKey = newKey;
399
+ state.senderKeys.set(peerId, newKey);
400
+ state.keyIterations.set(peerId, currentIteration + 1);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Returns the sender key for a specific member in a group, or null if unknown.
406
+ */
407
+ getSenderKey(groupId: string, memberId: string): Uint8Array | null {
408
+ const state = this.groups.get(groupId);
409
+ if (!state) return null;
410
+ return state.senderKeys.get(memberId) ?? null;
411
+ }
412
+
413
+ /**
414
+ * Encrypts plaintext for the group using the local peer's sender key.
415
+ * Uses AES-256-GCM. The ciphertext format is: nonce (12) || ciphertext || tag (16).
416
+ */
417
+ encryptForGroup(
418
+ groupId: string,
419
+ plaintext: Uint8Array,
420
+ ): { ciphertext: Uint8Array; senderId: string } {
421
+ const state = this.requireGroupState(groupId);
422
+ const senderKey = state.senderKeys.get(this.localPeerId);
423
+ if (!senderKey) {
424
+ throw new Error('No sender key available for local peer');
425
+ }
426
+
427
+ const nonce = randomBytes(GCM_NONCE_LENGTH);
428
+ const cipher = gcm(senderKey, nonce);
429
+ const sealed = cipher.encrypt(plaintext);
430
+
431
+ // Pack as: nonce || sealed (which is ciphertext || tag)
432
+ const result = new Uint8Array(GCM_NONCE_LENGTH + sealed.length);
433
+ result.set(nonce, 0);
434
+ result.set(sealed, GCM_NONCE_LENGTH);
435
+
436
+ // Record activity
437
+ this.updateActivity(groupId, this.localPeerId);
438
+
439
+ return { ciphertext: result, senderId: this.localPeerId };
440
+ }
441
+
442
+ /**
443
+ * Decrypts a group message from a specific sender using their sender key.
444
+ * Expects the ciphertext format: nonce (12) || ciphertext || tag (16).
445
+ */
446
+ decryptFromGroup(
447
+ groupId: string,
448
+ senderId: string,
449
+ ciphertext: Uint8Array,
450
+ ): Uint8Array {
451
+ const state = this.requireGroupState(groupId);
452
+ const senderKey = state.senderKeys.get(senderId);
453
+ if (!senderKey) {
454
+ throw new Error(`No sender key for peer ${senderId} in group ${groupId}`);
455
+ }
456
+
457
+ if (ciphertext.length < GCM_NONCE_LENGTH + GCM_TAG_LENGTH) {
458
+ throw new Error('Ciphertext too short');
459
+ }
460
+
461
+ const nonce = ciphertext.slice(0, GCM_NONCE_LENGTH);
462
+ const sealed = ciphertext.slice(GCM_NONCE_LENGTH);
463
+
464
+ const cipher = gcm(senderKey, nonce);
465
+ return cipher.decrypt(sealed);
466
+ }
467
+
468
+ // ---- Dynamic Relay Tree ----
469
+
470
+ /**
471
+ * Records a message-send activity for a member. Used to compute
472
+ * activity scores that drive relay tree topology.
473
+ */
474
+ updateActivity(groupId: string, peerId: string): void {
475
+ const state = this.requireGroupState(groupId);
476
+ const record = state.activity.get(peerId);
477
+ if (!record) return;
478
+
479
+ const now = Date.now();
480
+ record.timestamps.push(now);
481
+ record.lastActive = now;
482
+
483
+ // Prune timestamps older than the activity window
484
+ const cutoff = now - ACTIVITY_WINDOW_MS;
485
+ record.timestamps = record.timestamps.filter((t) => t >= cutoff);
486
+ }
487
+
488
+ /**
489
+ * Builds (or returns cached) relay tree for the group. The tree is
490
+ * structured so that the most active members form the trunk near the
491
+ * root, and less active members are leaves.
492
+ *
493
+ * The tree is rebuilt at most once per TREE_REBUILD_INTERVAL_MS.
494
+ */
495
+ buildRelayTree(groupId: string): TreeNode {
496
+ const state = this.requireGroupState(groupId);
497
+ const now = Date.now();
498
+
499
+ // Return cached tree if still fresh
500
+ if (
501
+ state.relayTree &&
502
+ now - state.lastTreeBuild < TREE_REBUILD_INTERVAL_MS
503
+ ) {
504
+ return state.relayTree;
505
+ }
506
+
507
+ const memberIds = Array.from(state.group.members.keys());
508
+ if (memberIds.length === 0) {
509
+ throw new Error(`Group ${groupId} has no members`);
510
+ }
511
+
512
+ // Compute activity scores
513
+ const scores = new Map<string, number>();
514
+ const cutoff = now - ACTIVITY_WINDOW_MS;
515
+
516
+ for (const peerId of memberIds) {
517
+ const record = state.activity.get(peerId);
518
+ if (!record) {
519
+ scores.set(peerId, 0);
520
+ continue;
521
+ }
522
+ const recentTimestamps = record.timestamps.filter((t) => t >= cutoff);
523
+ scores.set(peerId, recentTimestamps.length);
524
+ }
525
+
526
+ // Sort members by activity score descending; tie-break: tree root first, then ID
527
+ const sorted = [...memberIds].sort((a, b) => {
528
+ // Tree root (group creator) wins ties
529
+ if (a === state.group.treeRoot && b !== state.group.treeRoot) return -1;
530
+ if (b === state.group.treeRoot && a !== state.group.treeRoot) return 1;
531
+ const scoreA = scores.get(a) ?? 0;
532
+ const scoreB = scores.get(b) ?? 0;
533
+ if (scoreB !== scoreA) return scoreB - scoreA;
534
+ return a.localeCompare(b);
535
+ });
536
+
537
+ // Build a balanced relay tree: most active peer is root, next most active
538
+ // peers fill the trunk (first children), less active are leaves.
539
+ // We use a breadth-first insertion approach to build a roughly balanced tree.
540
+ const nodeMap = new Map<string, TreeNode>();
541
+
542
+ for (const peerId of sorted) {
543
+ const record = state.activity.get(peerId);
544
+ nodeMap.set(peerId, {
545
+ peerId,
546
+ children: [],
547
+ activityScore: scores.get(peerId) ?? 0,
548
+ lastActive: record?.lastActive ?? 0,
549
+ });
550
+ }
551
+
552
+ const root = nodeMap.get(sorted[0])!;
553
+
554
+ // BFS queue for balanced insertion — each node gets at most 2 children
555
+ // to keep tree depth logarithmic
556
+ const MAX_CHILDREN = 2;
557
+ const queue: TreeNode[] = [root];
558
+ let queueIdx = 0;
559
+
560
+ for (let i = 1; i < sorted.length; i++) {
561
+ const child = nodeMap.get(sorted[i])!;
562
+
563
+ // Find the next parent with capacity
564
+ while (queueIdx < queue.length && queue[queueIdx].children.length >= MAX_CHILDREN) {
565
+ queueIdx++;
566
+ }
567
+
568
+ if (queueIdx < queue.length) {
569
+ queue[queueIdx].children.push(child);
570
+ queue.push(child);
571
+ }
572
+ }
573
+
574
+ state.relayTree = root;
575
+ state.lastTreeBuild = now;
576
+
577
+ return root;
578
+ }
579
+
580
+ /**
581
+ * Determines which peer should relay a message to reach the target peer.
582
+ * Returns the parent of the target in the relay tree, or null if the
583
+ * target is the root or not found.
584
+ */
585
+ getRelayTarget(groupId: string, targetPeerId: string): string | null {
586
+ const tree = this.buildRelayTree(groupId);
587
+
588
+ // BFS to find the parent of the target
589
+ const queue: Array<{ node: TreeNode; parent: TreeNode | null }> = [
590
+ { node: tree, parent: null },
591
+ ];
592
+
593
+ while (queue.length > 0) {
594
+ const { node, parent } = queue.shift()!;
595
+
596
+ if (node.peerId === targetPeerId) {
597
+ return parent?.peerId ?? null;
598
+ }
599
+
600
+ for (const child of node.children) {
601
+ queue.push({ node: child, parent: node });
602
+ }
603
+ }
604
+
605
+ return null;
606
+ }
607
+
608
+ /**
609
+ * Returns the list of peer IDs that the local peer should relay messages to
610
+ * (i.e., the local peer's children in the relay tree).
611
+ */
612
+ getRelayChildren(groupId: string): string[] {
613
+ const tree = this.buildRelayTree(groupId);
614
+ const localNode = this.findNodeInTree(tree, this.localPeerId);
615
+ if (!localNode) return [];
616
+ return localNode.children.map((c) => c.peerId);
617
+ }
618
+
619
+ // ---- Group Message Routing ----
620
+
621
+ /**
622
+ * Routes a group message through the relay tree. Returns the list of peers
623
+ * that the local peer should forward the message to, based on the relay
624
+ * tree position.
625
+ *
626
+ * If the local peer is the sender (or root), it forwards to its children.
627
+ * If the local peer is relaying, it forwards to its children plus its parent
628
+ * (if the message didn't come from the parent direction).
629
+ */
630
+ routeGroupMessage(
631
+ groupId: string,
632
+ ciphertext: Uint8Array,
633
+ senderId: string,
634
+ ): Array<{ peerId: string; data: Uint8Array }> {
635
+ const tree = this.buildRelayTree(groupId);
636
+ const localNode = this.findNodeInTree(tree, this.localPeerId);
637
+
638
+ if (!localNode) return [];
639
+
640
+ const targets: Array<{ peerId: string; data: Uint8Array }> = [];
641
+
642
+ // Collect all descendants that should receive the relay
643
+ const collectDescendants = (node: TreeNode): void => {
644
+ for (const child of node.children) {
645
+ if (child.peerId !== senderId) {
646
+ targets.push({ peerId: child.peerId, data: ciphertext });
647
+ }
648
+ collectDescendants(child);
649
+ }
650
+ };
651
+
652
+ // If we are the sender, relay to all our tree children subtrees
653
+ if (senderId === this.localPeerId) {
654
+ for (const child of localNode.children) {
655
+ targets.push({ peerId: child.peerId, data: ciphertext });
656
+ }
657
+ } else {
658
+ // We are relaying — forward to our children (excluding the sender direction)
659
+ for (const child of localNode.children) {
660
+ if (child.peerId !== senderId && !this.isInSubtree(child, senderId)) {
661
+ targets.push({ peerId: child.peerId, data: ciphertext });
662
+ }
663
+ }
664
+
665
+ // Also forward to parent if the message came from a child
666
+ const parent = this.findParentInTree(tree, this.localPeerId);
667
+ if (parent && parent.peerId !== senderId) {
668
+ targets.push({ peerId: parent.peerId, data: ciphertext });
669
+ }
670
+ }
671
+
672
+ return targets;
673
+ }
674
+
675
+ // ---- Private Helpers ----
676
+
677
+ /** Generates a random hex-encoded group ID. */
678
+ private generateGroupId(): string {
679
+ const bytes = randomBytes(GROUP_ID_LENGTH);
680
+ return Array.from(bytes)
681
+ .map((b) => b.toString(16).padStart(2, '0'))
682
+ .join('');
683
+ }
684
+
685
+ /** Retrieves group state or throws if the group is unknown. */
686
+ private requireGroupState(groupId: string): GroupState {
687
+ const state = this.groups.get(groupId);
688
+ if (!state) {
689
+ throw new Error(`Unknown group ${groupId}`);
690
+ }
691
+ return state;
692
+ }
693
+
694
+ /** Searches the relay tree for a node by peer ID. */
695
+ private findNodeInTree(root: TreeNode, peerId: string): TreeNode | null {
696
+ if (root.peerId === peerId) return root;
697
+ for (const child of root.children) {
698
+ const found = this.findNodeInTree(child, peerId);
699
+ if (found) return found;
700
+ }
701
+ return null;
702
+ }
703
+
704
+ /** Finds the parent node of a given peer in the relay tree. */
705
+ private findParentInTree(root: TreeNode, peerId: string): TreeNode | null {
706
+ for (const child of root.children) {
707
+ if (child.peerId === peerId) return root;
708
+ const found = this.findParentInTree(child, peerId);
709
+ if (found) return found;
710
+ }
711
+ return null;
712
+ }
713
+
714
+ /** Checks whether a peer is anywhere in the subtree rooted at the given node. */
715
+ private isInSubtree(node: TreeNode, peerId: string): boolean {
716
+ if (node.peerId === peerId) return true;
717
+ return node.children.some((child) => this.isInSubtree(child, peerId));
718
+ }
719
+ }