@agentunion/fastaun 0.4.7 → 0.4.8

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 (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/_packed_docs/CHANGELOG.md +25 -0
  3. package/_packed_docs/INDEX.md +46 -22
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +16 -12
  5. package/_packed_docs/design/AUNClient/346/213/206/345/210/206/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +859 -0
  6. package/dist/agent-md.d.ts +1 -1
  7. package/dist/agent-md.js +12 -5
  8. package/dist/agent-md.js.map +1 -1
  9. package/dist/aid-store.d.ts +0 -1
  10. package/dist/aid-store.js +26 -13
  11. package/dist/aid-store.js.map +1 -1
  12. package/dist/aid.d.ts +1 -0
  13. package/dist/aid.js +8 -3
  14. package/dist/aid.js.map +1 -1
  15. package/dist/cert-utils.d.ts +5 -1
  16. package/dist/cert-utils.js +47 -9
  17. package/dist/cert-utils.js.map +1 -1
  18. package/dist/client/delivery.d.ts +50 -0
  19. package/dist/client/delivery.js +1147 -0
  20. package/dist/client/delivery.js.map +1 -0
  21. package/dist/client/group-state.d.ts +31 -0
  22. package/dist/client/group-state.js +853 -0
  23. package/dist/client/group-state.js.map +1 -0
  24. package/dist/client/identity.d.ts +7 -0
  25. package/dist/client/identity.js +35 -0
  26. package/dist/client/identity.js.map +1 -0
  27. package/dist/client/lifecycle.d.ts +9 -0
  28. package/dist/client/lifecycle.js +226 -0
  29. package/dist/client/lifecycle.js.map +1 -0
  30. package/dist/client/peers.d.ts +10 -0
  31. package/dist/client/peers.js +42 -0
  32. package/dist/client/peers.js.map +1 -0
  33. package/dist/client/rpc-pipeline.d.ts +36 -0
  34. package/dist/client/rpc-pipeline.js +461 -0
  35. package/dist/client/rpc-pipeline.js.map +1 -0
  36. package/dist/client/runtime.d.ts +5 -0
  37. package/dist/client/runtime.js +7 -0
  38. package/dist/client/runtime.js.map +1 -0
  39. package/dist/client/v2-e2ee.d.ts +109 -0
  40. package/dist/client/v2-e2ee.js +1640 -0
  41. package/dist/client/v2-e2ee.js.map +1 -0
  42. package/dist/client.d.ts +21 -56
  43. package/dist/client.js +303 -3856
  44. package/dist/client.js.map +1 -1
  45. package/dist/config.js +9 -8
  46. package/dist/config.js.map +1 -1
  47. package/dist/discovery.js +56 -6
  48. package/dist/discovery.js.map +1 -1
  49. package/dist/tools/cross-sdk-agent.js +2 -2
  50. package/dist/tools/cross-sdk-agent.js.map +1 -1
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +3 -3
@@ -0,0 +1,853 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { E2EEError } from '../errors.js';
3
+ import { normalizeGroupId } from '../group-id.js';
4
+ import { isJsonObject, } from '../types.js';
5
+ import { computeStateCommitment } from '../v2/state/index.js';
6
+ const MEMBERSHIP_MUTATION_METHODS = new Set([
7
+ 'group.create',
8
+ 'group.add_member',
9
+ 'group.kick',
10
+ 'group.remove_member',
11
+ 'group.leave',
12
+ 'group.review_join_request',
13
+ 'group.batch_review_join_request',
14
+ 'group.use_invite_code',
15
+ 'group.request_join',
16
+ ]);
17
+ const SPK_REGISTRATION_MUTATION_METHODS = new Set([
18
+ 'group.create',
19
+ 'group.use_invite_code',
20
+ ]);
21
+ const MEMBERSHIP_ACTIONS = new Set([
22
+ 'member_added',
23
+ 'member_left',
24
+ 'member_removed',
25
+ 'role_changed',
26
+ 'owner_transferred',
27
+ 'joined',
28
+ 'join_approved',
29
+ 'invite_code_used',
30
+ ]);
31
+ const V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
32
+ const V2_SIG_CACHE_MAX = 16_384;
33
+ function formatCaughtError(error) {
34
+ return error instanceof Error ? error : String(error);
35
+ }
36
+ function stableStringify(obj) {
37
+ if (obj === null || obj === undefined)
38
+ return 'null';
39
+ if (typeof obj === 'boolean' || typeof obj === 'number')
40
+ return JSON.stringify(obj);
41
+ if (typeof obj === 'string')
42
+ return JSON.stringify(obj);
43
+ if (Array.isArray(obj)) {
44
+ return '[' + obj.map(v => stableStringify(v)).join(',') + ']';
45
+ }
46
+ if (isJsonObject(obj)) {
47
+ const entries = Object.keys(obj).sort()
48
+ .filter(k => obj[k] !== undefined)
49
+ .map(k => stableStringify(k) + ':' + stableStringify(obj[k]));
50
+ return '{' + entries.join(',') + '}';
51
+ }
52
+ return JSON.stringify(obj);
53
+ }
54
+ function lengthPrefixedBytesKey(...parts) {
55
+ const chunks = [];
56
+ for (const part of parts) {
57
+ chunks.push(Buffer.from(`${part.byteLength}:`, 'ascii'), Buffer.from(part.buffer, part.byteOffset, part.byteLength), Buffer.from(';', 'ascii'));
58
+ }
59
+ return Buffer.concat(chunks);
60
+ }
61
+ function lengthPrefixedTextKey(...parts) {
62
+ return parts.map((part) => `${Buffer.byteLength(part, 'utf8')}:${part};`).join('');
63
+ }
64
+ function v2WrapCapabilities() {
65
+ return {
66
+ version: 'v2.1',
67
+ protocols: ['1DH', '3DH'],
68
+ scopes: ['aid', 'device'],
69
+ per_aid_wrap: true,
70
+ per_device_wrap: true,
71
+ };
72
+ }
73
+ function computeStateHash(params) {
74
+ const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
75
+ const membershipBlock = sortedMembers.map(m => `${m.aid}:${m.role}`).join('|');
76
+ const sortedPolicy = {};
77
+ for (const key of Object.keys(params.policy).sort()) {
78
+ sortedPolicy[key] = params.policy[key];
79
+ }
80
+ const policyBlock = Object.keys(params.policy).length > 0 ? JSON.stringify(sortedPolicy) : '';
81
+ const prevBytes = params.prevStateHash ? Buffer.from(params.prevStateHash, 'hex') : Buffer.alloc(32);
82
+ const svBuf = Buffer.alloc(8);
83
+ svBuf.writeBigUInt64BE(BigInt(params.stateVersion));
84
+ const keBuf = Buffer.alloc(8);
85
+ keBuf.writeBigUInt64BE(BigInt(params.keyEpoch));
86
+ const data = Buffer.concat([
87
+ Buffer.from(params.groupId, 'utf-8'), Buffer.from([0x00]),
88
+ svBuf, Buffer.from([0x00]),
89
+ keBuf, Buffer.from([0x00]),
90
+ Buffer.from(membershipBlock, 'utf-8'), Buffer.from([0x00]),
91
+ Buffer.from(policyBlock, 'utf-8'), Buffer.from([0x00]),
92
+ prevBytes,
93
+ ]);
94
+ return crypto.createHash('sha256').update(data).digest('hex');
95
+ }
96
+ function normalizedGroupId(raw) {
97
+ const groupId = String(raw ?? '').trim();
98
+ return normalizeGroupId(groupId) || groupId;
99
+ }
100
+ export class GroupStateCoordinator {
101
+ runtime;
102
+ constructor(runtime) {
103
+ this.runtime = runtime;
104
+ }
105
+ get client() {
106
+ return this.runtime.client;
107
+ }
108
+ async postprocessResult(method, params, result) {
109
+ const client = this.client;
110
+ const resultObj = isJsonObject(result) ? result : null;
111
+ if (!MEMBERSHIP_MUTATION_METHODS.has(method) || !resultObj || 'error' in resultObj || !client._v2Session) {
112
+ return result;
113
+ }
114
+ const groupId = this.extractGroupIdFromMutationResult(resultObj, params);
115
+ if (!groupId) {
116
+ return result;
117
+ }
118
+ try {
119
+ await client._v2AutoProposeState(groupId);
120
+ }
121
+ catch (exc) {
122
+ client._clientLog?.debug?.(`V2 post-membership propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
123
+ }
124
+ if (SPK_REGISTRATION_MUTATION_METHODS.has(method)) {
125
+ client._v2E2EE?.scheduleGroupSpkRegistration?.(groupId, { reason: method });
126
+ }
127
+ return result;
128
+ }
129
+ handleGroupChangedV2Membership(data) {
130
+ const client = this.client;
131
+ const groupId = normalizedGroupId(data.group_id);
132
+ const action = String(data.action ?? '').trim();
133
+ if (!groupId) {
134
+ return;
135
+ }
136
+ client._v2E2EE?.deleteBootstrapCacheEntry?.(`group:${groupId}`);
137
+ const membershipAction = MEMBERSHIP_ACTIONS.has(action);
138
+ if (client._v2Session && (action === 'upsert' || membershipAction)) {
139
+ client._safeAsync(client._v2AutoProposeState(groupId, { leaderDelay: true }));
140
+ }
141
+ client._v2E2EE?.handleGroupChangedSpk?.(data, groupId, action);
142
+ }
143
+ async onV2StateProposed(data) {
144
+ const client = this.client;
145
+ const d = isJsonObject(data) ? data : null;
146
+ if (!d || !client._v2Session)
147
+ return;
148
+ const groupId = normalizedGroupId(d.group_id);
149
+ if (!groupId)
150
+ return;
151
+ await client._dispatcher.publish('group.v2.state_proposed', d);
152
+ try {
153
+ await client._v2ConfirmPendingProposal(groupId);
154
+ }
155
+ catch (exc) {
156
+ client._clientLog.debug(`V2 state_proposed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
157
+ }
158
+ }
159
+ async onV2StateRetryNeeded(data) {
160
+ const client = this.client;
161
+ const d = isJsonObject(data) ? data : null;
162
+ if (!d || !client._v2Session)
163
+ return;
164
+ const groupId = normalizedGroupId(d.group_id);
165
+ if (!groupId)
166
+ return;
167
+ await client._dispatcher.publish('group.v2.state_retry_needed', d);
168
+ try {
169
+ await client._v2AutoProposeState(groupId, { leaderDelay: true });
170
+ }
171
+ catch (exc) {
172
+ client._clientLog.debug(`V2 state_retry_needed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
173
+ }
174
+ }
175
+ async onV2StateConfirmed(data) {
176
+ const client = this.client;
177
+ const d = isJsonObject(data) ? data : null;
178
+ if (!d)
179
+ return;
180
+ const groupId = normalizedGroupId(d.group_id);
181
+ if (groupId) {
182
+ client._v2E2EE?.deleteBootstrapCacheEntry?.(`group:${groupId}`);
183
+ client._v2AutoProposeLastSnapshot?.delete?.(groupId);
184
+ }
185
+ await client._dispatcher.publish('group.v2.state_confirmed', d);
186
+ }
187
+ async publishV2GroupSecurityLevel(groupId, bootstrap) {
188
+ const client = this.client;
189
+ const gid = normalizedGroupId(groupId);
190
+ if (!gid)
191
+ return;
192
+ const level = String(bootstrap.e2ee_security_level ?? '').trim() || 'end_to_end';
193
+ if (!(client._v2GroupSecurityLevels instanceof Map)) {
194
+ client._v2GroupSecurityLevels = new Map();
195
+ }
196
+ const previous = client._v2GroupSecurityLevels.get(gid);
197
+ if (previous === level)
198
+ return;
199
+ client._v2GroupSecurityLevels.set(gid, level);
200
+ await client._dispatcher.publish('group.v2.security_level', {
201
+ group_id: gid,
202
+ level,
203
+ warning: String(bootstrap.e2ee_security_warning ?? ''),
204
+ previous_level: previous ?? null,
205
+ });
206
+ }
207
+ async verifyStateSignature(groupId, bootstrap) {
208
+ const client = this.client;
209
+ const gid = normalizedGroupId(groupId);
210
+ if (!gid || !bootstrap)
211
+ return;
212
+ const stateSignature = String(bootstrap.state_signature ?? '');
213
+ const actorAid = String(bootstrap.state_actor_aid ?? '');
214
+ const stateHashSigned = String(bootstrap.state_hash_signed ?? '');
215
+ const membershipSnapshot = String(bootstrap.state_membership_snapshot ?? '');
216
+ const stateVersion = Number(bootstrap.state_version ?? 0) || 0;
217
+ if (stateVersion === 0 || !stateSignature || !actorAid)
218
+ return;
219
+ try {
220
+ const signPayload = stableStringify({
221
+ group_id: gid,
222
+ membership_snapshot: membershipSnapshot,
223
+ state_hash: stateHashSigned,
224
+ state_version: stateVersion,
225
+ });
226
+ const sigBytes = Buffer.from(stateSignature, 'base64');
227
+ const framedPayload = lengthPrefixedBytesKey(Buffer.from(actorAid, 'utf-8'), Buffer.from(signPayload, 'utf-8'));
228
+ const cacheKey = crypto.createHash('sha256')
229
+ .update(framedPayload)
230
+ .update(sigBytes)
231
+ .digest('hex');
232
+ if (!(client._v2SigCache instanceof Map)) {
233
+ client._v2SigCache = new Map();
234
+ }
235
+ const now = Date.now();
236
+ const cachedExp = client._v2SigCache.get(cacheKey);
237
+ if (cachedExp === undefined || cachedExp <= now) {
238
+ const certPem = await client._fetchPeerCert(actorAid);
239
+ const cert = new crypto.X509Certificate(certPem);
240
+ const ok = crypto.verify('SHA256', Buffer.from(signPayload, 'utf-8'), cert.publicKey, sigBytes);
241
+ if (!ok) {
242
+ throw new E2EEError(`V2 state signature verification failed: group=${gid} actor=${actorAid}`);
243
+ }
244
+ client._v2SigCache.set(cacheKey, now + V2_SIG_CACHE_TTL_MS);
245
+ this.pruneSigCache(now);
246
+ }
247
+ else {
248
+ client._clientLog.debug(`V2 state signature cache hit: group=${gid} sv=${stateVersion}`);
249
+ }
250
+ await this.checkMembershipTamper(gid, bootstrap, membershipSnapshot);
251
+ }
252
+ catch (exc) {
253
+ if (exc instanceof E2EEError)
254
+ throw exc;
255
+ throw new E2EEError(`V2 state signature verification failed: ${formatCaughtError(exc)}`);
256
+ }
257
+ }
258
+ async checkFork(groupId, serverChain) {
259
+ const client = this.client;
260
+ const gid = normalizedGroupId(groupId);
261
+ if (!gid || !serverChain)
262
+ return;
263
+ try {
264
+ if (!(client._v2StateChains instanceof Map)) {
265
+ client._v2StateChains = new Map();
266
+ }
267
+ const local = client._v2StateChains.get(gid);
268
+ if (!local) {
269
+ client._v2StateChains.set(gid, [0, serverChain]);
270
+ return;
271
+ }
272
+ const [localSv, localChain] = local;
273
+ if (localChain === serverChain)
274
+ return;
275
+ try {
276
+ const stateResp = await client.call('group.get_state', { group_id: gid });
277
+ if (isJsonObject(stateResp)) {
278
+ const serverSv = Number(stateResp.state_version ?? 0);
279
+ if (serverSv > localSv) {
280
+ client._v2StateChains.set(gid, [serverSv, serverChain]);
281
+ return;
282
+ }
283
+ if (serverSv < localSv) {
284
+ client._clientLog.warn(`V2 state chain rollback detected: group=${gid} server_sv=${serverSv} local_sv=${localSv}`);
285
+ }
286
+ }
287
+ }
288
+ catch {
289
+ // get_state 失败时继续发布 fork 告警。
290
+ }
291
+ client._clientLog.warn(`V2 state chain fork detected: group=${gid} local_chain=${localChain.slice(0, 16)}... server_chain=${serverChain.slice(0, 16)}...`);
292
+ await client._dispatcher.publish('group.v2.fork_detected', {
293
+ group_id: gid,
294
+ local_chain: localChain,
295
+ server_chain: serverChain,
296
+ });
297
+ }
298
+ catch (exc) {
299
+ client._clientLog.debug(`V2 fork check failed (non-fatal): ${formatCaughtError(exc)}`);
300
+ }
301
+ }
302
+ maybeTriggerAutoPropose(groupId) {
303
+ const client = this.client;
304
+ const gid = normalizedGroupId(groupId);
305
+ if (!gid)
306
+ return;
307
+ if (!(client._v2LazyProposeTriggered instanceof Map)) {
308
+ client._v2LazyProposeTriggered = new Map();
309
+ }
310
+ const now = Date.now();
311
+ const last = client._v2LazyProposeTriggered.get(gid) ?? 0;
312
+ if (now - last < 10000)
313
+ return;
314
+ client._v2LazyProposeTriggered.set(gid, now);
315
+ client._safeAsync(client._v2AutoProposeState(gid, { leaderDelay: true }));
316
+ }
317
+ async onGroupStateCommitted(data) {
318
+ const client = this.client;
319
+ const tStart = Date.now();
320
+ if (!isJsonObject(data)) {
321
+ client._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
322
+ return;
323
+ }
324
+ const d = data;
325
+ const groupId = String(d.group_id ?? '').trim();
326
+ if (!groupId) {
327
+ client._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (no group_id)`);
328
+ return;
329
+ }
330
+ client._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupId}, state_version=${String(d.state_version ?? '')}`);
331
+ try {
332
+ const cs = d.client_signature;
333
+ if (cs && isJsonObject(cs)) {
334
+ if (client._shouldSkipEventSignature(d)) {
335
+ delete d.client_signature;
336
+ }
337
+ else {
338
+ const verified = await client._verifyEventSignatureAsync(d, cs);
339
+ if (verified === false) {
340
+ client._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
341
+ return;
342
+ }
343
+ d._verified = verified;
344
+ }
345
+ }
346
+ const stateVersion = Number(d.state_version ?? 0);
347
+ const stateHash = String(d.state_hash ?? '').trim();
348
+ const prevStateHash = String(d.prev_state_hash ?? '').trim();
349
+ const keyEpoch = Number(d.key_epoch ?? 0);
350
+ const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
351
+ const policySnapshot = String(d.policy_snapshot ?? '').trim();
352
+ const loadFn = client._tokenStore.loadGroupState;
353
+ const localState = loadFn ? loadFn.call(client._tokenStore, groupId) : null;
354
+ if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
355
+ client._clientLog.warn(`state_hash chain discontinuous group=${groupId} local_sv=${localState.state_version} event_sv=${stateVersion}`);
356
+ try {
357
+ const serverState = await client._transport.call('group.get_state', { group_id: groupId });
358
+ if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
359
+ const stateObj = serverState;
360
+ const sv = Number(stateObj.state_version ?? 0);
361
+ const sHash = String(stateObj.state_hash ?? '');
362
+ const sEpoch = Number(stateObj.key_epoch ?? 0);
363
+ const sMembersJson = String(stateObj.membership_snapshot ?? '');
364
+ const sPolicyJson = String(stateObj.policy_snapshot ?? '');
365
+ const sPrev = String(stateObj.prev_state_hash ?? '');
366
+ if (sMembersJson && sHash) {
367
+ const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
368
+ const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
369
+ const computed = computeStateHash({
370
+ groupId, stateVersion: sv, keyEpoch: sEpoch,
371
+ members: sMembers, policy: sPolicy, prevStateHash: sPrev,
372
+ });
373
+ if (computed !== sHash) {
374
+ client._clientLog.warn(`backfill state_hash verification failed group=${groupId} sv=${sv} expected=${sHash} got=${computed}`);
375
+ return;
376
+ }
377
+ }
378
+ const saveFn = client._tokenStore.saveGroupState;
379
+ if (saveFn) {
380
+ saveFn.call(client._tokenStore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
381
+ }
382
+ }
383
+ }
384
+ catch (exc) {
385
+ client._clientLog.warn(`state backfill failed group=${groupId}: ${formatCaughtError(exc)}`);
386
+ }
387
+ return;
388
+ }
389
+ const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
390
+ const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
391
+ const computed = computeStateHash({
392
+ groupId, stateVersion, keyEpoch,
393
+ members, policy, prevStateHash,
394
+ });
395
+ if (computed !== stateHash) {
396
+ client._clientLog.warn(`state_hash recompute mismatch group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
397
+ return;
398
+ }
399
+ const saveFn = client._tokenStore.saveGroupState;
400
+ if (saveFn) {
401
+ saveFn.call(client._tokenStore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
402
+ }
403
+ client._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
404
+ }
405
+ catch (err) {
406
+ client._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
407
+ throw err;
408
+ }
409
+ }
410
+ async autoProposeState(groupId, options) {
411
+ const client = this.client;
412
+ const gid = normalizedGroupId(groupId);
413
+ if (!gid)
414
+ return;
415
+ if (options?.leaderDelay) {
416
+ const shouldContinue = await client._v2AutoProposeLeaderDelay(gid);
417
+ if (!shouldContinue)
418
+ return;
419
+ }
420
+ const inflight = client._v2AutoProposeInflight.get(gid);
421
+ if (inflight) {
422
+ client._v2AutoProposePending.add(gid);
423
+ await inflight;
424
+ return;
425
+ }
426
+ let resolveTask;
427
+ let rejectTask;
428
+ const task = new Promise((resolve, reject) => {
429
+ resolveTask = resolve;
430
+ rejectTask = reject;
431
+ });
432
+ client._v2AutoProposeInflight.set(gid, task);
433
+ void (async () => {
434
+ try {
435
+ do {
436
+ client._v2AutoProposePending.delete(gid);
437
+ await client._doV2AutoProposeState(gid);
438
+ } while (client._v2AutoProposePending.delete(gid));
439
+ resolveTask();
440
+ }
441
+ catch (exc) {
442
+ rejectTask(exc);
443
+ }
444
+ finally {
445
+ if (client._v2AutoProposeInflight.get(gid) === task) {
446
+ client._v2AutoProposeInflight.delete(gid);
447
+ }
448
+ client._v2AutoProposePending.delete(gid);
449
+ }
450
+ })();
451
+ try {
452
+ await task;
453
+ }
454
+ finally {
455
+ if (client._v2AutoProposeInflight.get(gid) === task) {
456
+ client._v2AutoProposeInflight.delete(gid);
457
+ }
458
+ client._v2AutoProposePending.delete(gid);
459
+ }
460
+ }
461
+ leaderDelayMs(input) {
462
+ let h = 2166136261;
463
+ for (let i = 0; i < input.length; i++) {
464
+ h ^= input.charCodeAt(i);
465
+ h = Math.imul(h, 16777619);
466
+ }
467
+ return 2000 + ((h >>> 0) % 4000);
468
+ }
469
+ async autoProposeLeaderDelay(groupId) {
470
+ const client = this.client;
471
+ try {
472
+ const membersResp = await client.call('group.get_online_members', { group_id: groupId });
473
+ const members = isJsonObject(membersResp)
474
+ ? (Array.isArray(membersResp.members) ? membersResp.members
475
+ : Array.isArray(membersResp.items) ? membersResp.items
476
+ : Array.isArray(membersResp.online_members) ? membersResp.online_members : [])
477
+ : [];
478
+ if (!Array.isArray(members))
479
+ return true;
480
+ const myAid = client._aid ?? '';
481
+ let myRole = '';
482
+ const onlineAdminAids = new Set();
483
+ for (const item of members) {
484
+ if (!isJsonObject(item))
485
+ continue;
486
+ const aid = String(item.aid ?? '').trim();
487
+ const role = String(item.role ?? '').trim();
488
+ if (!aid)
489
+ continue;
490
+ if ('online' in item && !Boolean(item.online))
491
+ continue;
492
+ if (role === 'owner' || role === 'admin')
493
+ onlineAdminAids.add(aid);
494
+ if (aid === myAid)
495
+ myRole = role;
496
+ }
497
+ if (myRole !== 'owner' && myRole !== 'admin')
498
+ return false;
499
+ const bootstrapResp = await client.call('group.v2.bootstrap', {
500
+ group_id: groupId,
501
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
502
+ });
503
+ const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
504
+ ? bootstrapResp.devices.filter((item) => isJsonObject(item))
505
+ : [];
506
+ const candidates = [];
507
+ for (const dev of devices) {
508
+ const aid = String(dev.aid ?? '').trim();
509
+ const hasDeviceId = 'device_id' in dev;
510
+ const deviceId = String(dev.device_id ?? '').trim();
511
+ if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
512
+ candidates.push(`${aid}\x1f${deviceId}`);
513
+ }
514
+ }
515
+ if (candidates.length === 0) {
516
+ for (const aid of [...onlineAdminAids].sort())
517
+ candidates.push(`${aid}\x1f`);
518
+ }
519
+ const myKey = `${myAid}\x1f${client._deviceId ?? ''}`;
520
+ if (!candidates.includes(myKey))
521
+ candidates.push(myKey);
522
+ const leader = [...new Set(candidates)].sort()[0];
523
+ if (leader === myKey) {
524
+ client._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
525
+ return true;
526
+ }
527
+ const delayMs = client._v2LeaderDelayMs(lengthPrefixedTextKey(groupId, myKey));
528
+ client._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
529
+ await client._sleep(delayMs);
530
+ return true;
531
+ }
532
+ catch (exc) {
533
+ client._clientLog.debug(`V2 auto propose leader check failed, fallback immediate: group=${groupId} err=${formatCaughtError(exc)}`);
534
+ return true;
535
+ }
536
+ }
537
+ verifyCommittedStateBase(groupId, stateResp) {
538
+ const client = this.client;
539
+ const currentSv = Number(stateResp.state_version ?? 0) || 0;
540
+ if (currentSv <= 0)
541
+ return true;
542
+ const currentSh = String(stateResp.state_hash ?? '').trim();
543
+ const membershipSnapshot = String(stateResp.membership_snapshot ?? '').trim();
544
+ if (!currentSh || !membershipSnapshot) {
545
+ client._clientLog.warn(`V2 committed state base incomplete: group=${groupId} sv=${currentSv}`);
546
+ return false;
547
+ }
548
+ try {
549
+ const parsed = JSON.parse(membershipSnapshot);
550
+ if (!isJsonObject(parsed)) {
551
+ client._clientLog.warn(`V2 committed state base snapshot is not object: group=${groupId} sv=${currentSv}`);
552
+ return false;
553
+ }
554
+ const computed = computeStateCommitment(groupId, currentSv, parsed);
555
+ if (computed !== currentSh) {
556
+ client._clientLog.warn(`V2 committed state base hash mismatch: group=${groupId} sv=${currentSv}`);
557
+ return false;
558
+ }
559
+ return true;
560
+ }
561
+ catch (exc) {
562
+ client._clientLog.warn(`V2 committed state base verification failed: group=${groupId} sv=${currentSv} err=${formatCaughtError(exc)}`);
563
+ return false;
564
+ }
565
+ }
566
+ async doAutoProposeState(groupId) {
567
+ const client = this.client;
568
+ try {
569
+ const myAid = client._aid ?? '';
570
+ if (!myAid)
571
+ return;
572
+ const membersResp = await client.call('group.get_members', { group_id: groupId });
573
+ const members = isJsonObject(membersResp)
574
+ ? (Array.isArray(membersResp.members) ? membersResp.members : membersResp.items)
575
+ : [];
576
+ if (!Array.isArray(members))
577
+ return;
578
+ let myRole = '';
579
+ const memberAids = [];
580
+ const adminAids = [];
581
+ for (const item of members) {
582
+ if (!isJsonObject(item))
583
+ continue;
584
+ const aid = String(item.aid ?? '').trim();
585
+ const role = String(item.role ?? '').trim();
586
+ if (!aid)
587
+ continue;
588
+ memberAids.push(aid);
589
+ if (role === 'owner' || role === 'admin')
590
+ adminAids.push(aid);
591
+ if (aid === myAid)
592
+ myRole = role;
593
+ }
594
+ if (myRole !== 'owner' && myRole !== 'admin')
595
+ return;
596
+ const proposalResp = await client.call('group.v2.get_proposal', { group_id: groupId });
597
+ if (isJsonObject(proposalResp)) {
598
+ const pendingProposal = proposalResp.proposal;
599
+ if (isJsonObject(pendingProposal) && String(pendingProposal.proposal_id ?? '').trim()) {
600
+ const confirmed = await client._v2ConfirmPendingProposal(groupId);
601
+ if (confirmed)
602
+ return;
603
+ const autoConfirmAt = Number(pendingProposal.auto_confirm_at ?? 0) || 0;
604
+ const nowMs = Date.now();
605
+ if (autoConfirmAt > nowMs) {
606
+ const waitMs = Math.min(autoConfirmAt - nowMs + 500, 35000);
607
+ client._clientLog.debug(`V2 auto propose: pending proposal exists, waiting ${waitMs}ms group=${groupId}`);
608
+ await new Promise((r) => setTimeout(r, waitMs));
609
+ }
610
+ }
611
+ }
612
+ const bootstrapResp = await client.call('group.v2.bootstrap', {
613
+ group_id: groupId,
614
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
615
+ });
616
+ const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
617
+ ? bootstrapResp.devices.filter((item) => isJsonObject(item))
618
+ : [];
619
+ const auditRecipients = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.audit_recipients)
620
+ ? bootstrapResp.audit_recipients.filter((item) => isJsonObject(item))
621
+ : [];
622
+ const auditAids = [...new Set(auditRecipients.map((item) => String(item.aid ?? '').trim()).filter(Boolean))].sort();
623
+ const membersWithDevices = {};
624
+ for (const aid of memberAids)
625
+ membersWithDevices[aid] = [];
626
+ for (const dev of allDevices) {
627
+ const aid = String(dev.aid ?? '').trim();
628
+ if (aid in membersWithDevices) {
629
+ membersWithDevices[aid].push({
630
+ device_id: String(dev.device_id ?? ''),
631
+ ik_fp: String(dev.ik_fp ?? ''),
632
+ });
633
+ }
634
+ }
635
+ const statePayload = {
636
+ members: Object.entries(membersWithDevices).map(([aid, devices]) => ({ aid, devices })),
637
+ audit_aids: auditAids,
638
+ admin_set: { admin_aids: adminAids.sort(), threshold: 1 },
639
+ join_policy_hash: null,
640
+ recovery_quorum: null,
641
+ history_policy: 'recent_7_days',
642
+ wrap_protocol: '3DH',
643
+ };
644
+ const stateResp = await client.call('group.get_state', { group_id: groupId });
645
+ if (!isJsonObject(stateResp))
646
+ return;
647
+ if (!this.verifyCommittedStateBase(groupId, stateResp))
648
+ return;
649
+ const currentSv = Number(stateResp.state_version ?? 0) || 0;
650
+ const currentSh = String(stateResp.state_hash ?? '');
651
+ const keyEpoch = Number(stateResp.key_epoch ?? 0) || 0;
652
+ const stateHash = computeStateCommitment(groupId, currentSv + 1, statePayload);
653
+ const membershipSnapshot = stableStringify(statePayload);
654
+ const lastMembershipSnapshot = client._v2AutoProposeLastSnapshot.get(groupId);
655
+ if (lastMembershipSnapshot === membershipSnapshot)
656
+ return;
657
+ const currentMembershipSnapshot = String(stateResp.membership_snapshot ?? '');
658
+ if (currentMembershipSnapshot && currentMembershipSnapshot === membershipSnapshot) {
659
+ client._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
660
+ return;
661
+ }
662
+ let signature = '';
663
+ const privateKeyPem = client._currentAid?.privateKeyPem ?? '';
664
+ if (privateKeyPem) {
665
+ try {
666
+ const signPayload = stableStringify({
667
+ group_id: groupId,
668
+ membership_snapshot: membershipSnapshot,
669
+ state_hash: stateHash,
670
+ state_version: currentSv + 1,
671
+ });
672
+ const key = crypto.createPrivateKey(privateKeyPem);
673
+ signature = crypto.sign('SHA256', Buffer.from(signPayload, 'utf-8'), key).toString('base64');
674
+ }
675
+ catch (exc) {
676
+ client._clientLog.debug(`V2 propose_state signature failed: ${formatCaughtError(exc)}`);
677
+ }
678
+ }
679
+ const propose = await client.call('group.v2.propose_state', {
680
+ group_id: groupId,
681
+ state_version: currentSv + 1,
682
+ key_epoch: keyEpoch,
683
+ state_hash: stateHash,
684
+ prev_state_hash: currentSh,
685
+ membership_snapshot: membershipSnapshot,
686
+ signature,
687
+ reason: 'membership_changed',
688
+ auto_confirm_seconds: 30,
689
+ });
690
+ const proposalId = isJsonObject(propose) ? String(propose.proposal_id ?? '').trim() : '';
691
+ if (proposalId) {
692
+ try {
693
+ await client.call('group.v2.confirm_state', { proposal_id: proposalId });
694
+ client._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
695
+ }
696
+ catch (exc) {
697
+ client._clientLog.debug(`V2 auto confirm_state failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
698
+ }
699
+ }
700
+ }
701
+ catch (exc) {
702
+ client._clientLog.debug(`V2 auto propose_state failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
703
+ }
704
+ }
705
+ verifyPendingProposalAgainstBase(groupId, proposal, stateResp) {
706
+ const client = this.client;
707
+ if (!this.verifyCommittedStateBase(groupId, stateResp))
708
+ return false;
709
+ const currentSv = Number(stateResp.state_version ?? 0) || 0;
710
+ const currentSh = String(stateResp.state_hash ?? '').trim();
711
+ const proposalSv = Number(proposal.state_version ?? 0) || 0;
712
+ const proposalHash = String(proposal.state_hash ?? '').trim();
713
+ const proposalPrev = String(proposal.prev_state_hash ?? '').trim();
714
+ const membershipSnapshot = String(proposal.membership_snapshot ?? '').trim();
715
+ if (proposalSv !== currentSv + 1 || proposalPrev !== currentSh || !proposalHash || !membershipSnapshot) {
716
+ client._clientLog.warn(`V2 pending proposal base mismatch: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
717
+ return false;
718
+ }
719
+ try {
720
+ const parsed = JSON.parse(membershipSnapshot);
721
+ if (!isJsonObject(parsed))
722
+ return false;
723
+ const computed = computeStateCommitment(groupId, proposalSv, parsed);
724
+ if (computed !== proposalHash) {
725
+ client._clientLog.warn(`V2 pending proposal hash mismatch: group=${groupId} proposal_sv=${proposalSv}`);
726
+ return false;
727
+ }
728
+ return true;
729
+ }
730
+ catch (exc) {
731
+ client._clientLog.warn(`V2 pending proposal verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
732
+ return false;
733
+ }
734
+ }
735
+ async confirmPendingProposal(groupId) {
736
+ const client = this.client;
737
+ const proposalResp = await client.call('group.v2.get_proposal', { group_id: groupId });
738
+ const proposal = isJsonObject(proposalResp)
739
+ && isJsonObject(proposalResp.proposal)
740
+ ? proposalResp.proposal
741
+ : null;
742
+ const proposalId = proposal ? String(proposal.proposal_id ?? '').trim() : '';
743
+ if (!proposal || !proposalId)
744
+ return false;
745
+ const stateResp = await client.call('group.get_state', { group_id: groupId });
746
+ if (!isJsonObject(stateResp))
747
+ return false;
748
+ const currentSv = Number(stateResp.state_version ?? 0) || 0;
749
+ const proposalSv = Number(proposal.state_version ?? 0) || 0;
750
+ if (proposalSv <= currentSv) {
751
+ client._clientLog.debug(`V2 pending proposal already settled: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
752
+ return false;
753
+ }
754
+ if (!this.verifyPendingProposalAgainstBase(groupId, proposal, stateResp))
755
+ return false;
756
+ await client.call('group.v2.confirm_state', { proposal_id: proposalId });
757
+ client._clientLog.info(`V2 confirmed pending proposal: group=${groupId} proposal=${proposalId}`);
758
+ return true;
759
+ }
760
+ async autoConfirmPendingProposals() {
761
+ const client = this.client;
762
+ try {
763
+ const myAid = client._aid ?? '';
764
+ if (!myAid)
765
+ return;
766
+ const groupsResp = await client.call('group.list_my', {});
767
+ const groups = isJsonObject(groupsResp)
768
+ ? (Array.isArray(groupsResp.groups) ? groupsResp.groups : groupsResp.items)
769
+ : [];
770
+ if (!Array.isArray(groups))
771
+ return;
772
+ for (const group of groups) {
773
+ if (!isJsonObject(group))
774
+ continue;
775
+ const groupId = String(group.group_id ?? '').trim();
776
+ const myRole = String(group.role ?? group.my_role ?? '').trim();
777
+ if (!groupId || (myRole !== 'owner' && myRole !== 'admin'))
778
+ continue;
779
+ try {
780
+ const confirmed = await client._v2ConfirmPendingProposal(groupId);
781
+ if (!confirmed) {
782
+ await client._v2AutoProposeState(groupId);
783
+ }
784
+ }
785
+ catch (exc) {
786
+ client._clientLog.debug(`V2 auto confirm/propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
787
+ }
788
+ }
789
+ }
790
+ catch (exc) {
791
+ client._clientLog.debug(`V2 auto confirm pending proposals failed (non-fatal): ${formatCaughtError(exc)}`);
792
+ }
793
+ }
794
+ extractGroupIdFromMutationResult(result, params) {
795
+ const client = this.client;
796
+ const extracted = typeof client._extractGroupIdFromResult === 'function'
797
+ ? client._extractGroupIdFromResult(result)
798
+ : '';
799
+ return normalizedGroupId(extracted || params.group_id || '');
800
+ }
801
+ pruneSigCache(now) {
802
+ const client = this.client;
803
+ if (!(client._v2SigCache instanceof Map) || client._v2SigCache.size <= V2_SIG_CACHE_MAX)
804
+ return;
805
+ for (const [key, exp] of client._v2SigCache) {
806
+ if (exp <= now)
807
+ client._v2SigCache.delete(key);
808
+ }
809
+ if (client._v2SigCache.size <= V2_SIG_CACHE_MAX)
810
+ return;
811
+ const entries = [...client._v2SigCache.entries()].sort((a, b) => a[1] - b[1]);
812
+ const evictCount = Math.floor(V2_SIG_CACHE_MAX / 4);
813
+ for (let i = 0; i < evictCount && i < entries.length; i++) {
814
+ client._v2SigCache.delete(entries[i][0]);
815
+ }
816
+ }
817
+ async checkMembershipTamper(groupId, bootstrap, membershipSnapshot) {
818
+ const client = this.client;
819
+ try {
820
+ if (!membershipSnapshot.startsWith('['))
821
+ return;
822
+ const signedSnapshot = JSON.parse(membershipSnapshot);
823
+ if (!Array.isArray(signedSnapshot))
824
+ return;
825
+ const signedMembers = new Set(signedSnapshot.map((item) => String(item)));
826
+ const serverMembers = Array.isArray(bootstrap.member_aids)
827
+ ? bootstrap.member_aids.map((item) => String(item))
828
+ : [];
829
+ const extra = serverMembers.filter((aid) => !signedMembers.has(aid));
830
+ if (extra.length === 0)
831
+ return;
832
+ let mode = '';
833
+ try {
834
+ const req = await client.call('group.get_join_requirements', { group_id: groupId });
835
+ mode = isJsonObject(req) ? String(req.mode ?? '') : '';
836
+ }
837
+ catch {
838
+ mode = '';
839
+ }
840
+ if (!['open', 'invite_code', 'invite_only'].includes(mode)) {
841
+ await client._dispatcher.publish('group.v2.state_tampered', {
842
+ group_id: groupId,
843
+ pending_extra: extra.sort(),
844
+ mode,
845
+ });
846
+ }
847
+ }
848
+ catch {
849
+ // snapshot 解析失败不阻断已完成的签名验证。
850
+ }
851
+ }
852
+ }
853
+ //# sourceMappingURL=group-state.js.map