morok-bot-sdk 1.0.1

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 (96) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +602 -0
  3. package/README.ru.md +602 -0
  4. package/dist/bot.d.ts +232 -0
  5. package/dist/bot.d.ts.map +1 -0
  6. package/dist/bot.js +558 -0
  7. package/dist/bot.js.map +1 -0
  8. package/dist/crypto/channel-cipher.d.ts +32 -0
  9. package/dist/crypto/channel-cipher.d.ts.map +1 -0
  10. package/dist/crypto/channel-cipher.js +77 -0
  11. package/dist/crypto/channel-cipher.js.map +1 -0
  12. package/dist/crypto/channel-key-store.d.ts +37 -0
  13. package/dist/crypto/channel-key-store.d.ts.map +1 -0
  14. package/dist/crypto/channel-key-store.js +149 -0
  15. package/dist/crypto/channel-key-store.js.map +1 -0
  16. package/dist/crypto/cross-signing.d.ts +57 -0
  17. package/dist/crypto/cross-signing.d.ts.map +1 -0
  18. package/dist/crypto/cross-signing.js +111 -0
  19. package/dist/crypto/cross-signing.js.map +1 -0
  20. package/dist/crypto/file-cipher.d.ts +36 -0
  21. package/dist/crypto/file-cipher.d.ts.map +1 -0
  22. package/dist/crypto/file-cipher.js +61 -0
  23. package/dist/crypto/file-cipher.js.map +1 -0
  24. package/dist/crypto/group-secret-cipher.d.ts +49 -0
  25. package/dist/crypto/group-secret-cipher.d.ts.map +1 -0
  26. package/dist/crypto/group-secret-cipher.js +69 -0
  27. package/dist/crypto/group-secret-cipher.js.map +1 -0
  28. package/dist/crypto/group-secret-store.d.ts +35 -0
  29. package/dist/crypto/group-secret-store.d.ts.map +1 -0
  30. package/dist/crypto/group-secret-store.js +149 -0
  31. package/dist/crypto/group-secret-store.js.map +1 -0
  32. package/dist/crypto/signal.d.ts +81 -0
  33. package/dist/crypto/signal.d.ts.map +1 -0
  34. package/dist/crypto/signal.js +125 -0
  35. package/dist/crypto/signal.js.map +1 -0
  36. package/dist/crypto/stores.d.ts +130 -0
  37. package/dist/crypto/stores.d.ts.map +1 -0
  38. package/dist/crypto/stores.js +314 -0
  39. package/dist/crypto/stores.js.map +1 -0
  40. package/dist/flow/attachments.d.ts +110 -0
  41. package/dist/flow/attachments.d.ts.map +1 -0
  42. package/dist/flow/attachments.js +409 -0
  43. package/dist/flow/attachments.js.map +1 -0
  44. package/dist/flow/conv-cache.d.ts +36 -0
  45. package/dist/flow/conv-cache.d.ts.map +1 -0
  46. package/dist/flow/conv-cache.js +84 -0
  47. package/dist/flow/conv-cache.js.map +1 -0
  48. package/dist/flow/direct.d.ts +109 -0
  49. package/dist/flow/direct.d.ts.map +1 -0
  50. package/dist/flow/direct.js +346 -0
  51. package/dist/flow/direct.js.map +1 -0
  52. package/dist/flow/groups.d.ts +146 -0
  53. package/dist/flow/groups.d.ts.map +1 -0
  54. package/dist/flow/groups.js +768 -0
  55. package/dist/flow/groups.js.map +1 -0
  56. package/dist/flow/prekeys.d.ts +45 -0
  57. package/dist/flow/prekeys.d.ts.map +1 -0
  58. package/dist/flow/prekeys.js +111 -0
  59. package/dist/flow/prekeys.js.map +1 -0
  60. package/dist/flow/receive.d.ts +125 -0
  61. package/dist/flow/receive.d.ts.map +1 -0
  62. package/dist/flow/receive.js +773 -0
  63. package/dist/flow/receive.js.map +1 -0
  64. package/dist/index.d.ts +15 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +6 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/morokbot-file.d.ts +14 -0
  69. package/dist/morokbot-file.d.ts.map +1 -0
  70. package/dist/morokbot-file.js +88 -0
  71. package/dist/morokbot-file.js.map +1 -0
  72. package/dist/ratelimit.d.ts +40 -0
  73. package/dist/ratelimit.d.ts.map +1 -0
  74. package/dist/ratelimit.js +76 -0
  75. package/dist/ratelimit.js.map +1 -0
  76. package/dist/sessions.d.ts +34 -0
  77. package/dist/sessions.d.ts.map +1 -0
  78. package/dist/sessions.js +69 -0
  79. package/dist/sessions.js.map +1 -0
  80. package/dist/state-lock.d.ts +17 -0
  81. package/dist/state-lock.d.ts.map +1 -0
  82. package/dist/state-lock.js +66 -0
  83. package/dist/state-lock.js.map +1 -0
  84. package/dist/transport/http.d.ts +48 -0
  85. package/dist/transport/http.d.ts.map +1 -0
  86. package/dist/transport/http.js +112 -0
  87. package/dist/transport/http.js.map +1 -0
  88. package/dist/transport/ws.d.ts +65 -0
  89. package/dist/transport/ws.d.ts.map +1 -0
  90. package/dist/transport/ws.js +219 -0
  91. package/dist/transport/ws.js.map +1 -0
  92. package/dist/types.d.ts +254 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +2 -0
  95. package/dist/types.js.map +1 -0
  96. package/package.json +59 -0
@@ -0,0 +1,768 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { SendRejectedError, SendUncertainError } from './direct.js';
3
+ import { parseChannelWire, decryptChannelWire, encryptChannelWire, } from '../crypto/channel-cipher.js';
4
+ import { unsealEpochKey, sealEpochKey, GROUP_SECRET_BYTES } from '../crypto/group-secret-cipher.js';
5
+ function mintFanoutId() {
6
+ return randomBytes(12).toString('base64url');
7
+ }
8
+ function mintClientMsgId() {
9
+ return randomUUID().replace(/-/g, '');
10
+ }
11
+ export class GroupsFlow {
12
+ botUserId;
13
+ http;
14
+ ws;
15
+ signal;
16
+ store;
17
+ gsStore;
18
+ convCache;
19
+ logger;
20
+ inflightInstalls = new Map();
21
+ pendingSends = new Map();
22
+ inflightGroupSecret = new Map();
23
+ inflightBackfills = new Set();
24
+ constructor(botUserId, http, ws, signal, store, gsStore, convCache, logger) {
25
+ this.botUserId = botUserId;
26
+ this.http = http;
27
+ this.ws = ws;
28
+ this.signal = signal;
29
+ this.store = store;
30
+ this.gsStore = gsStore;
31
+ this.convCache = convCache;
32
+ this.logger = logger;
33
+ this.ws.on('frame', (frame) => { this.onFrame(frame); });
34
+ this.ws.on('close', (info) => { if (info.willReconnect)
35
+ this.failPendingUncertain(); });
36
+ }
37
+ async installFromServer(conversationId, opts = {}) {
38
+ const since = opts.sinceEpoch ?? -1;
39
+ const cacheKey = `${conversationId}:${since}`;
40
+ const inFlight = this.inflightInstalls.get(cacheKey);
41
+ if (inFlight)
42
+ return inFlight;
43
+ const promise = this.doInstall(conversationId, since)
44
+ .finally(() => {
45
+ if (this.inflightInstalls.get(cacheKey) === promise) {
46
+ this.inflightInstalls.delete(cacheKey);
47
+ }
48
+ });
49
+ this.inflightInstalls.set(cacheKey, promise);
50
+ return promise;
51
+ }
52
+ async doInstall(conversationId, since) {
53
+ const res = await this.http.get(`/groups/${conversationId}/channel-key`, { params: { sinceEpoch: since } });
54
+ const body = res.data;
55
+ if (!body || !Array.isArray(body.keys)) {
56
+ this.logger?.warn({ conversationId }, '[groups] /channel-key returned malformed body');
57
+ return 0;
58
+ }
59
+ if (body.keys.length === 0) {
60
+ this.logger?.debug({ conversationId, since, pending: body.pending }, '[groups] no new channel-key epochs available');
61
+ return 0;
62
+ }
63
+ const merged = [];
64
+ for (const k of body.keys) {
65
+ if (!Number.isInteger(k.epoch) || k.epoch < 0) {
66
+ this.logger?.warn({ conversationId, entry: k }, '[groups] dropping channel-key entry with bad epoch');
67
+ continue;
68
+ }
69
+ if (k.messageType !== 1 && k.messageType !== 3) {
70
+ this.logger?.warn({ conversationId, epoch: k.epoch, messageType: k.messageType }, '[groups] dropping channel-key entry with non-Signal type');
71
+ continue;
72
+ }
73
+ if (typeof k.sharedByUserId !== 'number'
74
+ || typeof k.sharedByDeviceId !== 'number') {
75
+ this.logger?.warn({ conversationId, epoch: k.epoch }, '[groups] dropping channel-key entry missing sharer coords');
76
+ continue;
77
+ }
78
+ const sharerId = k.sharedByUserId;
79
+ const sharerDevice = k.sharedByDeviceId;
80
+ let plaintextBytes;
81
+ try {
82
+ plaintextBytes = await this.signal.withPeerLock(sharerId, sharerDevice, () => this.signal.decrypt(sharerId, sharerDevice, k.messageType, k.encryptedKey));
83
+ }
84
+ catch (err) {
85
+ this.logger?.warn({
86
+ conversationId, epoch: k.epoch,
87
+ sharerId, sharerDevice, messageType: k.messageType,
88
+ err: err.message,
89
+ }, '[groups] channel-key envelope decrypt failed');
90
+ continue;
91
+ }
92
+ const secretBase64 = new TextDecoder('utf-8', { fatal: false })
93
+ .decode(plaintextBytes)
94
+ .trim();
95
+ if (!/^[A-Za-z0-9+/=]+$/.test(secretBase64) || secretBase64.length < 40) {
96
+ this.logger?.warn({ conversationId, epoch: k.epoch }, '[groups] channel-key plaintext not a base64 secret');
97
+ continue;
98
+ }
99
+ merged.push({ epoch: k.epoch, secretBase64 });
100
+ }
101
+ const haveEpoch = new Set(merged.map(m => m.epoch));
102
+ const bundles = Array.isArray(body.sealedBundles) ? body.sealedBundles : [];
103
+ const SEAL_BUNDLE_CAP = 10_000;
104
+ if (bundles.length > SEAL_BUNDLE_CAP) {
105
+ this.logger?.warn({ conversationId, bundleCount: bundles.length, cap: SEAL_BUNDLE_CAP }, '[groups] sealedBundles array exceeds cap - truncating');
106
+ }
107
+ const interesting = bundles
108
+ .slice(0, SEAL_BUNDLE_CAP)
109
+ .filter(b => Number.isInteger(b.epoch) && b.epoch >= 0 && !haveEpoch.has(b.epoch));
110
+ if (interesting.length > 0) {
111
+ let needFetch = false;
112
+ for (const b of interesting) {
113
+ if (Number.isInteger(b.groupSecretVersion)) {
114
+ const have = await this.gsStore.getSecret(conversationId, b.groupSecretVersion);
115
+ if (!have) {
116
+ needFetch = true;
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ if (needFetch) {
122
+ try {
123
+ await this.installGroupSecret(conversationId);
124
+ }
125
+ catch (err) {
126
+ this.logger?.warn({ conversationId, err: err.message }, '[groups] sealedBundle path: installGroupSecret failed');
127
+ }
128
+ }
129
+ for (const b of interesting) {
130
+ if (typeof b.sealedToGroupSecret !== 'string'
131
+ || typeof b.sealedToGroupSecretIv !== 'string'
132
+ || !Number.isInteger(b.groupSecretVersion)) {
133
+ this.logger?.warn({ conversationId, bundle: b }, '[groups] dropping malformed sealedBundle');
134
+ continue;
135
+ }
136
+ const secret = await this.gsStore.getSecret(conversationId, b.groupSecretVersion);
137
+ if (!secret) {
138
+ this.logger?.debug({ conversationId, epoch: b.epoch, version: b.groupSecretVersion }, '[groups] sealedBundle skipped: no matching group_secret available');
139
+ continue;
140
+ }
141
+ try {
142
+ const epochKey = await unsealEpochKey({
143
+ ciphertextBase64: b.sealedToGroupSecret,
144
+ ivBase64: b.sealedToGroupSecretIv,
145
+ groupSecret: secret,
146
+ conversationId,
147
+ version: b.groupSecretVersion,
148
+ expectedEpoch: b.epoch,
149
+ });
150
+ merged.push({
151
+ epoch: b.epoch,
152
+ secretBase64: Buffer.from(epochKey).toString('base64'),
153
+ });
154
+ haveEpoch.add(b.epoch);
155
+ }
156
+ catch (err) {
157
+ this.logger?.warn({
158
+ conversationId, epoch: b.epoch, version: b.groupSecretVersion,
159
+ err: err.message,
160
+ }, '[groups] sealedBundle unseal failed');
161
+ }
162
+ }
163
+ }
164
+ if (merged.length === 0)
165
+ return 0;
166
+ await this.store.mergeEpochs(conversationId, merged);
167
+ this.logger?.info({ conversationId, installed: merged.map(m => m.epoch) }, '[groups] installed channel-key epochs');
168
+ return merged.length;
169
+ }
170
+ async decryptGroupMessage(conversationId, wireBase64) {
171
+ const wire = Buffer.from(wireBase64, 'base64');
172
+ const parsed = parseChannelWire(new Uint8Array(wire.buffer, wire.byteOffset, wire.byteLength));
173
+ let secret = await this.store.getSecret(conversationId, parsed.epoch);
174
+ if (!secret) {
175
+ try {
176
+ await this.installFromServer(conversationId, {
177
+ sinceEpoch: Math.max(-1, parsed.epoch - 1),
178
+ });
179
+ }
180
+ catch (err) {
181
+ this.logger?.warn({
182
+ conversationId, epoch: parsed.epoch,
183
+ err: err.message,
184
+ }, '[groups] lazy channel-key fetch failed');
185
+ }
186
+ secret = await this.store.getSecret(conversationId, parsed.epoch);
187
+ }
188
+ if (!secret) {
189
+ throw new Error(`[groups] no channel key for epoch ${parsed.epoch} in conv ${conversationId}`);
190
+ }
191
+ return decryptChannelWire(secret, conversationId, parsed);
192
+ }
193
+ async forgetConversation(conversationId) {
194
+ await this.store.drop(conversationId);
195
+ await this.gsStore.drop(conversationId);
196
+ this.inflightGroupSecret.delete(conversationId);
197
+ }
198
+ async sendMessage(conversationId, plaintext, opts = {}) {
199
+ if (!Number.isInteger(conversationId) || conversationId < 1) {
200
+ throw new Error(`sendMessage: conversationId must be a positive integer, got ${conversationId}`);
201
+ }
202
+ let info = this.convCache.peek(conversationId);
203
+ if (!info) {
204
+ info = await this.convCache.load(conversationId).catch(() => null);
205
+ }
206
+ const isChannelComment = info?.isChannel === true
207
+ && opts.replyToId !== undefined && opts.replyToId !== null;
208
+ if (info && !info.canPost && !isChannelComment) {
209
+ const where = info.isChannel ? 'channel' : 'group';
210
+ throw new Error(`[groups] bot lacks post permission in ${where} ${conversationId} ` +
211
+ `(myRole=${info.myRole}, canPost=false). Ask an admin to grant /can-post.`);
212
+ }
213
+ if (isChannelComment && info?.commentsEnabled === false) {
214
+ throw new Error(`[groups] channel ${conversationId} has comments disabled - ` +
215
+ `cannot post a comment (replyToId=${opts.replyToId})`);
216
+ }
217
+ let state = await this.store.load(conversationId);
218
+ if (!state || state.currentEpoch < 0) {
219
+ await this.installFromServer(conversationId, { sinceEpoch: -1 });
220
+ state = await this.store.load(conversationId);
221
+ }
222
+ if (!state || state.currentEpoch < 0) {
223
+ throw new Error(`[groups] no channel key for conv ${conversationId} - bot may have just joined; ` +
224
+ `wait for channel_key_rotated or ask an admin to share`);
225
+ }
226
+ const epoch = state.currentEpoch;
227
+ const secretBase64 = state.keys[String(epoch)];
228
+ if (!secretBase64) {
229
+ throw new Error(`[groups] currentEpoch=${epoch} has no key entry in store for conv ${conversationId}`);
230
+ }
231
+ const secret = Buffer.from(secretBase64, 'base64');
232
+ if (secret.byteLength !== 32) {
233
+ throw new Error(`[groups] currentEpoch=${epoch} key is ${secret.byteLength} bytes; expected 32`);
234
+ }
235
+ const secretBytes = new Uint8Array(secret.buffer, secret.byteOffset, secret.byteLength);
236
+ const wire = await encryptChannelWire(secretBytes, conversationId, epoch, plaintext);
237
+ const ciphertextBase64 = Buffer.from(wire).toString('base64');
238
+ const fanoutId = mintFanoutId();
239
+ const clientMsgId = mintClientMsgId();
240
+ const timeoutMs = opts.sendTimeoutMs ?? 10_000;
241
+ return new Promise((resolve, reject) => {
242
+ const timer = setTimeout(() => {
243
+ if (this.pendingSends.delete(fanoutId)) {
244
+ reject(new Error(`group send timeout after ${timeoutMs}ms (conv=${conversationId})`));
245
+ }
246
+ }, timeoutMs);
247
+ this.pendingSends.set(fanoutId, {
248
+ resolve: (messageId) => resolve({ messageId, clientMsgId, fanoutId, conversationId, epoch }),
249
+ reject,
250
+ timer,
251
+ clientMsgId,
252
+ conversationId,
253
+ });
254
+ const frame = {
255
+ type: 'group_message',
256
+ conversationId,
257
+ ciphertext: ciphertextBase64,
258
+ messageType: 8,
259
+ fanoutId,
260
+ clientMsgId,
261
+ };
262
+ if (opts.replyToId !== undefined)
263
+ frame.replyToId = opts.replyToId;
264
+ if (opts.replyToClientMsgId !== undefined)
265
+ frame.replyToClientMsgId = opts.replyToClientMsgId;
266
+ if (opts.threadRootId !== undefined)
267
+ frame.threadRootId = opts.threadRootId;
268
+ if (opts.fileId !== undefined)
269
+ frame.fileId = opts.fileId;
270
+ if (opts.kind !== undefined)
271
+ frame.kind = opts.kind;
272
+ if (opts.expiresInSeconds !== undefined)
273
+ frame.expiresInSeconds = opts.expiresInSeconds;
274
+ if (opts.additionalFileIds !== undefined && opts.additionalFileIds.length > 0) {
275
+ frame.additionalFileIds = opts.additionalFileIds;
276
+ }
277
+ this.ws.send(frame);
278
+ });
279
+ }
280
+ async encryptForChannel(conversationId, plaintext) {
281
+ let state = await this.store.load(conversationId);
282
+ if (!state || state.currentEpoch < 0) {
283
+ await this.installFromServer(conversationId, { sinceEpoch: -1 });
284
+ state = await this.store.load(conversationId);
285
+ }
286
+ if (!state || state.currentEpoch < 0) {
287
+ throw new Error(`[groups] no channel key for conv ${conversationId} - bot may have just joined; ` +
288
+ `wait for channel_key_rotated or ask an admin to share`);
289
+ }
290
+ const epoch = state.currentEpoch;
291
+ const secretBase64 = state.keys[String(epoch)];
292
+ if (!secretBase64) {
293
+ throw new Error(`[groups] currentEpoch=${epoch} has no key entry in store for conv ${conversationId}`);
294
+ }
295
+ const secret = Buffer.from(secretBase64, 'base64');
296
+ if (secret.byteLength !== 32) {
297
+ throw new Error(`[groups] currentEpoch=${epoch} key is ${secret.byteLength} bytes; expected 32`);
298
+ }
299
+ const secretBytes = new Uint8Array(secret.buffer, secret.byteOffset, secret.byteLength);
300
+ const wire = await encryptChannelWire(secretBytes, conversationId, epoch, plaintext);
301
+ return { ciphertext: Buffer.from(wire).toString('base64'), epoch };
302
+ }
303
+ async rotateChannelKey(conversationId) {
304
+ if (!Number.isInteger(conversationId) || conversationId < 1) {
305
+ throw new Error(`rotateChannelKey: bad conversationId ${conversationId}`);
306
+ }
307
+ const devsRes = await this.http.get(`/conversations/${conversationId}/peer-devices`);
308
+ const devicesMap = devsRes.data?.devices ?? {};
309
+ const targets = [];
310
+ for (const [uidStr, devs] of Object.entries(devicesMap)) {
311
+ const uid = parseInt(uidStr, 10);
312
+ if (!Number.isInteger(uid))
313
+ continue;
314
+ for (const d of devs) {
315
+ if (typeof d?.deviceId !== 'number')
316
+ continue;
317
+ if (uid === this.botUserId && d.deviceId === 1)
318
+ continue;
319
+ targets.push({ userId: uid, deviceId: d.deviceId });
320
+ }
321
+ }
322
+ const rawBuf = randomBytes(32);
323
+ const rawBytes = new Uint8Array(rawBuf.buffer, rawBuf.byteOffset, rawBuf.byteLength);
324
+ const keyBase64 = rawBuf.toString('base64');
325
+ const distributions = [];
326
+ const keyPlaintext = new TextEncoder().encode(keyBase64);
327
+ for (const t of targets) {
328
+ try {
329
+ const env = await this.signal.withPeerLock(t.userId, t.deviceId, async () => {
330
+ if (!(await this.signal.hasOpenSession(t.userId, t.deviceId))) {
331
+ const bundle = await this.http.get(`/prekeys/${t.userId}/${t.deviceId}`);
332
+ await this.signal.processPreKeyBundle(bundle.data);
333
+ }
334
+ return this.signal.encrypt(t.userId, t.deviceId, keyPlaintext);
335
+ });
336
+ if (env.type !== 1 && env.type !== 3) {
337
+ this.logger?.warn({ target: t, messageType: env.type }, '[groups] rotate: signal envelope produced unexpected type, skipping device');
338
+ continue;
339
+ }
340
+ distributions.push({
341
+ recipientId: t.userId,
342
+ recipientDeviceId: t.deviceId,
343
+ encryptedKey: env.body,
344
+ messageType: env.type,
345
+ });
346
+ }
347
+ catch (err) {
348
+ this.logger?.warn({
349
+ target: t,
350
+ err: err.message,
351
+ }, '[groups] rotate: per-device wrap failed, skipping');
352
+ }
353
+ }
354
+ const rotatePayload = { distributions };
355
+ try {
356
+ const gsState = await this.gsStore.load(conversationId);
357
+ if (gsState && gsState.currentVersion >= 0) {
358
+ const bestVersion = gsState.currentVersion;
359
+ const bestSecret = await this.gsStore.getSecret(conversationId, bestVersion);
360
+ if (bestSecret) {
361
+ const state = await this.store.load(conversationId);
362
+ const localMax = state?.currentEpoch ?? -1;
363
+ const predictedEpoch = Math.max(0, localMax + 1);
364
+ const sealed = await sealEpochKey({
365
+ epochKey: rawBytes,
366
+ groupSecret: bestSecret,
367
+ conversationId,
368
+ version: bestVersion,
369
+ predictedEpoch,
370
+ });
371
+ rotatePayload.sealedToGroupSecret = sealed.ciphertext;
372
+ rotatePayload.sealedToGroupSecretIv = sealed.iv;
373
+ rotatePayload.groupSecretVersion = bestVersion;
374
+ }
375
+ }
376
+ }
377
+ catch (err) {
378
+ this.logger?.warn({ conversationId, err: err.message }, '[groups] rotate: sealing under group_secret failed (continuing without bundle)');
379
+ }
380
+ const res = await this.http.post(`/groups/${conversationId}/channel-key/rotate`, rotatePayload);
381
+ const epoch = res.data?.epoch;
382
+ if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 0) {
383
+ throw new Error(`[groups] rotate: server returned malformed epoch ${epoch}`);
384
+ }
385
+ await this.store.mergeEpochs(conversationId, [{ epoch, secretBase64: keyBase64 }]);
386
+ this.logger?.info({
387
+ conversationId, epoch,
388
+ wraps: distributions.length,
389
+ skipped: targets.length - distributions.length,
390
+ sealed: rotatePayload.sealedToGroupSecret !== undefined,
391
+ }, '[groups] channel key rotated');
392
+ rawBytes.fill(0);
393
+ return { epoch };
394
+ }
395
+ async rotateGroupSecret(conversationId) {
396
+ try {
397
+ return await this.rotateGroupSecretOnce(conversationId);
398
+ }
399
+ catch (err) {
400
+ const code = err?.response?.data?.code;
401
+ if (code !== 'SECRET_VERSION_STALE')
402
+ throw err;
403
+ this.logger?.warn({ conversationId }, '[groups] rotateGroupSecret stale, refreshing and retrying once');
404
+ await this.installGroupSecret(conversationId);
405
+ return await this.rotateGroupSecretOnce(conversationId);
406
+ }
407
+ }
408
+ async rotateGroupSecretOnce(conversationId) {
409
+ if (!Number.isInteger(conversationId) || conversationId < 1) {
410
+ throw new Error(`rotateGroupSecret: bad conversationId ${conversationId}`);
411
+ }
412
+ let state = await this.gsStore.load(conversationId);
413
+ if (!state || state.currentVersion < 0) {
414
+ await this.installGroupSecret(conversationId);
415
+ state = await this.gsStore.load(conversationId);
416
+ }
417
+ if (!state || state.currentVersion < 0) {
418
+ throw new Error(`[groups] rotateGroupSecret: no current group_secret known for conv ${conversationId} - bot may have just joined; wait for installGroupSecret to settle`);
419
+ }
420
+ const expectedCurrentVersion = state.currentVersion;
421
+ const devsRes = await this.http.get(`/conversations/${conversationId}/peer-devices`);
422
+ const devicesMap = devsRes.data?.devices ?? {};
423
+ const targets = [];
424
+ for (const [uidStr, devs] of Object.entries(devicesMap)) {
425
+ const uid = parseInt(uidStr, 10);
426
+ if (!Number.isInteger(uid))
427
+ continue;
428
+ for (const d of devs) {
429
+ if (typeof d?.deviceId !== 'number')
430
+ continue;
431
+ if (uid === this.botUserId && d.deviceId === 1)
432
+ continue;
433
+ targets.push({ userId: uid, deviceId: d.deviceId });
434
+ }
435
+ }
436
+ const newSecretBuf = randomBytes(32);
437
+ const newSecretBytes = new Uint8Array(newSecretBuf.buffer, newSecretBuf.byteOffset, newSecretBuf.byteLength);
438
+ const newSecretBase64 = newSecretBuf.toString('base64');
439
+ const newChanKeyBuf = randomBytes(32);
440
+ const newChanKeyBytes = new Uint8Array(newChanKeyBuf.buffer, newChanKeyBuf.byteOffset, newChanKeyBuf.byteLength);
441
+ const newChanKeyBase64 = newChanKeyBuf.toString('base64');
442
+ const secretWraps = [];
443
+ const channelKeyDistributions = [];
444
+ const secretPlaintext = new TextEncoder().encode(newSecretBase64);
445
+ const chanKeyPlaintext = new TextEncoder().encode(newChanKeyBase64);
446
+ for (const t of targets) {
447
+ try {
448
+ const wraps = await this.signal.withPeerLock(t.userId, t.deviceId, async () => {
449
+ if (!(await this.signal.hasOpenSession(t.userId, t.deviceId))) {
450
+ const bundle = await this.http.get(`/prekeys/${t.userId}/${t.deviceId}`);
451
+ await this.signal.processPreKeyBundle(bundle.data);
452
+ }
453
+ const sEnv = await this.signal.encrypt(t.userId, t.deviceId, secretPlaintext);
454
+ const ckEnv = await this.signal.encrypt(t.userId, t.deviceId, chanKeyPlaintext);
455
+ return { sEnv, ckEnv };
456
+ });
457
+ if ((wraps.sEnv.type !== 1 && wraps.sEnv.type !== 3)
458
+ || (wraps.ckEnv.type !== 1 && wraps.ckEnv.type !== 3)) {
459
+ this.logger?.warn({ target: t, sType: wraps.sEnv.type, ckType: wraps.ckEnv.type }, '[groups] rotateGroupSecret: unexpected envelope type, skipping device');
460
+ continue;
461
+ }
462
+ secretWraps.push({
463
+ recipientUserId: t.userId,
464
+ recipientDeviceId: t.deviceId,
465
+ encryptedSecret: wraps.sEnv.body,
466
+ messageType: wraps.sEnv.type,
467
+ });
468
+ channelKeyDistributions.push({
469
+ recipientId: t.userId,
470
+ recipientDeviceId: t.deviceId,
471
+ encryptedKey: wraps.ckEnv.body,
472
+ messageType: wraps.ckEnv.type,
473
+ });
474
+ }
475
+ catch (err) {
476
+ this.logger?.warn({ target: t, err: err.message }, '[groups] rotateGroupSecret: per-device wrap failed, skipping');
477
+ }
478
+ }
479
+ const newVersion = expectedCurrentVersion + 1;
480
+ const ckState = await this.store.load(conversationId);
481
+ const predictedEpoch = Math.max(0, (ckState?.currentEpoch ?? -1) + 1);
482
+ const sealed = await sealEpochKey({
483
+ epochKey: newChanKeyBytes,
484
+ groupSecret: newSecretBytes,
485
+ conversationId,
486
+ version: newVersion,
487
+ predictedEpoch,
488
+ });
489
+ const resealHistorical = [];
490
+ const HISTORY_CAP = 10_000;
491
+ if (ckState) {
492
+ const epochs = Object.keys(ckState.keys)
493
+ .map(s => parseInt(s, 10))
494
+ .filter(n => Number.isInteger(n) && n >= 0)
495
+ .sort((a, b) => a - b)
496
+ .slice(0, HISTORY_CAP);
497
+ for (const e of epochs) {
498
+ const raw = await this.store.getSecret(conversationId, e);
499
+ if (!raw)
500
+ continue;
501
+ try {
502
+ const oneSealed = await sealEpochKey({
503
+ epochKey: raw,
504
+ groupSecret: newSecretBytes,
505
+ conversationId,
506
+ version: newVersion,
507
+ predictedEpoch: e,
508
+ });
509
+ resealHistorical.push({
510
+ epoch: e,
511
+ sealedToGroupSecret: oneSealed.ciphertext,
512
+ sealedToGroupSecretIv: oneSealed.iv,
513
+ });
514
+ }
515
+ catch (err) {
516
+ this.logger?.warn({ conversationId, epoch: e, err: err.message }, '[groups] rotateGroupSecret: per-epoch reseal failed, skipping');
517
+ }
518
+ }
519
+ }
520
+ const res = await this.http.post(`/groups/${conversationId}/group-secret/rotate`, {
521
+ expectedCurrentVersion,
522
+ secretWraps,
523
+ channelKeyDistributions,
524
+ sealedToGroupSecret: sealed.ciphertext,
525
+ sealedToGroupSecretIv: sealed.iv,
526
+ ...(resealHistorical.length > 0 ? { resealHistorical } : {}),
527
+ });
528
+ const epoch = res.data?.epoch;
529
+ const version = res.data?.version;
530
+ if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 0
531
+ || typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
532
+ throw new Error(`[groups] rotateGroupSecret: server returned malformed response (epoch=${epoch}, version=${version})`);
533
+ }
534
+ await this.gsStore.mergeVersions(conversationId, [
535
+ { version, secretBase64: newSecretBase64 },
536
+ ]);
537
+ await this.store.mergeEpochs(conversationId, [
538
+ { epoch, secretBase64: newChanKeyBase64 },
539
+ ]);
540
+ this.logger?.info({
541
+ conversationId, epoch, version,
542
+ wraps: secretWraps.length,
543
+ skipped: targets.length - secretWraps.length,
544
+ resealed: resealHistorical.length,
545
+ }, '[groups] group_secret + channel_key rotated atomically');
546
+ newSecretBytes.fill(0);
547
+ newChanKeyBytes.fill(0);
548
+ return { epoch, version };
549
+ }
550
+ async backfillChannelKeys(conversationId, target, opts = {}) {
551
+ if (!Number.isInteger(conversationId) || conversationId < 1) {
552
+ throw new Error(`backfillChannelKeys: bad conversationId ${conversationId}`);
553
+ }
554
+ if (!Number.isInteger(target.userId) || target.userId < 1) {
555
+ throw new Error(`backfillChannelKeys: bad target.userId ${target.userId}`);
556
+ }
557
+ if (target.userId === this.botUserId) {
558
+ throw new Error('backfillChannelKeys: cannot backfill to self - use /channel-key sibling-share for own devices');
559
+ }
560
+ let deviceIds;
561
+ if (Array.isArray(target.deviceIds) && target.deviceIds.length > 0) {
562
+ deviceIds = target.deviceIds.filter(d => Number.isInteger(d) && d >= 1);
563
+ }
564
+ else {
565
+ const res = await this.http.get(`/prekeys/${target.userId}/devices`);
566
+ deviceIds = Array.isArray(res.data?.deviceIds) ? res.data.deviceIds : [];
567
+ }
568
+ if (deviceIds.length === 0) {
569
+ this.logger?.warn({ conversationId, targetUserId: target.userId }, '[groups] backfill: target has no addressable devices');
570
+ return { accepted: 0 };
571
+ }
572
+ const state = await this.store.load(conversationId);
573
+ if (!state) {
574
+ this.logger?.warn({ conversationId }, '[groups] backfill: no local channel-key state - nothing to share');
575
+ return { accepted: 0 };
576
+ }
577
+ const known = Object.keys(state.keys).map(s => parseInt(s, 10)).filter(Number.isInteger);
578
+ const epochs = Array.isArray(opts.epochs)
579
+ ? opts.epochs.filter(e => Number.isInteger(e) && e >= 0 && known.includes(e))
580
+ : known;
581
+ if (epochs.length === 0)
582
+ return { accepted: 0 };
583
+ const distributions = [];
584
+ for (const epoch of epochs) {
585
+ const keyBase64 = state.keys[String(epoch)];
586
+ if (!keyBase64)
587
+ continue;
588
+ const keyPlaintext = new TextEncoder().encode(keyBase64);
589
+ for (const deviceId of deviceIds) {
590
+ try {
591
+ const env = await this.signal.withPeerLock(target.userId, deviceId, async () => {
592
+ if (!(await this.signal.hasOpenSession(target.userId, deviceId))) {
593
+ const bundle = await this.http.get(`/prekeys/${target.userId}/${deviceId}`);
594
+ await this.signal.processPreKeyBundle(bundle.data);
595
+ }
596
+ return this.signal.encrypt(target.userId, deviceId, keyPlaintext);
597
+ });
598
+ if (env.type !== 1 && env.type !== 3) {
599
+ this.logger?.warn({ target: { userId: target.userId, deviceId }, epoch, messageType: env.type }, '[groups] backfill: unexpected envelope type, skipping');
600
+ continue;
601
+ }
602
+ distributions.push({
603
+ recipientId: target.userId,
604
+ recipientDeviceId: deviceId,
605
+ encryptedKey: env.body,
606
+ messageType: env.type,
607
+ epoch,
608
+ });
609
+ }
610
+ catch (err) {
611
+ this.logger?.warn({
612
+ target: { userId: target.userId, deviceId }, epoch,
613
+ err: err.message,
614
+ }, '[groups] backfill: per-wrap failure, skipping');
615
+ }
616
+ }
617
+ }
618
+ if (distributions.length === 0)
619
+ return { accepted: 0 };
620
+ const BACKFILL_CHUNK_SIZE = 500;
621
+ let totalAccepted = 0;
622
+ for (let i = 0; i < distributions.length; i += BACKFILL_CHUNK_SIZE) {
623
+ const chunk = distributions.slice(i, i + BACKFILL_CHUNK_SIZE);
624
+ try {
625
+ const res = await this.http.post(`/groups/${conversationId}/channel-key/backfill`, { distributions: chunk });
626
+ let accepted;
627
+ if (typeof res.data?.acceptedCount === 'number' && res.data.acceptedCount >= 0) {
628
+ accepted = res.data.acceptedCount;
629
+ }
630
+ else if (typeof res.data?.count === 'number' && res.data.count >= 0) {
631
+ accepted = res.data.count;
632
+ }
633
+ else {
634
+ this.logger?.warn({ conversationId, targetUserId: target.userId, chunkLen: chunk.length }, '[groups] backfill: server response missing acceptedCount / count - treating as 0 accepted');
635
+ accepted = 0;
636
+ }
637
+ totalAccepted += accepted;
638
+ }
639
+ catch (err) {
640
+ this.logger?.warn({ conversationId, targetUserId: target.userId, chunkStart: i, err: err.message }, '[groups] backfill: chunk POST failed');
641
+ }
642
+ }
643
+ this.logger?.info({
644
+ conversationId, targetUserId: target.userId,
645
+ attempted: distributions.length, accepted: totalAccepted,
646
+ deviceCount: deviceIds.length, epochCount: epochs.length,
647
+ }, '[groups] channel-key backfill complete');
648
+ return { accepted: totalAccepted };
649
+ }
650
+ async installGroupSecret(conversationId) {
651
+ if (!Number.isInteger(conversationId) || conversationId < 1) {
652
+ throw new Error(`installGroupSecret: bad conversationId ${conversationId}`);
653
+ }
654
+ const inFlight = this.inflightGroupSecret.get(conversationId);
655
+ if (inFlight)
656
+ return inFlight;
657
+ const p = this.doInstallGroupSecret(conversationId).finally(() => {
658
+ if (this.inflightGroupSecret.get(conversationId) === p) {
659
+ this.inflightGroupSecret.delete(conversationId);
660
+ }
661
+ });
662
+ this.inflightGroupSecret.set(conversationId, p);
663
+ return p;
664
+ }
665
+ async doInstallGroupSecret(conversationId) {
666
+ let body;
667
+ try {
668
+ const res = await this.http.get(`/groups/${conversationId}/group-secret`);
669
+ body = res.data ?? {};
670
+ }
671
+ catch (err) {
672
+ this.logger?.warn({ conversationId, err: err.message }, '[groups] /group-secret fetch failed');
673
+ return;
674
+ }
675
+ if (body.initialized === false) {
676
+ this.logger?.debug({ conversationId }, '[groups] conv has no group_secret yet - skipping');
677
+ return;
678
+ }
679
+ if (typeof body.version !== 'number'
680
+ || typeof body.encryptedSecret !== 'string'
681
+ || (body.messageType !== 1 && body.messageType !== 3)
682
+ || typeof body.sharedByUserId !== 'number'
683
+ || typeof body.sharedByDeviceId !== 'number') {
684
+ this.logger?.warn({ conversationId, body }, '[groups] /group-secret malformed body');
685
+ return;
686
+ }
687
+ const version = body.version;
688
+ const sharerId = body.sharedByUserId;
689
+ const sharerDevice = body.sharedByDeviceId;
690
+ const messageType = body.messageType;
691
+ const encryptedKey = body.encryptedSecret;
692
+ let plaintext;
693
+ try {
694
+ plaintext = await this.signal.withPeerLock(sharerId, sharerDevice, () => this.signal.decrypt(sharerId, sharerDevice, messageType, encryptedKey));
695
+ }
696
+ catch (err) {
697
+ this.logger?.warn({
698
+ conversationId, version, sharerId, sharerDevice, messageType,
699
+ err: err.message,
700
+ }, '[groups] group_secret envelope decrypt failed');
701
+ return;
702
+ }
703
+ const secretBase64 = new TextDecoder('utf-8', { fatal: false })
704
+ .decode(plaintext)
705
+ .trim();
706
+ const raw = Buffer.from(secretBase64, 'base64');
707
+ if (raw.byteLength !== GROUP_SECRET_BYTES) {
708
+ this.logger?.warn({ conversationId, version, gotBytes: raw.byteLength }, `[groups] unwrapped group_secret has wrong length; expected ${GROUP_SECRET_BYTES}`);
709
+ return;
710
+ }
711
+ await this.gsStore.mergeVersions(conversationId, [{ version, secretBase64 }]);
712
+ this.logger?.info({ conversationId, version }, '[groups] installed group_secret');
713
+ }
714
+ failPendingUncertain() {
715
+ for (const [, p] of this.pendingSends) {
716
+ clearTimeout(p.timer);
717
+ p.reject(new SendUncertainError('group send interrupted by a disconnect before the server confirmed', p.clientMsgId, p.conversationId));
718
+ }
719
+ this.pendingSends.clear();
720
+ }
721
+ shutdown() {
722
+ for (const [fanoutId, p] of this.pendingSends) {
723
+ clearTimeout(p.timer);
724
+ p.reject(new Error(`group send aborted: SDK shutting down (fanoutId=${fanoutId})`));
725
+ }
726
+ this.pendingSends.clear();
727
+ this.inflightBackfills.clear();
728
+ }
729
+ trackBackfill(p) {
730
+ this.inflightBackfills.add(p);
731
+ p.finally(() => { this.inflightBackfills.delete(p); }).catch(() => { });
732
+ }
733
+ onFrame(frame) {
734
+ const fanoutId = frame.fanoutId;
735
+ if (typeof fanoutId !== 'string')
736
+ return;
737
+ const pending = this.pendingSends.get(fanoutId);
738
+ if (!pending)
739
+ return;
740
+ clearTimeout(pending.timer);
741
+ this.pendingSends.delete(fanoutId);
742
+ if (frame.type === 'error') {
743
+ const msg = typeof frame.message === 'string'
744
+ ? frame.message
745
+ : typeof frame.code === 'string'
746
+ ? frame.code
747
+ : 'group send rejected by server';
748
+ const codeStr = typeof frame.code === 'string' ? ` (code=${frame.code})` : '';
749
+ pending.reject(new SendRejectedError(`group send rejected: ${msg}${codeStr}`, typeof frame.code === 'string' ? frame.code : undefined));
750
+ return;
751
+ }
752
+ if (frame.type === 'message') {
753
+ if (frame.messageType !== 8) {
754
+ pending.reject(new Error(`unexpected messageType ${frame.messageType} on own-echo (expected 8)`));
755
+ return;
756
+ }
757
+ const id = typeof frame.id === 'number' ? frame.id : null;
758
+ if (id === null) {
759
+ pending.reject(new Error('group own-echo missing message id'));
760
+ return;
761
+ }
762
+ pending.resolve(id);
763
+ return;
764
+ }
765
+ pending.reject(new Error(`unexpected frame type "${frame.type}" for fanoutId ${fanoutId}`));
766
+ }
767
+ }
768
+ //# sourceMappingURL=groups.js.map