@agentunion/fastaun 0.2.15 → 0.2.17

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 (44) hide show
  1. package/dist/auth.d.ts +3 -0
  2. package/dist/auth.js +28 -25
  3. package/dist/auth.js.map +1 -1
  4. package/dist/client.d.ts +48 -7
  5. package/dist/client.js +1079 -280
  6. package/dist/client.js.map +1 -1
  7. package/dist/config.d.ts +2 -0
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/e2ee-group.d.ts +36 -1
  11. package/dist/e2ee-group.js +134 -23
  12. package/dist/e2ee-group.js.map +1 -1
  13. package/dist/e2ee.d.ts +3 -0
  14. package/dist/e2ee.js +12 -4
  15. package/dist/e2ee.js.map +1 -1
  16. package/dist/events.d.ts +3 -0
  17. package/dist/events.js +11 -1
  18. package/dist/events.js.map +1 -1
  19. package/dist/group-id.d.ts +23 -0
  20. package/dist/group-id.js +94 -0
  21. package/dist/group-id.js.map +1 -0
  22. package/dist/keystore/aid-db.d.ts +11 -0
  23. package/dist/keystore/aid-db.js +49 -2
  24. package/dist/keystore/aid-db.js.map +1 -1
  25. package/dist/keystore/file.d.ts +5 -0
  26. package/dist/keystore/file.js +12 -6
  27. package/dist/keystore/file.js.map +1 -1
  28. package/dist/keystore/index.d.ts +14 -0
  29. package/dist/keystore/sqlite-backup.d.ts +5 -1
  30. package/dist/keystore/sqlite-backup.js +9 -6
  31. package/dist/keystore/sqlite-backup.js.map +1 -1
  32. package/dist/logger.d.ts +26 -3
  33. package/dist/logger.js +117 -40
  34. package/dist/logger.js.map +1 -1
  35. package/dist/secret-store/file-store.d.ts +4 -0
  36. package/dist/secret-store/file-store.js +5 -2
  37. package/dist/secret-store/file-store.js.map +1 -1
  38. package/dist/secret-store/index.d.ts +3 -0
  39. package/dist/secret-store/index.js +2 -2
  40. package/dist/secret-store/index.js.map +1 -1
  41. package/dist/transport.d.ts +3 -0
  42. package/dist/transport.js +9 -1
  43. package/dist/transport.js.map +1 -1
  44. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -19,12 +19,13 @@ import { configFromMap, getDeviceId, normalizeInstanceId } from './config.js';
19
19
  import { CryptoProvider } from './crypto.js';
20
20
  import { GatewayDiscovery } from './discovery.js';
21
21
  import { E2EEManager } from './e2ee.js';
22
- import { GroupE2EEManager, computeMembershipCommitment, storeGroupSecret, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, } from './e2ee-group.js';
22
+ import { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, } from './e2ee-group.js';
23
23
  import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
24
24
  import { EventDispatcher } from './events.js';
25
25
  import { FileKeyStore } from './keystore/file.js';
26
26
  import { AUNLogger } from './logger.js';
27
27
  import { SQLiteBackup } from './keystore/sqlite-backup.js';
28
+ import { normalizeGroupId } from './group-id.js';
28
29
  import { AuthNamespace } from './namespaces/auth.js';
29
30
  import { CustodyNamespace } from './namespaces/custody.js';
30
31
  import { MetaNamespace } from './namespaces/meta.js';
@@ -32,18 +33,6 @@ import { RPCTransport } from './transport.js';
32
33
  import { AuthFlow } from './auth.js';
33
34
  import { SeqTracker } from './seq-tracker.js';
34
35
  import { isJsonObject, } from './types.js';
35
- // ── 日志辅助 ──────────────────────────────────────────────────
36
- /** 文件日志(模块级单例) */
37
- let _debugLogger = null;
38
- /** 简易日志:前缀 [aun_core.client] */
39
- function _clientLog(level, msg, ...args) {
40
- const ts = new Date().toISOString();
41
- const formatted = args.reduce((s, a) => s.replace('%s', String(a)), msg);
42
- // eslint-disable-next-line no-console
43
- console.log(`[${ts}] [aun_core.client] ${level}: ${formatted}`);
44
- if (_debugLogger)
45
- _debugLogger.log(`${level}: ${formatted}`);
46
- }
47
36
  /**
48
37
  * 递归排序键的 JSON 序列化(Canonical JSON for AUN)
49
38
  * 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
@@ -83,7 +72,7 @@ const INTERNAL_ONLY_METHODS = new Set([
83
72
  const DEFAULT_SESSION_OPTIONS = {
84
73
  auto_reconnect: true,
85
74
  heartbeat_interval: 30.0,
86
- token_refresh_before: 60.0,
75
+ token_refresh_before: 1800.0,
87
76
  retry: {
88
77
  initial_delay: 1.0,
89
78
  max_delay: 64.0,
@@ -98,6 +87,7 @@ const DEFAULT_SESSION_OPTIONS = {
98
87
  };
99
88
  const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
100
89
  const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
90
+ const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
101
91
  const GROUP_ROTATION_LEASE_MS = 120_000;
102
92
  const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
103
93
  const PENDING_DECRYPT_LIMIT = 100;
@@ -139,9 +129,15 @@ const SIGNED_METHODS = new Set([
139
129
  'group.resources.delete', 'group.resources.request_add',
140
130
  'group.resources.direct_add', 'group.resources.approve_request',
141
131
  'group.resources.reject_request',
132
+ 'group.commit_state',
133
+ 'group.e2ee.begin_rotation', 'group.e2ee.commit_rotation',
134
+ 'group.e2ee.abort_rotation',
135
+ 'group.ban', 'group.unban',
136
+ 'group.dissolve', 'group.suspend', 'group.resume',
142
137
  ]);
143
- /** peer 证书缓存 TTL(10 分钟) */
144
- const PEER_CERT_CACHE_TTL = 600;
138
+ /** peer 证书缓存 TTL(1 小时) */
139
+ const PEER_CERT_CACHE_TTL = 3600;
140
+ const PEER_PREKEYS_CACHE_TTL = 3600;
145
141
  const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
146
142
  function isGroupServiceAid(value) {
147
143
  const text = String(value ?? '').trim();
@@ -213,6 +209,19 @@ function normalizePeerPrekeys(prekeys) {
213
209
  }
214
210
  return filtered;
215
211
  }
212
+ /** 判断加密失败是否由过期的对端证书或 prekey 引起,可通过刷新缓存重试 */
213
+ function isRetryablePeerMaterialError(error) {
214
+ const localCode = String(error?.localCode ?? error?.code ?? '').trim();
215
+ if (localCode === 'PEER_CERT_FINGERPRINT_MISMATCH'
216
+ || localCode === 'PREKEY_CERT_FINGERPRINT_MISMATCH'
217
+ || localCode === 'PREKEY_SIGNATURE_VERIFY_FAILED') {
218
+ return true;
219
+ }
220
+ const message = error instanceof Error ? error.message : String(error ?? '');
221
+ return message.includes('peer cert fingerprint mismatch for ')
222
+ || message.includes('prekey cert fingerprint mismatch')
223
+ || message.includes('prekey 签名验证失败');
224
+ }
216
225
  function formatCaughtError(error) {
217
226
  return error instanceof Error ? error : String(error);
218
227
  }
@@ -352,6 +361,8 @@ export class AUNClient {
352
361
  _groupEpochRotationInflight = new Set();
353
362
  _groupEpochRecoveryInflight = new Map();
354
363
  _groupMembershipRotationDone = new Set();
364
+ /** 群密钥 backfill 去重:已完成/进行中的 key 集合,防止重复分发 */
365
+ _groupMemberKeyBackfillDone = new Set();
355
366
  _groupEpochRotationRetryTimers = new Map();
356
367
  // ── 后台任务定时器 ──────────────────────────────────────────
357
368
  _heartbeatTimer = null;
@@ -364,6 +375,8 @@ export class AUNClient {
364
375
  _reconnectActive = false;
365
376
  _reconnectAbort = null;
366
377
  _serverKicked = false;
378
+ _logger;
379
+ _clientLog;
367
380
  constructor(config, debug = false) {
368
381
  const rawConfig = { ...(config ?? {}) };
369
382
  this._configModel = configFromMap(rawConfig);
@@ -372,20 +385,27 @@ export class AUNClient {
372
385
  root_ca_path: this._configModel.rootCaPath,
373
386
  seed_password: this._configModel.seedPassword,
374
387
  };
375
- this._dispatcher = new EventDispatcher();
388
+ // 初始化 Logger(per-client 单例,必须最早创建)
389
+ const debugFlag = this._configModel.debug || debug;
390
+ this._logger = new AUNLogger({
391
+ debug: debugFlag,
392
+ aunPath: this._configModel.aunPath,
393
+ });
394
+ this._clientLog = this._logger.for('aun_core.client');
395
+ if (debugFlag) {
396
+ this._clientLog.info(`AUNClient 初始化完成 (debug=true, aunPath=${this._configModel.aunPath})`);
397
+ }
398
+ this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
376
399
  this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl });
377
- const defaultSQLiteBackup = new SQLiteBackup(join(this._configModel.aunPath, '.aun_backup', 'aun_backup.db'));
400
+ const defaultSQLiteBackup = new SQLiteBackup(join(this._configModel.aunPath, '.aun_backup', 'aun_backup.db'), { logger: this._logger.for('aun_core.keystore') });
378
401
  const keystore = new FileKeyStore(this._configModel.aunPath, {
379
402
  encryptionSeed: this._configModel.seedPassword ?? undefined,
380
403
  sqliteBackup: defaultSQLiteBackup,
404
+ logger: this._logger.for('aun_core.keystore'),
405
+ secretStoreLogger: this._logger.for('aun_core.secret-store'),
381
406
  });
382
407
  this._keystore = keystore;
383
408
  this._deviceId = getDeviceId(this._configModel.aunPath);
384
- // 初始化文件日志(仅 debug 模式)
385
- if (debug) {
386
- _debugLogger = new AUNLogger();
387
- _clientLog('info', 'AUNClient 初始化完成 (debug=true, aunPath=%s)', this._configModel.aunPath);
388
- }
389
409
  this._slotId = '';
390
410
  this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
391
411
  this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
@@ -397,24 +417,28 @@ export class AUNClient {
397
417
  slotId: this._slotId,
398
418
  rootCaPath: this._configModel.rootCaPath ?? undefined,
399
419
  verifySsl: this._configModel.verifySsl,
420
+ logger: this._logger.for('aun_core.auth'),
400
421
  });
401
422
  this._transport = new RPCTransport({
402
423
  eventDispatcher: this._dispatcher,
403
424
  timeout: 10_000,
404
425
  onDisconnect: (err, closeCode) => this._handleTransportDisconnect(err, closeCode),
405
426
  verifySsl: this._configModel.verifySsl,
427
+ logger: this._logger.for('aun_core.transport'),
406
428
  });
407
429
  this._e2ee = new E2EEManager({
408
430
  identityFn: () => this._identity ?? {},
409
431
  deviceIdFn: () => this._deviceId,
410
432
  keystore,
411
433
  replayWindowSeconds: this._configModel.replayWindowSeconds,
434
+ logger: this._logger.for('aun_core.e2ee'),
412
435
  });
413
436
  this._groupE2ee = new GroupE2EEManager({
414
437
  identityFn: () => this._identity ?? {},
415
438
  keystore,
416
439
  senderCertResolver: (aid) => this._getVerifiedPeerCert(aid),
417
440
  initiatorCertResolver: (aid) => this._getVerifiedPeerCert(aid),
441
+ logger: this._logger.for('aun_core.e2ee-group'),
418
442
  });
419
443
  this.auth = new AuthNamespace(this);
420
444
  this.custody = new CustodyNamespace(this);
@@ -425,6 +449,8 @@ export class AUNClient {
425
449
  this._dispatcher.subscribe('_raw.group.message_created', (data) => this._onRawGroupMessageCreated(data));
426
450
  // 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
427
451
  this._dispatcher.subscribe('_raw.group.changed', (data) => this._onRawGroupChanged(data));
452
+ // 群组状态提交事件:验证 state_hash 链并更新本地存储
453
+ this._dispatcher.subscribe('_raw.group.state_committed', (data) => this._onGroupStateCommitted(data));
428
454
  // 其他事件直接透传
429
455
  for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
430
456
  this._dispatcher.subscribe(`_raw.${evt}`, (data) => this._dispatcher.publish(evt, data));
@@ -468,6 +494,7 @@ export class AUNClient {
468
494
  if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
469
495
  throw new StateError(`connect not allowed in state ${this._state}`);
470
496
  }
497
+ this._state = 'connecting';
471
498
  const params = { ...auth };
472
499
  if (options)
473
500
  Object.assign(params, options);
@@ -477,7 +504,16 @@ export class AUNClient {
477
504
  const callTimeoutSec = this._sessionOptions.timeouts.call;
478
505
  this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
479
506
  this._closing = false;
480
- await this._connectOnce(normalized, false);
507
+ try {
508
+ await this._connectOnce(normalized, false);
509
+ }
510
+ catch (err) {
511
+ // 连接失败时回退状态,允许重试
512
+ if (this._state === 'connecting' || this._state === 'authenticating') {
513
+ this._state = 'disconnected';
514
+ }
515
+ throw err;
516
+ }
481
517
  }
482
518
  /** 关闭连接 */
483
519
  async close() {
@@ -489,6 +525,7 @@ export class AUNClient {
489
525
  const closableKeyStore = this._keystore;
490
526
  closableKeyStore.close?.();
491
527
  this._state = 'closed';
528
+ this._logger.close();
492
529
  this._resetSeqTrackingState();
493
530
  return;
494
531
  }
@@ -496,6 +533,7 @@ export class AUNClient {
496
533
  const closableKeyStore = this._keystore;
497
534
  closableKeyStore.close?.();
498
535
  this._state = 'closed';
536
+ this._logger.close();
499
537
  await this._dispatcher.publish('connection.state', { state: this._state });
500
538
  this._resetSeqTrackingState();
501
539
  }
@@ -555,6 +593,11 @@ export class AUNClient {
555
593
  const p = { ...(params ?? {}) };
556
594
  this._validateOutboundCall(method, p);
557
595
  this._injectMessageCursorContext(method, p);
596
+ // group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
597
+ // 不对无域输入补本域——保持与历史行为兼容,交给服务端归一化
598
+ if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null && p.group_id !== '') {
599
+ p.group_id = normalizeGroupId(p.group_id);
600
+ }
558
601
  // group.* 方法注入 device_id(服务端用于多设备消息路由)
559
602
  if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
560
603
  p.device_id = this._deviceId;
@@ -569,8 +612,7 @@ export class AUNClient {
569
612
  if (encrypt) {
570
613
  return await this._sendEncrypted(p);
571
614
  }
572
- delete p.protected_headers;
573
- delete p.headers;
615
+ // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
574
616
  }
575
617
  // 自动加密:group.send 默认加密(encrypt 默认 True)
576
618
  if (method === 'group.send') {
@@ -579,24 +621,20 @@ export class AUNClient {
579
621
  if (encrypt) {
580
622
  return await this._sendGroupEncrypted(p);
581
623
  }
582
- delete p.protected_headers;
583
- delete p.headers;
584
624
  }
585
625
  if (method === 'group.thought.put') {
586
626
  const encrypt = p.encrypt ?? true;
587
627
  delete p.encrypt;
588
- if (!encrypt) {
589
- throw new ValidationError('group.thought.put requires encrypt=true');
628
+ if (encrypt) {
629
+ return await this._putGroupThoughtEncrypted(p);
590
630
  }
591
- return await this._putGroupThoughtEncrypted(p);
592
631
  }
593
632
  if (method === 'message.thought.put') {
594
633
  const encrypt = p.encrypt ?? true;
595
634
  delete p.encrypt;
596
- if (!encrypt) {
597
- throw new ValidationError('message.thought.put requires encrypt=true');
635
+ if (encrypt) {
636
+ return await this._putMessageThoughtEncrypted(p);
598
637
  }
599
- return await this._putMessageThoughtEncrypted(p);
600
638
  }
601
639
  // 关键操作自动附加客户端签名
602
640
  if (SIGNED_METHODS.has(method)) {
@@ -628,7 +666,7 @@ export class AUNClient {
628
666
  if (serverAck > 0) {
629
667
  const contig = this._seqTracker.getContiguousSeq(ns);
630
668
  if (contig < serverAck) {
631
- _clientLog('info', 'message.pull retention-floor 推进: ns=%s contiguous=%d -> server_ack_seq=%d', ns, contig, serverAck);
669
+ this._clientLog.info(`message.pull retention-floor 推进: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAck}`);
632
670
  this._seqTracker.forceContiguousSeq(ns, serverAck);
633
671
  }
634
672
  }
@@ -640,7 +678,7 @@ export class AUNClient {
640
678
  seq: contig,
641
679
  device_id: this._deviceId,
642
680
  slot_id: this._slotId,
643
- }).catch((e) => { _clientLog('debug', 'message.pull auto-ack 失败: %s', formatCaughtError(e)); });
681
+ }).catch((e) => { this._clientLog.debug(`message.pull auto-ack 失败: ${formatCaughtError(e)}`); });
644
682
  }
645
683
  }
646
684
  }
@@ -667,7 +705,7 @@ export class AUNClient {
667
705
  if (serverAck > 0) {
668
706
  const contig = this._seqTracker.getContiguousSeq(ns);
669
707
  if (contig < serverAck) {
670
- _clientLog('info', 'group.pull retention-floor 推进: ns=%s contiguous=%d -> cursor.current_seq=%d', ns, contig, serverAck);
708
+ this._clientLog.info(`group.pull retention-floor 推进: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAck}`);
671
709
  this._seqTracker.forceContiguousSeq(ns, serverAck);
672
710
  }
673
711
  }
@@ -682,7 +720,7 @@ export class AUNClient {
682
720
  msg_seq: contig,
683
721
  device_id: this._deviceId,
684
722
  slot_id: this._slotId,
685
- }).catch((e) => { _clientLog('debug', 'group.pull auto-ack 失败: group=%s %s', gid, formatCaughtError(e)); });
723
+ }).catch((e) => { this._clientLog.debug(`group.pull auto-ack 失败: group=${gid} ${formatCaughtError(e)}`); });
686
724
  }
687
725
  }
688
726
  }
@@ -722,10 +760,13 @@ export class AUNClient {
722
760
  const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
723
761
  if (groupId && this._membershipRotationChanged(method, result)) {
724
762
  const expectedEpoch = this._membershipRotationExpectedEpoch(result);
763
+ // 自加入方法(request_join/use_invite_code)需要 allowMember=true,
764
+ // 因为新成员角色是 member,必须允许 member 参与 leader 选举。
765
+ const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
725
766
  // P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
726
- const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
767
+ const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
727
768
  const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
728
- await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => _clientLog('warn', 'membership RPC epoch rotation fallback failed: %s', formatCaughtError(exc)));
769
+ await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => this._clientLog.warn(`membership RPC epoch rotation fallback failed: ${formatCaughtError(exc)}`));
729
770
  }
730
771
  }
731
772
  return result;
@@ -780,50 +821,73 @@ export class AUNClient {
780
821
  if (!this._p2pSynced) {
781
822
  await this._lazySyncP2p();
782
823
  }
783
- const recipientPrekeys = await this._fetchPeerPrekeys(toAid);
784
- const selfSyncCopies = await this._buildSelfSyncCopies({
785
- logicalToAid: toAid,
786
- payload,
787
- messageId,
788
- timestamp,
789
- protectedHeaders,
790
- });
791
- if (recipientPrekeys.length <= 1 && selfSyncCopies.length === 0) {
792
- return await this._sendEncryptedSingle({
824
+ // 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
825
+ const sendAttempt = async (refreshPeerMaterial = false) => {
826
+ const recipientPrekeys = refreshPeerMaterial
827
+ ? await this._refreshPeerPrekeys(toAid)
828
+ : await this._fetchPeerPrekeys(toAid);
829
+ const selfSyncCopies = await this._buildSelfSyncCopies({
830
+ logicalToAid: toAid,
831
+ payload,
832
+ messageId,
833
+ timestamp,
834
+ protectedHeaders,
835
+ });
836
+ // 多设备过滤:只保留有有效 device_id 的可路由 prekey,
837
+ // 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
838
+ const routablePrekeys = recipientPrekeys.filter(pk => {
839
+ const did = String(pk.device_id ?? '').trim();
840
+ return did && did !== PREKEY_FALLBACK_DEVICE_ID;
841
+ });
842
+ const canUseMultiDevice = routablePrekeys.length > 0
843
+ && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
844
+ if (!canUseMultiDevice) {
845
+ return await this._sendEncryptedSingle({
846
+ toAid,
847
+ payload,
848
+ messageId,
849
+ timestamp,
850
+ prekey: routablePrekeys[0] ?? recipientPrekeys[0],
851
+ persistRequired,
852
+ protectedHeaders,
853
+ });
854
+ }
855
+ const recipientCopies = await this._buildRecipientDeviceCopies({
793
856
  toAid,
794
857
  payload,
795
858
  messageId,
796
859
  timestamp,
797
- prekey: recipientPrekeys[0],
798
- persistRequired,
860
+ prekeys: routablePrekeys,
799
861
  protectedHeaders,
800
862
  });
801
- }
802
- const recipientCopies = await this._buildRecipientDeviceCopies({
803
- toAid,
804
- payload,
805
- messageId,
806
- timestamp,
807
- prekeys: recipientPrekeys,
808
- protectedHeaders,
809
- });
810
- const sendParams = {
811
- to: toAid,
812
- payload: {
863
+ const sendParams = {
864
+ to: toAid,
865
+ payload: {
866
+ type: 'e2ee.multi_device',
867
+ logical_message_id: messageId,
868
+ recipient_copies: recipientCopies,
869
+ self_copies: selfSyncCopies,
870
+ },
813
871
  type: 'e2ee.multi_device',
814
- logical_message_id: messageId,
815
- recipient_copies: recipientCopies,
816
- self_copies: selfSyncCopies,
817
- },
818
- type: 'e2ee.multi_device',
819
- encrypted: true,
820
- message_id: messageId,
821
- timestamp,
872
+ encrypted: true,
873
+ message_id: messageId,
874
+ timestamp,
875
+ };
876
+ if (persistRequired) {
877
+ sendParams.persist_required = true;
878
+ }
879
+ return await this._transport.call('message.send', sendParams);
822
880
  };
823
- if (persistRequired) {
824
- sendParams.persist_required = true;
881
+ // 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
882
+ try {
883
+ return await sendAttempt(false);
825
884
  }
826
- return await this._transport.call('message.send', sendParams);
885
+ catch (exc) {
886
+ if (!isRetryablePeerMaterialError(exc))
887
+ throw exc;
888
+ this._clientLog.warn(`peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
889
+ }
890
+ return await sendAttempt(true);
827
891
  }
828
892
  async _sendEncryptedSingle(opts) {
829
893
  let prekey = opts.prekey ?? null;
@@ -927,7 +991,15 @@ export class AUNClient {
927
991
  if (deviceId === this._deviceId) {
928
992
  continue;
929
993
  }
930
- const peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
994
+ let peerCertPem;
995
+ try {
996
+ peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
997
+ }
998
+ catch (e) {
999
+ // 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
1000
+ this._clientLog.warn(`self-sync 跳过设备 ${deviceId}: 证书解析失败 (${e}),可能是旧 prekey`);
1001
+ continue;
1002
+ }
931
1003
  const [envelope, encryptResult] = this._encryptCopyPayload({
932
1004
  logicalToAid: opts.logicalToAid,
933
1005
  payload: opts.payload,
@@ -963,7 +1035,7 @@ export class AUNClient {
963
1035
  mode: encryptResult.mode,
964
1036
  reason: encryptResult.degradation_reason,
965
1037
  }).catch((exc) => {
966
- _clientLog('warn', '发布 e2ee.degraded 事件失败: %s', formatCaughtError(exc));
1038
+ this._clientLog.warn(`发布 e2ee.degraded 事件失败: ${formatCaughtError(exc)}`);
967
1039
  });
968
1040
  }
969
1041
  }
@@ -1028,7 +1100,7 @@ export class AUNClient {
1028
1100
  }
1029
1101
  catch (exc) {
1030
1102
  if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
1031
- _clientLog('warn', '群 %s 调用 %s 时 epoch 已过旧,恢复密钥后重加密重试一次: %s', groupId, method, formatCaughtError(exc));
1103
+ this._clientLog.warn(`群 ${groupId} 调用 ${method} 时 epoch 已过旧,恢复密钥后重加密重试一次: ${formatCaughtError(exc)}`);
1032
1104
  ({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
1033
1105
  continue;
1034
1106
  }
@@ -1114,11 +1186,11 @@ export class AUNClient {
1114
1186
  }
1115
1187
  if (messages.length > 0) {
1116
1188
  this._saveSeqTrackerState();
1117
- _clientLog('info', '惰性同步群 %s: pull %d 条消息, after_seq=%d', groupId, messages.length, afterSeq);
1189
+ this._clientLog.info(`惰性同步群 ${groupId}: pull ${messages.length} 条消息, after_seq=${afterSeq}`);
1118
1190
  }
1119
1191
  }
1120
1192
  catch (exc) {
1121
- _clientLog('warn', '惰性同步群 %s 失败: %s', groupId, formatCaughtError(exc));
1193
+ this._clientLog.warn(`惰性同步群 ${groupId} 失败: ${formatCaughtError(exc)}`);
1122
1194
  }
1123
1195
  }
1124
1196
  /** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
@@ -1141,11 +1213,11 @@ export class AUNClient {
1141
1213
  }
1142
1214
  if (messages.length > 0) {
1143
1215
  this._saveSeqTrackerState();
1144
- _clientLog('info', '惰性同步 P2P: pull %d 条消息, after_seq=%d', messages.length, afterSeq);
1216
+ this._clientLog.info(`惰性同步 P2P: pull ${messages.length} 条消息, after_seq=${afterSeq}`);
1145
1217
  }
1146
1218
  }
1147
1219
  catch (exc) {
1148
- _clientLog('warn', '惰性同步 P2P 失败: %s', formatCaughtError(exc));
1220
+ this._clientLog.warn(`惰性同步 P2P 失败: ${formatCaughtError(exc)}`);
1149
1221
  }
1150
1222
  }
1151
1223
  _isGroupEpochTooOldError(exc) {
@@ -1201,10 +1273,10 @@ export class AUNClient {
1201
1273
  encrypt: true,
1202
1274
  persist_required: true,
1203
1275
  });
1204
- _clientLog('info', '已向 %s 请求群 %s 的 epoch %s 密钥', targetAid, groupId, epoch);
1276
+ this._clientLog.info(`已向 ${targetAid} 请求群 ${groupId} 的 epoch ${epoch} 密钥`);
1205
1277
  }
1206
1278
  catch (exc) {
1207
- _clientLog('warn', '向 %s 请求群 %s 密钥失败: %s', targetAid, groupId, formatCaughtError(exc));
1279
+ this._clientLog.warn(`向 ${targetAid} 请求群 ${groupId} 密钥失败: ${formatCaughtError(exc)}`);
1208
1280
  }
1209
1281
  }
1210
1282
  async _requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult) {
@@ -1219,7 +1291,7 @@ export class AUNClient {
1219
1291
  const secretData = this._groupE2ee.loadSecret(groupId, 1);
1220
1292
  if (!secretData || secretData.pending_rotation_id)
1221
1293
  return epochResult;
1222
- _clientLog('warn', '群 %s 检测到本地 epoch 1 已存在但服务端 epoch 仍为 0,尝试补同步初始 epoch', groupId);
1294
+ this._clientLog.warn(`群 ${groupId} 检测到本地 epoch 1 已存在但服务端 epoch 仍为 0,尝试补同步初始 epoch`);
1223
1295
  await this._syncEpochToServer(groupId);
1224
1296
  try {
1225
1297
  const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
@@ -1227,7 +1299,7 @@ export class AUNClient {
1227
1299
  return refreshed;
1228
1300
  }
1229
1301
  catch (exc) {
1230
- _clientLog('warn', '群 %s 初始 epoch 补同步后刷新服务端 epoch 失败: %s', groupId, formatCaughtError(exc));
1302
+ this._clientLog.warn(`群 ${groupId} 初始 epoch 补同步后刷新服务端 epoch 失败: ${formatCaughtError(exc)}`);
1231
1303
  }
1232
1304
  return epochResult;
1233
1305
  }
@@ -1244,10 +1316,10 @@ export class AUNClient {
1244
1316
  catch (exc) {
1245
1317
  if (strict)
1246
1318
  throw new StateError(`group ${groupId} failed to query server epoch before retry: ${formatCaughtError(exc)}`);
1247
- _clientLog('warn', 'group %s epoch precheck failed: %s', groupId, formatCaughtError(exc));
1319
+ this._clientLog.warn(`group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
1248
1320
  return;
1249
1321
  }
1250
- let serverEpoch = Number(epochResult.epoch ?? 0);
1322
+ let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
1251
1323
  if (!Number.isFinite(serverEpoch))
1252
1324
  return;
1253
1325
  const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
@@ -1263,7 +1335,7 @@ export class AUNClient {
1263
1335
  let effectiveLocalEpoch = initialLocalEpoch;
1264
1336
  if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
1265
1337
  epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
1266
- serverEpoch = Number(epochResult.epoch ?? 0);
1338
+ serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
1267
1339
  if (serverEpoch === 0) {
1268
1340
  throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
1269
1341
  }
@@ -1275,7 +1347,9 @@ export class AUNClient {
1275
1347
  while (Date.now() < waitDeadline) {
1276
1348
  await new Promise(resolve => setTimeout(resolve, 150));
1277
1349
  const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
1278
- const refreshedEpoch = isJsonObject(refreshed) ? Number(refreshed.epoch ?? 0) : 0;
1350
+ const refreshedEpoch = isJsonObject(refreshed)
1351
+ ? Number(refreshed.committed_epoch ?? refreshed.epoch ?? 0)
1352
+ : 0;
1279
1353
  const currentLocal = await this._groupE2ee.currentEpoch(groupId);
1280
1354
  if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
1281
1355
  epochResult = refreshed;
@@ -1290,8 +1364,8 @@ export class AUNClient {
1290
1364
  throw new StateError(`group ${groupId} epoch rotation has not completed`);
1291
1365
  }
1292
1366
  }
1293
- _clientLog('warn', 'group %s local epoch=%s < server epoch=%s; requesting key recovery', groupId, effectiveLocalEpoch, serverEpoch);
1294
- await this._requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult);
1367
+ this._clientLog.warn(`group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
1368
+ await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
1295
1369
  const deadline = Date.now() + 5000;
1296
1370
  while (Date.now() < deadline) {
1297
1371
  await new Promise(resolve => setTimeout(resolve, 150));
@@ -1314,7 +1388,7 @@ export class AUNClient {
1314
1388
  members = isJsonObject(membersResult) ? membersResult.members : null;
1315
1389
  }
1316
1390
  catch (exc) {
1317
- _clientLog('debug', '群 %s 成员 epoch floor 预检跳过: %s', groupId, formatCaughtError(exc));
1391
+ this._clientLog.debug(`群 ${groupId} 成员 epoch floor 预检跳过: ${formatCaughtError(exc)}`);
1318
1392
  return;
1319
1393
  }
1320
1394
  let maxMinReadEpoch = 0;
@@ -1329,7 +1403,7 @@ export class AUNClient {
1329
1403
  }
1330
1404
  if (maxMinReadEpoch <= committedEpoch)
1331
1405
  return;
1332
- _clientLog('warn', '群 %s 成员 min_read_epoch 高于 committed epoch,按 committed epoch 继续发送: committed=%s floor=%s', groupId, committedEpoch, maxMinReadEpoch);
1406
+ this._clientLog.warn(`群 ${groupId} 成员 min_read_epoch 高于 committed epoch,按 committed epoch 继续发送: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
1333
1407
  return;
1334
1408
  }
1335
1409
  }
@@ -1340,7 +1414,7 @@ export class AUNClient {
1340
1414
  return epochResult;
1341
1415
  }
1342
1416
  catch (exc) {
1343
- _clientLog('warn', '群 %s 查询 committed epoch 状态失败,回退本地 epoch: %s', groupId, formatCaughtError(exc));
1417
+ this._clientLog.warn(`群 ${groupId} 查询 committed epoch 状态失败,回退本地 epoch: ${formatCaughtError(exc)}`);
1344
1418
  }
1345
1419
  const localEpoch = await this._groupE2ee.currentEpoch(groupId);
1346
1420
  return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
@@ -1364,13 +1438,28 @@ export class AUNClient {
1364
1438
  async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
1365
1439
  if (committedEpoch <= 0)
1366
1440
  return committedEpoch;
1367
- const secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
1368
- const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
1441
+ let secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
1442
+ let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
1443
+ if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
1444
+ const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
1445
+ this._clientLog.warn(`群 ${groupId} committed epoch ${committedEpoch} 的成员快照与当前成员不一致,触发成员变更轮换修复`);
1446
+ await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
1447
+ const refreshed = await this._committedGroupEpochState(groupId);
1448
+ const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
1449
+ if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
1450
+ committedEpoch = refreshedCommittedEpoch;
1451
+ committedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
1452
+ secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
1453
+ }
1454
+ if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
1455
+ throw new StateError(`group ${groupId} committed membership is stale at epoch ${committedEpoch}; key rotation repair has not completed`);
1456
+ }
1457
+ }
1369
1458
  if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
1370
1459
  return committedEpoch;
1371
1460
  }
1372
1461
  const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
1373
- _clientLog('warn', '群 %s epoch %s 本地 pending key 未匹配服务端 committed rotation,先恢复密钥: local_rotation=%s', groupId, committedEpoch, pendingRotationId || '-');
1462
+ this._clientLog.warn(`群 ${groupId} epoch ${committedEpoch} 本地 pending key 未匹配服务端 committed rotation,先恢复密钥: local_rotation=${pendingRotationId || '-'}`);
1374
1463
  await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
1375
1464
  let refreshed = await this._committedGroupEpochState(groupId);
1376
1465
  const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
@@ -1386,6 +1475,45 @@ export class AUNClient {
1386
1475
  }
1387
1476
  return committedEpoch;
1388
1477
  }
1478
+ async _committedRotationMembershipGap(groupId, committedEpoch, committedRotation) {
1479
+ if (!this._aid || committedEpoch <= 0 || !committedRotation)
1480
+ return false;
1481
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
1482
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
1483
+ : [];
1484
+ if (expectedMembers.length === 0)
1485
+ return false;
1486
+ try {
1487
+ const membersResult = await this.call('group.get_members', { group_id: groupId });
1488
+ const rawMembers = isJsonObject(membersResult)
1489
+ ? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
1490
+ : [];
1491
+ if (!Array.isArray(rawMembers))
1492
+ return false;
1493
+ const activeMembers = rawMembers
1494
+ .filter((item) => isJsonObject(item))
1495
+ .map((item) => ({
1496
+ aid: String(item.aid ?? '').trim(),
1497
+ status: String(item.status ?? 'active').trim().toLowerCase(),
1498
+ }))
1499
+ .filter((item) => item.aid && ['', 'active'].includes(item.status))
1500
+ .map((item) => item.aid)
1501
+ .sort();
1502
+ if (!activeMembers.includes(this._aid) || activeMembers.length === 0)
1503
+ return false;
1504
+ if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
1505
+ const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
1506
+ const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
1507
+ this._clientLog.info(`群 ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
1508
+ return true;
1509
+ }
1510
+ return false;
1511
+ }
1512
+ catch (exc) {
1513
+ this._clientLog.debug(`查询当前成员失败,无法判断 committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
1514
+ return false;
1515
+ }
1516
+ }
1389
1517
  // ── 客户端签名 ────────────────────────────────────────────
1390
1518
  /**
1391
1519
  * 为关键操作附加客户端 ECDSA 签名(client_signature 字段)。
@@ -1434,7 +1562,7 @@ export class AUNClient {
1434
1562
  async _onRawMessageReceived(data) {
1435
1563
  // 异步处理,不阻塞事件调度
1436
1564
  this._processAndPublishMessage(data).catch((exc) => {
1437
- _clientLog('warn', 'P2P 消息解密失败: %s', formatCaughtError(exc));
1565
+ this._clientLog.warn(`P2P 消息解密失败: ${formatCaughtError(exc)}`);
1438
1566
  // H26: 不再投递原始密文 payload;改发 message.undecryptable 事件,仅携带安全 header
1439
1567
  if (isJsonObject(data)) {
1440
1568
  const safeEvent = {
@@ -1470,7 +1598,7 @@ export class AUNClient {
1470
1598
  const ns = `p2p:${this._aid}`;
1471
1599
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1472
1600
  if (needPull) {
1473
- this._fillP2pGap().catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
1601
+ this._fillP2pGap().catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
1474
1602
  }
1475
1603
  // auto-ack contiguous_seq
1476
1604
  const contig = this._seqTracker.getContiguousSeq(ns);
@@ -1479,7 +1607,7 @@ export class AUNClient {
1479
1607
  seq: contig,
1480
1608
  device_id: this._deviceId,
1481
1609
  slot_id: this._slotId,
1482
- }).catch((e) => { _clientLog('debug', 'P2P auto-ack 失败: %s', formatCaughtError(e)); });
1610
+ }).catch((e) => { this._clientLog.debug(`P2P auto-ack 失败: ${formatCaughtError(e)}`); });
1483
1611
  }
1484
1612
  // 即时持久化 cursor,异常断连后不回退
1485
1613
  this._saveSeqTrackerState();
@@ -1496,7 +1624,7 @@ export class AUNClient {
1496
1624
  /** 处理群组消息推送:自动解密后 re-publish */
1497
1625
  async _onRawGroupMessageCreated(data) {
1498
1626
  this._processAndPublishGroupMessage(data).catch((exc) => {
1499
- _clientLog('warn', '群消息解密失败: %s', formatCaughtError(exc));
1627
+ this._clientLog.warn(`群消息解密失败: ${formatCaughtError(exc)}`);
1500
1628
  // H26: 不再投递原始密文 payload;改发 group.message_undecryptable 事件
1501
1629
  if (isJsonObject(data)) {
1502
1630
  const safeEvent = {
@@ -1541,7 +1669,7 @@ export class AUNClient {
1541
1669
  const ns = `group:${groupId}`;
1542
1670
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1543
1671
  if (needPull) {
1544
- this._fillGroupGap(groupId).catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
1672
+ this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
1545
1673
  }
1546
1674
  const contig = this._seqTracker.getContiguousSeq(ns);
1547
1675
  if (contig > 0) {
@@ -1550,7 +1678,7 @@ export class AUNClient {
1550
1678
  msg_seq: contig,
1551
1679
  device_id: this._deviceId,
1552
1680
  slot_id: this._slotId,
1553
- }).catch((e) => { _clientLog('debug', '群消息 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
1681
+ }).catch((e) => { this._clientLog.debug(`群消息 auto-ack 失败: group=${groupId} ${formatCaughtError(e)}`); });
1554
1682
  }
1555
1683
  this._saveSeqTrackerState();
1556
1684
  }
@@ -1618,7 +1746,7 @@ export class AUNClient {
1618
1746
  }
1619
1747
  }
1620
1748
  catch (exc) {
1621
- _clientLog('debug', '自动 pull 群消息失败: %s', formatCaughtError(exc));
1749
+ this._clientLog.debug(`自动 pull 群消息失败: ${formatCaughtError(exc)}`);
1622
1750
  }
1623
1751
  await this._publishAppEvent('group.message_created', notification);
1624
1752
  }
@@ -1661,7 +1789,7 @@ export class AUNClient {
1661
1789
  }
1662
1790
  }
1663
1791
  catch (exc) {
1664
- _clientLog('warn', '群消息补洞失败: %s', formatCaughtError(exc));
1792
+ this._clientLog.warn(`群消息补洞失败: ${formatCaughtError(exc)}`);
1665
1793
  }
1666
1794
  finally {
1667
1795
  this._gapFillDone.delete(dedupKey);
@@ -1706,7 +1834,7 @@ export class AUNClient {
1706
1834
  }
1707
1835
  }
1708
1836
  catch (exc) {
1709
- _clientLog('warn', 'P2P 消息补洞失败: %s', formatCaughtError(exc));
1837
+ this._clientLog.warn(`P2P 消息补洞失败: ${formatCaughtError(exc)}`);
1710
1838
  }
1711
1839
  finally {
1712
1840
  this._gapFillDone.delete(dedupKey);
@@ -1863,7 +1991,7 @@ export class AUNClient {
1863
1991
  if (serverAck > 0) {
1864
1992
  const contigBefore = this._seqTracker.getContiguousSeq(ns);
1865
1993
  if (contigBefore < serverAck) {
1866
- _clientLog('info', 'group.pull_events retention-floor 推进: ns=%s contiguous=%d -> cursor.current_seq=%d', ns, contigBefore, serverAck);
1994
+ this._clientLog.info(`group.pull_events retention-floor 推进: ns=${ns} contiguous=${contigBefore} -> cursor.current_seq=${serverAck}`);
1867
1995
  this._seqTracker.forceContiguousSeq(ns, serverAck);
1868
1996
  }
1869
1997
  }
@@ -1876,7 +2004,7 @@ export class AUNClient {
1876
2004
  event_seq: contig,
1877
2005
  device_id: this._deviceId,
1878
2006
  slot_id: this._slotId,
1879
- }).catch((e) => { _clientLog('debug', '群事件 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
2007
+ }).catch((e) => { this._clientLog.debug(`群事件 auto-ack 失败: group=${groupId} ${formatCaughtError(e)}`); });
1880
2008
  }
1881
2009
  for (const evt of events) {
1882
2010
  if (isJsonObject(evt)) {
@@ -1885,6 +2013,11 @@ export class AUNClient {
1885
2013
  // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
1886
2014
  if (et === 'group.message_created')
1887
2015
  continue;
2016
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2017
+ const cs = evt.client_signature;
2018
+ if (cs && typeof cs === 'object') {
2019
+ evt._verified = await this._verifyEventSignatureAsync(evt, cs);
2020
+ }
1888
2021
  // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
1889
2022
  await this._dispatcher.publish('group.changed', evt);
1890
2023
  }
@@ -1893,43 +2026,12 @@ export class AUNClient {
1893
2026
  }
1894
2027
  }
1895
2028
  catch (exc) {
1896
- _clientLog('warn', '群事件补洞失败: %s', formatCaughtError(exc));
2029
+ this._clientLog.warn(`群事件补洞失败: ${formatCaughtError(exc)}`);
1897
2030
  }
1898
2031
  finally {
1899
2032
  this._gapFillDone.delete(dedupKey);
1900
2033
  }
1901
2034
  }
1902
- /**
1903
- * 上线/重连后一次性同步所有已加入群:
1904
- * 1. 有 epoch key 的群 → 补消息 + 补事件
1905
- * 2. 无 epoch key 的群 → 仅补事件(事件不加密,等推送触发密钥恢复)
1906
- */
1907
- async _syncAllGroupsOnce() {
1908
- try {
1909
- const result = await this.call('group.list_my', {});
1910
- if (!isJsonObject(result))
1911
- return;
1912
- const items = result.items;
1913
- if (!Array.isArray(items))
1914
- return;
1915
- for (const g of items) {
1916
- if (isJsonObject(g)) {
1917
- const gid = String(g.group_id ?? '');
1918
- if (gid) {
1919
- // 有 epoch key → 补消息
1920
- if (this._groupE2ee.hasSecret(gid)) {
1921
- await this._fillGroupGap(gid);
1922
- }
1923
- // 所有群都补事件(事件不加密)
1924
- await this._fillGroupEventGap(gid);
1925
- }
1926
- }
1927
- }
1928
- }
1929
- catch (exc) {
1930
- _clientLog('debug', '批量同步群失败: %s', formatCaughtError(exc));
1931
- }
1932
- }
1933
2035
  /**
1934
2036
  * 处理群组变更事件:透传给用户,并在成员离开/被踢时自动触发 epoch 轮换。
1935
2037
  * 按协议,轮换由剩余在线 admin/owner 负责。
@@ -2081,12 +2183,12 @@ export class AUNClient {
2081
2183
  event_seq: contig,
2082
2184
  device_id: this._deviceId,
2083
2185
  slot_id: this._slotId,
2084
- }).catch((e) => { _clientLog('debug', '群事件推送 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
2186
+ }).catch((e) => { this._clientLog.debug(`群事件推送 auto-ack 失败: group=${groupId} ${formatCaughtError(e)}`); });
2085
2187
  }
2086
2188
  }
2087
2189
  // 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
2088
2190
  if (needPull && groupId && !d._from_gap_fill) {
2089
- this._fillGroupEventGap(groupId).catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
2191
+ this._fillGroupEventGap(groupId).catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
2090
2192
  }
2091
2193
  // 成员退出或被踢 → 剩余 admin/owner 自动补位轮换
2092
2194
  // H21: 避免 epoch 轮换风暴——所有剩余 admin 同时收到事件不能都发起轮换,
@@ -2097,7 +2199,7 @@ export class AUNClient {
2097
2199
  {
2098
2200
  const expectedEpoch = this._membershipRotationExpectedEpoch(d);
2099
2201
  if (expectedEpoch === null) {
2100
- _clientLog('debug', 'membership event without old_epoch skipped for epoch rotation: aid=%s group=%s action=%s event_seq=%s', this._aid ?? '', groupId, String(d.action ?? ''), String(d.event_seq ?? ''));
2202
+ this._clientLog.debug(`membership event without old_epoch skipped for epoch rotation: aid=${this._aid ?? ''} group=${groupId} action=${String(d.action ?? '')} event_seq=${String(d.event_seq ?? '')}`);
2101
2203
  }
2102
2204
  else {
2103
2205
  this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
@@ -2105,15 +2207,38 @@ export class AUNClient {
2105
2207
  }
2106
2208
  }
2107
2209
  }
2210
+ // 成员加入:按 action 区分策略
2211
+ // - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
2212
+ // - joined / invite_code_used(开放群/邀请码群):新成员先恢复 committed_epoch,延迟轮换
2108
2213
  if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
2109
2214
  if (groupId) {
2110
2215
  {
2216
+ const action = String(d.action ?? '');
2111
2217
  const expectedEpoch = this._membershipRotationExpectedEpoch(d);
2112
- if (expectedEpoch === null) {
2113
- _clientLog('debug', 'membership event without old_epoch skipped for epoch rotation: aid=%s group=%s action=%s event_seq=%s', this._aid ?? '', groupId, String(d.action ?? ''), String(d.event_seq ?? ''));
2218
+ const joinedAids = this._joinedMemberAidsFromPayload(d);
2219
+ const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
2220
+ this._clientLog.warn(`DEBUG: group.changed action=${action} groupId=${groupId} joinedAids=${JSON.stringify(joinedAids)} myAid=${this._aid} isSelfJoining=${String(isSelfJoining)} expectedEpoch=${String(expectedEpoch)}`);
2221
+ if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
2222
+ // open/invite_code 群:所有在线成员都参与延迟轮换
2223
+ // 新成员自己延迟更长,优先让其他在线成员先轮换
2224
+ const triggerId = this._membershipRotationTriggerId(groupId, d);
2225
+ if (!isSelfJoining) {
2226
+ this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
2227
+ }
2228
+ if (expectedEpoch !== null) {
2229
+ const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
2230
+ this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
2231
+ }
2114
2232
  }
2115
2233
  else {
2116
- this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
2234
+ // member_added / join_approved:立即轮换
2235
+ if (expectedEpoch === null) {
2236
+ const triggerId = this._membershipRotationTriggerId(groupId, d);
2237
+ this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
2238
+ }
2239
+ else {
2240
+ this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
2241
+ }
2117
2242
  }
2118
2243
  }
2119
2244
  }
@@ -2130,11 +2255,94 @@ export class AUNClient {
2130
2255
  await this._dispatcher.publish('group.changed', data);
2131
2256
  }
2132
2257
  }
2258
+ /**
2259
+ * 处理 event/group.state_committed:验证 state_hash 链并更新本地存储。
2260
+ * 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
2261
+ */
2262
+ async _onGroupStateCommitted(data) {
2263
+ if (!isJsonObject(data))
2264
+ return;
2265
+ const d = data;
2266
+ const groupId = String(d.group_id ?? '').trim();
2267
+ if (!groupId)
2268
+ return;
2269
+ // 提交者签名验证(兼容旧版:无签名时继续)
2270
+ const cs = d.client_signature;
2271
+ if (cs && isJsonObject(cs)) {
2272
+ const verified = await this._verifyEventSignatureAsync(d, cs);
2273
+ if (verified === false) {
2274
+ this._clientLog.warn(`state_committed 提交者签名验证失败 group=${groupId}`);
2275
+ return;
2276
+ }
2277
+ d._verified = verified;
2278
+ }
2279
+ const stateVersion = Number(d.state_version ?? 0);
2280
+ const stateHash = String(d.state_hash ?? '').trim();
2281
+ const prevStateHash = String(d.prev_state_hash ?? '').trim();
2282
+ const keyEpoch = Number(d.key_epoch ?? 0);
2283
+ const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
2284
+ const policySnapshot = String(d.policy_snapshot ?? '').trim();
2285
+ // 1. 验证 prev_state_hash 连续性
2286
+ const loadFn = this._keystore.loadGroupState;
2287
+ const localState = loadFn ? loadFn.call(this._keystore, groupId) : null;
2288
+ if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
2289
+ this._clientLog.warn(`state_hash 链不连续 group=${groupId} local_sv=${localState.state_version} event_sv=${stateVersion}`);
2290
+ // 回源同步
2291
+ try {
2292
+ const serverState = await this._transport.call('group.get_state', { group_id: groupId });
2293
+ if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
2294
+ const sv = Number(serverState.state_version ?? 0);
2295
+ const sHash = String(serverState.state_hash ?? '');
2296
+ const sEpoch = Number(serverState.key_epoch ?? 0);
2297
+ const sMembersJson = String(serverState.membership_snapshot ?? '');
2298
+ const sPolicyJson = String(serverState.policy_snapshot ?? '');
2299
+ const sPrev = String(serverState.prev_state_hash ?? '');
2300
+ // 回源也做 hash 验证
2301
+ if (sMembersJson && sHash) {
2302
+ const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
2303
+ const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
2304
+ const computed = computeStateHash({
2305
+ groupId, stateVersion: sv, keyEpoch: sEpoch,
2306
+ members: sMembers, policy: sPolicy, prevStateHash: sPrev,
2307
+ });
2308
+ if (computed !== sHash) {
2309
+ this._clientLog.warn(`回源 state_hash 验证失败 group=${groupId} sv=${sv} expected=${sHash} got=${computed}`);
2310
+ return;
2311
+ }
2312
+ }
2313
+ const saveFn = this._keystore.saveGroupState;
2314
+ if (saveFn) {
2315
+ saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
2316
+ }
2317
+ }
2318
+ }
2319
+ catch (exc) {
2320
+ this._clientLog.warn(`state 回源失败 group=${groupId}: ${formatCaughtError(exc)}`);
2321
+ }
2322
+ return;
2323
+ }
2324
+ // 2. 本地重算验证
2325
+ const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
2326
+ const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
2327
+ const computed = computeStateHash({
2328
+ groupId, stateVersion, keyEpoch,
2329
+ members, policy, prevStateHash,
2330
+ });
2331
+ if (computed !== stateHash) {
2332
+ this._clientLog.warn(`state_hash 重算不匹配 group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
2333
+ return;
2334
+ }
2335
+ // 3. 更新本地存储
2336
+ const saveFn = this._keystore.saveGroupState;
2337
+ if (saveFn) {
2338
+ saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
2339
+ }
2340
+ }
2133
2341
  /**
2134
2342
  * 成员退出/被踢后,判断本地是否为 leader admin 并发起 epoch 轮换。
2135
2343
  * 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
2136
2344
  */
2137
- async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
2345
+ async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
2138
2346
  const myAid = this._aid;
2139
2347
  if (!myAid || this._closing || this._state !== 'connected')
2140
2348
  return;
@@ -2145,7 +2353,7 @@ export class AUNClient {
2145
2353
  if (this._closing || this._state !== 'connected')
2146
2354
  return;
2147
2355
  if (Date.now() - started > 20000) {
2148
- _clientLog('warn', 'group epoch rotation still in-flight; skip pending trigger (group=%s trigger=%s)', groupId, triggerId || '-');
2356
+ this._clientLog.warn(`group epoch rotation still in-flight; skip pending trigger (group=${groupId} trigger=${triggerId || '-'})`);
2149
2357
  return;
2150
2358
  }
2151
2359
  await new Promise((resolve) => setTimeout(resolve, 200));
@@ -2163,24 +2371,46 @@ export class AUNClient {
2163
2371
  if (!Array.isArray(rawList))
2164
2372
  return;
2165
2373
  const admins = [];
2374
+ const members = [];
2166
2375
  for (const m of rawList) {
2167
2376
  if (!isJsonObject(m))
2168
2377
  continue;
2169
2378
  const role = String(m.role ?? '');
2170
2379
  const aid = String(m.aid ?? '');
2171
- if (aid && (role === 'admin' || role === 'owner'))
2380
+ if (!aid)
2381
+ continue;
2382
+ if (role === 'admin' || role === 'owner') {
2172
2383
  admins.push(aid);
2384
+ }
2385
+ else if (allowMember && role === 'member') {
2386
+ members.push(aid);
2387
+ }
2173
2388
  }
2174
- if (admins.length === 0)
2389
+ // 候选列表:admin/owner 排序在前,member 排序在后
2390
+ let candidates = [...admins.sort(), ...members.sort()];
2391
+ if (candidates.length === 0)
2175
2392
  return;
2176
- admins.sort();
2177
- const leader = admins[0];
2393
+ // 没有当前 epoch key 的成员不参与 leader 选举。
2394
+ // open/invite_code 群排除后为空时保留自己兜底(从服务端取 prev chain)。
2395
+ if (expectedEpoch !== null && expectedEpoch > 0) {
2396
+ const localSecret = this._groupE2ee.loadSecret(groupId, expectedEpoch);
2397
+ if (!localSecret) {
2398
+ const filtered = candidates.filter(c => c !== myAid);
2399
+ if (filtered.length > 0) {
2400
+ candidates = filtered;
2401
+ }
2402
+ else if (!allowMember) {
2403
+ return;
2404
+ }
2405
+ }
2406
+ }
2407
+ const leader = candidates[0];
2178
2408
  if (leader === myAid) {
2179
2409
  // 我是 leader,直接发起
2180
2410
  await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
2181
2411
  return;
2182
2412
  }
2183
- if (!admins.includes(myAid))
2413
+ if (!candidates.includes(myAid))
2184
2414
  return;
2185
2415
  // 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
2186
2416
  const jitterMs = 2000 + Math.floor(Math.random() * 4000);
@@ -2218,11 +2448,11 @@ export class AUNClient {
2218
2448
  });
2219
2449
  return;
2220
2450
  }
2221
- _clientLog('info', '[H21] leader 未完成 epoch 轮换,非 leader 兜底: group=%s myAid=%s', groupId, myAid);
2451
+ this._clientLog.info(`[H21] leader 未完成 epoch 轮换,非 leader 兜底: group=${groupId} myAid=${myAid}`);
2222
2452
  await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
2223
2453
  }
2224
2454
  catch (exc) {
2225
- _clientLog('warn', '_maybeLeadRotateGroupEpoch 失败: %s', formatCaughtError(exc));
2455
+ this._clientLog.warn(`_maybeLeadRotateGroupEpoch 失败: ${formatCaughtError(exc)}`);
2226
2456
  }
2227
2457
  finally {
2228
2458
  this._groupEpochRotationInflight.delete(groupId);
@@ -2240,7 +2470,7 @@ export class AUNClient {
2240
2470
  this._groupE2ee.removeGroup(groupId);
2241
2471
  }
2242
2472
  catch (exc) {
2243
- _clientLog('warn', '清理解散群组 %s epoch 密钥失败: %s', groupId, formatCaughtError(exc));
2473
+ this._clientLog.warn(`清理解散群组 ${groupId} epoch 密钥失败: ${formatCaughtError(exc)}`);
2244
2474
  }
2245
2475
  // 2. 清理 seq_tracker 中的群消息和群事件命名空间
2246
2476
  this._seqTracker.removeNamespace(`group:${groupId}`);
@@ -2257,7 +2487,7 @@ export class AUNClient {
2257
2487
  this._pushedSeqs.delete(`group_event:${groupId}`);
2258
2488
  this._pendingOrderedMsgs.delete(`group:${groupId}`);
2259
2489
  this._pendingDecryptMsgs.delete(`group:${groupId}`);
2260
- _clientLog('info', '已清理解散群组 %s 的本地状态', groupId);
2490
+ this._clientLog.info(`已清理解散群组 ${groupId} 的本地状态`);
2261
2491
  }
2262
2492
  /** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
2263
2493
  /**
@@ -2292,7 +2522,7 @@ export class AUNClient {
2292
2522
  if (expectedFP) {
2293
2523
  const actualFP = 'sha256:' + certObj.fingerprint256.replace(/:/g, '').toLowerCase();
2294
2524
  if (actualFP !== expectedFP) {
2295
- _clientLog('warn', '验签失败:证书指纹不匹配 aid=%s', sigAid);
2525
+ this._clientLog.warn(`验签失败:证书指纹不匹配 aid=${sigAid}`);
2296
2526
  return false;
2297
2527
  }
2298
2528
  }
@@ -2303,7 +2533,7 @@ export class AUNClient {
2303
2533
  const pubKey = certObj.publicKey;
2304
2534
  const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
2305
2535
  if (!ok) {
2306
- _clientLog('warn', '群事件验签失败 aid=%s method=%s', sigAid, method);
2536
+ this._clientLog.warn(`群事件验签失败 aid=${sigAid} method=${method}`);
2307
2537
  // P1-16: 签名失败统一发布事件
2308
2538
  this._dispatcher.publish('signature.verification_failed', {
2309
2539
  aid: sigAid, method, error: 'ECDSA verification failed',
@@ -2312,7 +2542,7 @@ export class AUNClient {
2312
2542
  return ok;
2313
2543
  }
2314
2544
  catch (exc) {
2315
- _clientLog('warn', '群事件验签异常: %s', formatCaughtError(exc));
2545
+ this._clientLog.warn(`群事件验签异常: ${formatCaughtError(exc)}`);
2316
2546
  // P1-16: 签名失败统一发布事件
2317
2547
  this._dispatcher.publish('signature.verification_failed', {
2318
2548
  aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
@@ -2370,6 +2600,11 @@ export class AUNClient {
2370
2600
  result = this._groupE2ee.handleIncoming(actualPayload);
2371
2601
  if (result === 'distribution') {
2372
2602
  await this._discardGroupDistributionIfStale(actualPayload);
2603
+ // 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
2604
+ const distGroupId = actualPayload.group_id;
2605
+ if (distGroupId && !this._groupSynced.has(distGroupId)) {
2606
+ this._lazySyncGroup(distGroupId).catch(() => { });
2607
+ }
2373
2608
  }
2374
2609
  // S14: 非控制面消息且 handleIncoming 不识别 → 不拦截
2375
2610
  if (!isControlPlane && result === null)
@@ -2379,7 +2614,9 @@ export class AUNClient {
2379
2614
  const groupId = String(actualPayload.group_id ?? '');
2380
2615
  const requester = String(actualPayload.requester_aid ?? '');
2381
2616
  let members = this._groupE2ee.getMemberAids(groupId);
2382
- // 请求者不在本地成员列表时,回源查询服务端最新成员列表
2617
+ // 请求者不在本地成员列表时,回源查询服务端最新成员列表,
2618
+ // 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
2619
+ // (历史 epoch 的成员隔离由 handleKeyRequest 内部负责)。
2383
2620
  if (requester && !members.includes(requester)) {
2384
2621
  try {
2385
2622
  const membersResult = await this.call('group.get_members', { group_id: groupId });
@@ -2387,18 +2624,9 @@ export class AUNClient {
2387
2624
  ? membersResult.members
2388
2625
  : [];
2389
2626
  members = memberList.map((m) => String(m.aid));
2390
- // 更新本地当前 epoch 的 member_aids/commitment
2391
- if (members.includes(requester)) {
2392
- const secretData = this._groupE2ee.loadSecret(groupId);
2393
- if (secretData && this._aid) {
2394
- const epoch = secretData.epoch;
2395
- const commitment = computeMembershipCommitment(members, epoch, groupId, secretData.secret);
2396
- storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, members);
2397
- }
2398
- }
2399
2627
  }
2400
2628
  catch (exc) {
2401
- _clientLog('warn', '群组 %s 成员列表回源失败: %s', groupId, formatCaughtError(exc));
2629
+ this._clientLog.warn(`群组 ${groupId} 成员列表回源失败: ${formatCaughtError(exc)}`);
2402
2630
  }
2403
2631
  }
2404
2632
  const response = this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
@@ -2412,7 +2640,7 @@ export class AUNClient {
2412
2640
  });
2413
2641
  }
2414
2642
  catch (exc) {
2415
- _clientLog('warn', '向 %s 回复群组密钥失败: %s', requester, formatCaughtError(exc));
2643
+ this._clientLog.warn(`向 ${requester} 回复群组密钥失败: ${formatCaughtError(exc)}`);
2416
2644
  }
2417
2645
  }
2418
2646
  }
@@ -2423,7 +2651,7 @@ export class AUNClient {
2423
2651
  const keyCommitment = String(actualPayload.commitment ?? '');
2424
2652
  if (rotationId && keyCommitment) {
2425
2653
  this._ackGroupRotationKey(rotationId, keyCommitment)
2426
- .catch((exc) => _clientLog('warn', '提交 epoch key ack 失败: %s', formatCaughtError(exc)));
2654
+ .catch((exc) => this._clientLog.warn(`提交 epoch key ack 失败: ${formatCaughtError(exc)}`));
2427
2655
  }
2428
2656
  this._scheduleRetryPendingDecryptMsgs(groupId);
2429
2657
  }
@@ -2498,7 +2726,7 @@ export class AUNClient {
2498
2726
  this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
2499
2727
  }
2500
2728
  catch (exc) {
2501
- _clientLog('error', '写入证书到 keystore 失败 (aid=%s, fp=%s): %s', aid, certFingerprint ?? '', formatCaughtError(exc));
2729
+ this._clientLog.error(`写入证书到 keystore 失败 (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
2502
2730
  }
2503
2731
  return certPem;
2504
2732
  }
@@ -2531,7 +2759,7 @@ export class AUNClient {
2531
2759
  if (normalized.length > 0) {
2532
2760
  this._peerPrekeysCache.set(peerAid, {
2533
2761
  items: normalized.map((item) => ({ ...item })),
2534
- expireAt: Date.now() / 1000 + 300,
2762
+ expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
2535
2763
  });
2536
2764
  this._e2ee.cachePrekey(peerAid, normalized[0]);
2537
2765
  return normalized;
@@ -2546,7 +2774,7 @@ export class AUNClient {
2546
2774
  if (normalized.length > 0) {
2547
2775
  this._peerPrekeysCache.set(peerAid, {
2548
2776
  items: normalized.map((item) => ({ ...item })),
2549
- expireAt: Date.now() / 1000 + 300,
2777
+ expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
2550
2778
  });
2551
2779
  this._e2ee.cachePrekey(peerAid, normalized[0]);
2552
2780
  return normalized.map((item) => ({ ...item }));
@@ -2579,6 +2807,25 @@ export class AUNClient {
2579
2807
  }
2580
2808
  return { ...prekeys[0] };
2581
2809
  }
2810
+ /** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
2811
+ _invalidatePeerPrekeyCache(peerAid) {
2812
+ this._peerPrekeysCache.delete(peerAid);
2813
+ this._e2ee.invalidatePrekeyCache(peerAid);
2814
+ }
2815
+ /** 清除对端证书缓存(精确匹配 aid 或 aid# 前缀的所有条目) */
2816
+ _clearPeerCertCache(peerAid) {
2817
+ for (const cacheKey of this._certCache.keys()) {
2818
+ if (cacheKey === peerAid || cacheKey.startsWith(`${peerAid}#`)) {
2819
+ this._certCache.delete(cacheKey);
2820
+ }
2821
+ }
2822
+ }
2823
+ /** 清除对端所有缓存后重新拉取 prekey(用于指纹不匹配时的强制刷新) */
2824
+ async _refreshPeerPrekeys(peerAid) {
2825
+ this._invalidatePeerPrekeyCache(peerAid);
2826
+ this._clearPeerCertCache(peerAid);
2827
+ return await this._fetchPeerPrekeys(peerAid);
2828
+ }
2582
2829
  /** 生成 prekey 并上传到服务端 */
2583
2830
  async _uploadPrekey() {
2584
2831
  const prekeyMaterial = this._e2ee.generatePrekey();
@@ -2630,10 +2877,10 @@ export class AUNClient {
2630
2877
  catch (exc) {
2631
2878
  // 刷新失败时:若内存缓存有 PKI 验证过的证书(未过期 x2 倍 TTL)则继续用
2632
2879
  if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
2633
- _clientLog('debug', '刷新发送方 %s 证书失败,继续使用已验证的内存缓存: %s', aid, formatCaughtError(exc));
2880
+ this._clientLog.debug(`刷新发送方 ${aid} 证书失败,继续使用已验证的内存缓存: ${formatCaughtError(exc)}`);
2634
2881
  return true;
2635
2882
  }
2636
- _clientLog('warn', '获取发送方 %s 证书失败且无已验证缓存,拒绝信任: %s', aid, formatCaughtError(exc));
2883
+ this._clientLog.warn(`获取发送方 ${aid} 证书失败且无已验证缓存,拒绝信任: ${formatCaughtError(exc)}`);
2637
2884
  return false;
2638
2885
  }
2639
2886
  }
@@ -2642,7 +2889,11 @@ export class AUNClient {
2642
2889
  * 零信任:不直接信任 keystore 中可能由恶意服务端注入的证书。
2643
2890
  */
2644
2891
  _getVerifiedPeerCert(aid, certFingerprint) {
2645
- const cached = this._certCache.get(AUNClient._certCacheKey(aid, certFingerprint));
2892
+ let cached = this._certCache.get(AUNClient._certCacheKey(aid, certFingerprint));
2893
+ // 带 fingerprint 查不到时,降级用 aid 再查一次
2894
+ if (!cached && certFingerprint) {
2895
+ cached = this._certCache.get(AUNClient._certCacheKey(aid, undefined));
2896
+ }
2646
2897
  const now = Date.now() / 1000;
2647
2898
  if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
2648
2899
  return cached.certPem;
@@ -2665,7 +2916,7 @@ export class AUNClient {
2665
2916
  if (fromAid) {
2666
2917
  const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2667
2918
  if (!certReady) {
2668
- _clientLog('warn', '无法获取发送方 %s 的证书,跳过解密', fromAid);
2919
+ this._clientLog.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
2669
2920
  throw new Error(`发送方证书不可用: from=${fromAid}, mid=${message.message_id}`);
2670
2921
  }
2671
2922
  }
@@ -2701,7 +2952,7 @@ export class AUNClient {
2701
2952
  if (fromAid) {
2702
2953
  const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2703
2954
  if (!certReady) {
2704
- _clientLog('warn', '无法获取发送方 %s 的证书,跳过解密', fromAid);
2955
+ this._clientLog.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
2705
2956
  continue;
2706
2957
  }
2707
2958
  }
@@ -2712,7 +2963,7 @@ export class AUNClient {
2712
2963
  }
2713
2964
  else {
2714
2965
  // TS-015: 解密失败不回退到密文,跳过该消息并记录
2715
- _clientLog('warn', 'pull 消息解密失败,跳过: from=%s mid=%s', msg.from, msg.message_id);
2966
+ this._clientLog.warn(`pull 消息解密失败,跳过: from=${msg.from} mid=${msg.message_id}`);
2716
2967
  }
2717
2968
  }
2718
2969
  else {
@@ -2765,7 +3016,7 @@ export class AUNClient {
2765
3016
  _scheduleRetryPendingDecryptMsgs(groupId) {
2766
3017
  if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
2767
3018
  return;
2768
- this._retryPendingDecryptMsgs(groupId).catch((exc) => _clientLog('warn', '群 %s pending 消息重试失败: %s', groupId, formatCaughtError(exc)));
3019
+ this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(`群 ${groupId} pending 消息重试失败: ${formatCaughtError(exc)}`));
2769
3020
  }
2770
3021
  async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
2771
3022
  const existing = await this._groupE2ee.loadSecret(groupId, epoch);
@@ -2783,7 +3034,254 @@ export class AUNClient {
2783
3034
  this._groupEpochRecoveryInflight.set(key, promise);
2784
3035
  return promise;
2785
3036
  }
3037
+ static _extractGroupJoinMode(payload) {
3038
+ if (!isJsonObject(payload))
3039
+ return '';
3040
+ for (const key of ['join_mode', 'mode']) {
3041
+ const v = String(payload[key] ?? '').trim().toLowerCase();
3042
+ if (v)
3043
+ return v;
3044
+ }
3045
+ for (const key of ['join_requirements', 'join']) {
3046
+ const nested = payload[key];
3047
+ if (isJsonObject(nested)) {
3048
+ for (const nk of ['mode', 'join_mode']) {
3049
+ const v = String(nested[nk] ?? '').trim().toLowerCase();
3050
+ if (v)
3051
+ return v;
3052
+ }
3053
+ }
3054
+ }
3055
+ if (isJsonObject(payload.group)) {
3056
+ const v = AUNClient._extractGroupJoinMode(payload.group);
3057
+ if (v)
3058
+ return v;
3059
+ }
3060
+ const settings = payload.settings;
3061
+ if (isJsonObject(settings)) {
3062
+ for (const key of ['join.mode', 'join_mode', 'mode']) {
3063
+ const v = String(settings[key] ?? '').trim().toLowerCase();
3064
+ if (v)
3065
+ return v;
3066
+ }
3067
+ }
3068
+ if (Array.isArray(settings)) {
3069
+ for (const item of settings) {
3070
+ if (!isJsonObject(item))
3071
+ continue;
3072
+ const k = String(item.key ?? item.name ?? '').trim().toLowerCase();
3073
+ if (k === 'join.mode' || k === 'join_mode' || k === 'mode') {
3074
+ const v = String(item.value ?? '').trim().toLowerCase();
3075
+ if (v)
3076
+ return v;
3077
+ }
3078
+ }
3079
+ }
3080
+ return '';
3081
+ }
3082
+ static _joinModeAllowsMemberEpochRotation(mode) {
3083
+ const m = mode.trim().toLowerCase();
3084
+ return m === 'open' || m === 'invite_only' || m === 'invite_code';
3085
+ }
3086
+ async _groupAllowsMemberEpochRotation(groupId) {
3087
+ try {
3088
+ const resp = await this.call('group.get_join_requirements', { group_id: groupId });
3089
+ const mode = AUNClient._extractGroupJoinMode(resp);
3090
+ if (mode)
3091
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
3092
+ }
3093
+ catch { /* best effort */ }
3094
+ try {
3095
+ const resp = await this.call('group.get_settings', { group_id: groupId, keys: ['join.mode'] });
3096
+ const mode = AUNClient._extractGroupJoinMode(resp);
3097
+ if (mode)
3098
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
3099
+ }
3100
+ catch { /* best effort */ }
3101
+ try {
3102
+ const resp = await this.call('group.get', { group_id: groupId });
3103
+ const mode = AUNClient._extractGroupJoinMode(resp);
3104
+ if (mode)
3105
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
3106
+ }
3107
+ catch { /* best effort */ }
3108
+ return false;
3109
+ }
3110
+ /** 尝试从服务端拉取 ECIES 加密的 epoch key 并解密存入 keystore */
3111
+ async _tryRecoverEpochKeyFromServer(groupId, epoch) {
3112
+ try {
3113
+ const params = { group_id: groupId };
3114
+ if (epoch > 0)
3115
+ params.epoch = epoch;
3116
+ const result = await this.call('group.e2ee.get_epoch_key', params);
3117
+ if (!isJsonObject(result))
3118
+ return false;
3119
+ const encryptedB64 = result.encrypted_key;
3120
+ if (!encryptedB64 || typeof encryptedB64 !== 'string')
3121
+ return false;
3122
+ const serverEpoch = Number(result.epoch ?? epoch);
3123
+ const encryptedBytes = Buffer.from(encryptedB64, 'base64');
3124
+ // 用自己的 AID 私钥 ECIES 解密
3125
+ const myAid = this._aid || '';
3126
+ const keyPair = this._keystore.loadKeyPair(myAid);
3127
+ if (!keyPair?.private_key_pem) {
3128
+ this._clientLog.warn(`无法加载 AID 私钥用于 ECIES 解密: aid=${myAid}`);
3129
+ return false;
3130
+ }
3131
+ const { eciesDecrypt } = await import('./e2ee-group.js');
3132
+ const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
3133
+ if (!groupSecret || groupSecret.length !== 32) {
3134
+ this._clientLog.warn(`服务端 epoch key ECIES 解密结果长度异常: group=${groupId} epoch=${serverEpoch} len=${groupSecret?.length ?? 0}`);
3135
+ return false;
3136
+ }
3137
+ // 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
3138
+ let memberAids = [];
3139
+ let committedRotation = null;
3140
+ let epochChain = '';
3141
+ try {
3142
+ const epochInfo = await this.call('group.e2ee.get_epoch', { group_id: groupId });
3143
+ if (isJsonObject(epochInfo)) {
3144
+ if (Array.isArray(epochInfo.members)) {
3145
+ memberAids = epochInfo.members
3146
+ .map((m) => {
3147
+ if (typeof m === 'string')
3148
+ return m;
3149
+ if (isJsonObject(m) && typeof m.aid === 'string')
3150
+ return m.aid;
3151
+ return '';
3152
+ })
3153
+ .filter((s) => s.length > 0);
3154
+ }
3155
+ if (isJsonObject(epochInfo.committed_rotation)) {
3156
+ committedRotation = epochInfo.committed_rotation;
3157
+ const rawChain = String(committedRotation.epoch_chain ?? '').trim();
3158
+ if (rawChain)
3159
+ epochChain = rawChain;
3160
+ // 如果有 expected_members,用它覆盖 memberAids
3161
+ if (Array.isArray(committedRotation.expected_members) && committedRotation.expected_members.length > 0) {
3162
+ memberAids = committedRotation.expected_members
3163
+ .map(item => String(item ?? '').trim())
3164
+ .filter(s => s.length > 0);
3165
+ }
3166
+ }
3167
+ }
3168
+ }
3169
+ catch { /* best effort */ }
3170
+ if (memberAids.length === 0) {
3171
+ this._clientLog.warn(`服务端 epoch key 恢复缺少成员快照: group=${groupId} epoch=${serverEpoch}`);
3172
+ return false;
3173
+ }
3174
+ const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
3175
+ // committed_rotation 存在时验证 commitment 和 epoch_chain
3176
+ let epochChainUnverified = null;
3177
+ let epochChainUnverifiedReason = null;
3178
+ if (committedRotation) {
3179
+ const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
3180
+ const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3181
+ if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
3182
+ this._clientLog.warn(`服务端 epoch key 恢复 commitment 不匹配: group=${groupId} epoch=${serverEpoch}`);
3183
+ return false;
3184
+ }
3185
+ if (epochChain && committedEpoch === serverEpoch) {
3186
+ let rotatorAid = '';
3187
+ for (const key of ['rotated_by', 'lease_owner', 'committed_by']) {
3188
+ const v = String(committedRotation[key] ?? '').trim();
3189
+ if (v) {
3190
+ rotatorAid = v;
3191
+ break;
3192
+ }
3193
+ }
3194
+ const prevData = this._groupE2ee.loadSecret(groupId, serverEpoch - 1);
3195
+ const prevChain = String(prevData?.epoch_chain ?? '').trim();
3196
+ if (prevChain && rotatorAid) {
3197
+ if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
3198
+ this._clientLog.warn(`服务端 epoch key 恢复 epoch_chain 验证失败: group=${groupId} epoch=${serverEpoch} rotator=${rotatorAid}`);
3199
+ return false;
3200
+ }
3201
+ epochChainUnverified = false;
3202
+ }
3203
+ else {
3204
+ epochChainUnverified = true;
3205
+ epochChainUnverifiedReason = prevChain ? 'missing_rotator_aid' : 'missing_prev_chain';
3206
+ }
3207
+ }
3208
+ }
3209
+ const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
3210
+ if (!stored) {
3211
+ this._clientLog.warn(`服务端 epoch key 恢复存储失败: group=${groupId} epoch=${serverEpoch}`);
3212
+ return false;
3213
+ }
3214
+ this._clientLog.info(`从服务端恢复 epoch key 成功: group=${groupId} epoch=${serverEpoch}`);
3215
+ return true;
3216
+ }
3217
+ catch (exc) {
3218
+ this._clientLog.debug(`从服务端恢复 epoch key 失败: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
3219
+ return false;
3220
+ }
3221
+ }
3222
+ /** 为每个成员用其 AID 证书公钥 ECIES 加密 group_secret,返回 {aid: base64_ciphertext} */
3223
+ async _buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId) {
3224
+ try {
3225
+ const { eciesEncrypt } = await import('./e2ee-group.js');
3226
+ // 从 distribution payload 中提取 group_secret
3227
+ let groupSecretBytes = null;
3228
+ const distributions = Array.isArray(info.distributions) ? info.distributions : [];
3229
+ for (const dist of distributions) {
3230
+ if (isJsonObject(dist) && isJsonObject(dist.payload)) {
3231
+ const gsB64 = dist.payload.group_secret;
3232
+ if (typeof gsB64 === 'string' && gsB64.length > 0) {
3233
+ groupSecretBytes = Buffer.from(gsB64, 'base64');
3234
+ break;
3235
+ }
3236
+ }
3237
+ }
3238
+ if (!groupSecretBytes) {
3239
+ // fallback: 从本地 keystore 加载
3240
+ const loaded = this._groupE2ee.loadSecret(groupId, targetEpoch);
3241
+ if (loaded?.secret) {
3242
+ groupSecretBytes = loaded.secret;
3243
+ }
3244
+ else {
3245
+ this._clientLog.debug(`无法获取 group_secret 用于 ECIES 加密: group=${groupId} epoch=${targetEpoch}`);
3246
+ return {};
3247
+ }
3248
+ }
3249
+ const encryptedKeys = {};
3250
+ for (const aid of memberAids) {
3251
+ try {
3252
+ const certPem = await this._fetchPeerCert(aid);
3253
+ const x509Cert = new crypto.X509Certificate(certPem);
3254
+ const pubKey = x509Cert.publicKey;
3255
+ // 导出未压缩 EC 公钥点
3256
+ const jwk = pubKey.export({ format: 'jwk' });
3257
+ if (jwk.crv !== 'P-256' || !jwk.x || !jwk.y)
3258
+ continue;
3259
+ const xBuf = Buffer.from(jwk.x, 'base64url');
3260
+ const yBuf = Buffer.from(jwk.y, 'base64url');
3261
+ const pubkeyBytes = Buffer.concat([Buffer.from([0x04]), xBuf, yBuf]);
3262
+ const ciphertext = eciesEncrypt(pubkeyBytes, groupSecretBytes);
3263
+ encryptedKeys[aid] = ciphertext.toString('base64');
3264
+ }
3265
+ catch (exc) {
3266
+ this._clientLog.debug(`为成员 ${aid} 构建 ECIES epoch key 失败: ${formatCaughtError(exc)}`);
3267
+ continue;
3268
+ }
3269
+ }
3270
+ return encryptedKeys;
3271
+ }
3272
+ catch (exc) {
3273
+ this._clientLog.debug(`构建 encrypted_keys 失败: group=${groupId} err=${formatCaughtError(exc)}`);
3274
+ return {};
3275
+ }
3276
+ }
2786
3277
  async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
3278
+ // 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
3279
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
3280
+ if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
3281
+ this._scheduleRetryPendingDecryptMsgs(groupId);
3282
+ return true;
3283
+ }
3284
+ }
2787
3285
  let epochResult = { epoch };
2788
3286
  try {
2789
3287
  const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
@@ -2797,7 +3295,31 @@ export class AUNClient {
2797
3295
  const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
2798
3296
  epochResult.recovery_candidates = [senderAid, ...current];
2799
3297
  }
2800
- await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
3298
+ // 在线优先恢复:先查在线成员列表,只向在线成员发送密钥请求
3299
+ let onlineAids = null;
3300
+ try {
3301
+ const onlineResp = await this.call('group.get_online_members', { group_id: groupId });
3302
+ if (isJsonObject(onlineResp)) {
3303
+ const rawMembers = Array.isArray(onlineResp.members) ? onlineResp.members
3304
+ : Array.isArray(onlineResp.items) ? onlineResp.items : [];
3305
+ onlineAids = rawMembers
3306
+ .filter((m) => isJsonObject(m) && m.online === true && String(m.aid ?? '') !== this._aid)
3307
+ .map(m => String(m.aid ?? ''));
3308
+ }
3309
+ }
3310
+ catch {
3311
+ this._clientLog.debug(`群 ${groupId} 查询在线成员失败,回退全量候选`);
3312
+ }
3313
+ if (onlineAids !== null) {
3314
+ if (onlineAids.length === 0) {
3315
+ this._clientLog.info(`群 ${groupId} epoch ${String(epoch)} 恢复失败:无在线成员可请求密钥`);
3316
+ return false;
3317
+ }
3318
+ await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
3319
+ }
3320
+ else {
3321
+ await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
3322
+ }
2801
3323
  const deadline = Date.now() + timeoutMs;
2802
3324
  while (Date.now() < deadline) {
2803
3325
  await new Promise(resolve => setTimeout(resolve, 150));
@@ -2813,6 +3335,22 @@ export class AUNClient {
2813
3335
  this._scheduleRetryPendingDecryptMsgs(groupId);
2814
3336
  return ready;
2815
3337
  }
3338
+ /** 只向在线成员发送密钥恢复请求 */
3339
+ async _requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult) {
3340
+ const candidates = this._groupKeyRecoveryCandidates(groupId, epochResult);
3341
+ const ordered = [];
3342
+ for (const aid of candidates) {
3343
+ if (onlineAids.includes(aid) && !ordered.includes(aid))
3344
+ ordered.push(aid);
3345
+ }
3346
+ for (const aid of onlineAids) {
3347
+ if (!ordered.includes(aid))
3348
+ ordered.push(aid);
3349
+ }
3350
+ for (const aid of ordered) {
3351
+ await this._requestGroupKeyFrom(groupId, aid, epoch);
3352
+ }
3353
+ }
2816
3354
  async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
2817
3355
  if (!isJsonObject(secret))
2818
3356
  return false;
@@ -2855,7 +3393,7 @@ export class AUNClient {
2855
3393
  if (senderAid) {
2856
3394
  const certOk = await this._ensureSenderCertCached(senderAid);
2857
3395
  if (!certOk) {
2858
- _clientLog('warn', '群消息解密跳过:发送方 %s 证书不可用', senderAid);
3396
+ this._clientLog.warn(`群消息解密跳过:发送方 ${senderAid} 证书不可用`);
2859
3397
  return message;
2860
3398
  }
2861
3399
  }
@@ -2882,7 +3420,7 @@ export class AUNClient {
2882
3420
  }
2883
3421
  }
2884
3422
  catch (exc) {
2885
- _clientLog('debug', '群 %s epoch %s 同步恢复失败: %s', groupId, epoch, formatCaughtError(exc));
3423
+ this._clientLog.debug(`群 ${groupId} epoch ${epoch} 同步恢复失败: ${formatCaughtError(exc)}`);
2886
3424
  }
2887
3425
  }
2888
3426
  return message;
@@ -2943,18 +3481,27 @@ export class AUNClient {
2943
3481
  payload: payload ?? {},
2944
3482
  created_at: Number(item.created_at ?? 0),
2945
3483
  };
3484
+ if (isJsonObject(item.context))
3485
+ message.context = item.context;
2946
3486
  const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
3487
+ let decryptFailed = false;
2947
3488
  if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
2948
- this._enqueuePendingDecrypt(groupId, message);
2949
- continue;
3489
+ decryptFailed = true;
3490
+ // 安全网:触发 epoch key 恢复(内部有去重,重复调用安全)
3491
+ const epoch = Number(payload.epoch ?? 0);
3492
+ if (epoch > 0) {
3493
+ this._recoverGroupEpochKey(groupId, epoch, senderAid, 5000).catch(() => { });
3494
+ }
2950
3495
  }
2951
3496
  const thought = {
2952
3497
  thought_id: thoughtId,
2953
3498
  message_id: thoughtId,
2954
- payload: decrypted.payload,
3499
+ payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
2955
3500
  created_at: item.created_at,
2956
3501
  e2ee: decrypted.e2ee,
2957
3502
  };
3503
+ if (decryptFailed)
3504
+ thought.decrypt_failed = true;
2958
3505
  if ('context' in item)
2959
3506
  thought.context = item.context;
2960
3507
  thoughts.push(thought);
@@ -2985,19 +3532,25 @@ export class AUNClient {
2985
3532
  encrypted: item.encrypted !== false,
2986
3533
  timestamp: Number(item.created_at ?? 0),
2987
3534
  };
3535
+ if (isJsonObject(item.context))
3536
+ message.context = item.context;
2988
3537
  let decrypted = message;
3538
+ let decryptFailed = false;
2989
3539
  if (payload?.type === 'e2ee.encrypted') {
2990
3540
  const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2991
3541
  if (fromAid) {
2992
3542
  const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2993
3543
  if (!certReady) {
2994
- _clientLog('warn', '无法获取发送方 %s 的证书,跳过 message.thought.get 解密', fromAid);
2995
- continue;
3544
+ this._clientLog.warn(`无法获取发送方 ${fromAid} 的证书,跳过 message.thought.get 解密`);
3545
+ decryptFailed = true;
2996
3546
  }
2997
3547
  }
2998
- decrypted = this._e2ee._decryptMessage(message);
2999
- if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
3000
- continue;
3548
+ if (!decryptFailed) {
3549
+ decrypted = this._e2ee._decryptMessage(message);
3550
+ if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
3551
+ decryptFailed = true;
3552
+ decrypted = message;
3553
+ }
3001
3554
  }
3002
3555
  }
3003
3556
  const thought = {
@@ -3005,10 +3558,12 @@ export class AUNClient {
3005
3558
  message_id: thoughtId,
3006
3559
  from: fromAid,
3007
3560
  to: toAid,
3008
- payload: decrypted.payload,
3561
+ payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
3009
3562
  created_at: item.created_at,
3010
- e2ee: decrypted.e2ee,
3563
+ e2ee: decryptFailed ? undefined : decrypted.e2ee,
3011
3564
  };
3565
+ if (decryptFailed)
3566
+ thought.decrypt_failed = true;
3012
3567
  if ('context' in item)
3013
3568
  thought.context = item.context;
3014
3569
  thoughts.push(thought);
@@ -3071,7 +3626,7 @@ export class AUNClient {
3071
3626
  }
3072
3627
  else {
3073
3628
  failed.push(String(dist.to));
3074
- _clientLog('warn', 'epoch 密钥分发失败 (to=%s): %s', dist.to, formatCaughtError(exc));
3629
+ this._clientLog.warn(`epoch 密钥分发失败 (to=${dist.to}): ${formatCaughtError(exc)}`);
3075
3630
  }
3076
3631
  }
3077
3632
  }
@@ -3089,7 +3644,7 @@ export class AUNClient {
3089
3644
  return isJsonObject(result) && result.success === true;
3090
3645
  }
3091
3646
  catch (exc) {
3092
- _clientLog('warn', '刷新 epoch rotation lease 失败: rotation=%s err=%s', rotationId, formatCaughtError(exc));
3647
+ this._clientLog.warn(`刷新 epoch rotation lease 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
3093
3648
  return false;
3094
3649
  }
3095
3650
  }
@@ -3105,7 +3660,7 @@ export class AUNClient {
3105
3660
  return isJsonObject(result) && result.success === true;
3106
3661
  }
3107
3662
  catch (exc) {
3108
- _clientLog('warn', '提交 epoch key ack 失败: rotation=%s err=%s', rotationId, formatCaughtError(exc));
3663
+ this._clientLog.warn(`提交 epoch key ack 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
3109
3664
  return false;
3110
3665
  }
3111
3666
  }
@@ -3128,12 +3683,21 @@ export class AUNClient {
3128
3683
  if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
3129
3684
  if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
3130
3685
  const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3131
- if (committedCommitment && commitment && committedCommitment !== commitment)
3132
- return false;
3686
+ if (committedCommitment && commitment && committedCommitment !== commitment) {
3687
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
3688
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
3689
+ : [];
3690
+ if (this._aid && !expectedMembers.includes(this._aid)) {
3691
+ this._clientLog.debug(`放行 group key 分发:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
3692
+ }
3693
+ else {
3694
+ return false;
3695
+ }
3696
+ }
3133
3697
  }
3134
3698
  return true;
3135
3699
  }
3136
- _clientLog('info', '拒绝缺少 rotation_id 的未来 epoch key 分发: group=%s epoch=%s committed=%s', groupId, epoch, committedEpoch);
3700
+ this._clientLog.info(`拒绝缺少 rotation_id 的未来 epoch key 分发: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
3137
3701
  return false;
3138
3702
  }
3139
3703
  const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
@@ -3154,10 +3718,10 @@ export class AUNClient {
3154
3718
  }
3155
3719
  }
3156
3720
  catch (exc) {
3157
- _clientLog('warn', '拒绝无法校验 active rotation 的 epoch key 分发: group=%s rotation=%s err=%s', groupId, rotationId, formatCaughtError(exc));
3721
+ this._clientLog.warn(`拒绝无法校验 active rotation 的 epoch key 分发: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
3158
3722
  return false;
3159
3723
  }
3160
- _clientLog('info', '拒绝非 pending/committed 状态的 epoch key 分发: group=%s rotation=%s epoch=%s', groupId, rotationId, epoch);
3724
+ this._clientLog.info(`拒绝非 pending/committed 状态的 epoch key 分发: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
3161
3725
  return false;
3162
3726
  }
3163
3727
  async _discardGroupDistributionIfStale(payload) {
@@ -3172,10 +3736,10 @@ export class AUNClient {
3172
3736
  return;
3173
3737
  try {
3174
3738
  this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
3175
- _clientLog('info', '丢弃 verify 后变为 stale 的 group epoch key: group=%s epoch=%s rotation=%s', groupId, epoch, rotationId);
3739
+ this._clientLog.info(`丢弃 verify 后变为 stale 的 group epoch key: group=${groupId} epoch=${epoch} rotation=${rotationId}`);
3176
3740
  }
3177
3741
  catch (exc) {
3178
- _clientLog('debug', '清理 stale group epoch key 失败: group=%s epoch=%s rotation=%s err=%s', groupId, epoch, rotationId, formatCaughtError(exc));
3742
+ this._clientLog.debug(`清理 stale group epoch key 失败: group=${groupId} epoch=${epoch} rotation=${rotationId} err=${formatCaughtError(exc)}`);
3179
3743
  }
3180
3744
  }
3181
3745
  async _verifyGroupKeyResponseEpoch(payload) {
@@ -3192,19 +3756,28 @@ export class AUNClient {
3192
3756
  return false;
3193
3757
  const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
3194
3758
  if (epoch > committedEpoch) {
3195
- _clientLog('info', '拒绝未提交 epoch 的 group key response: group=%s epoch=%s committed=%s', groupId, epoch, committedEpoch);
3759
+ this._clientLog.info(`拒绝未提交 epoch 的 group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
3196
3760
  return false;
3197
3761
  }
3198
3762
  const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
3199
3763
  if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
3200
3764
  const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3201
- if (committedCommitment && commitment && committedCommitment !== commitment)
3202
- return false;
3765
+ if (committedCommitment && commitment && committedCommitment !== commitment) {
3766
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
3767
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
3768
+ : [];
3769
+ if (this._aid && !expectedMembers.includes(this._aid)) {
3770
+ this._clientLog.debug(`放行 group key response:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
3771
+ }
3772
+ else {
3773
+ return false;
3774
+ }
3775
+ }
3203
3776
  }
3204
3777
  return true;
3205
3778
  }
3206
3779
  catch (exc) {
3207
- _clientLog('warn', '拒绝无法校验 committed epoch 的 group key response: group=%s epoch=%s err=%s', groupId, epoch, formatCaughtError(exc));
3780
+ this._clientLog.warn(`拒绝无法校验 committed epoch 的 group key response: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
3208
3781
  return false;
3209
3782
  }
3210
3783
  }
@@ -3219,7 +3792,7 @@ export class AUNClient {
3219
3792
  return isJsonObject(result) && result.success === true;
3220
3793
  }
3221
3794
  catch (exc) {
3222
- _clientLog('warn', '中止 epoch rotation 失败: rotation=%s err=%s', rotationId, formatCaughtError(exc));
3795
+ this._clientLog.warn(`中止 epoch rotation 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
3223
3796
  return false;
3224
3797
  }
3225
3798
  }
@@ -3253,7 +3826,7 @@ export class AUNClient {
3253
3826
  if (this._closing || this._state !== 'connected')
3254
3827
  return;
3255
3828
  this._maybeLeadRotateGroupEpoch(groupId, opts.triggerId, opts.expectedEpoch)
3256
- .catch((exc) => _clientLog('warn', 'group epoch rotation retry failed: %s', formatCaughtError(exc)));
3829
+ .catch((exc) => this._clientLog.warn(`group epoch rotation retry failed: ${formatCaughtError(exc)}`));
3257
3830
  }, this._rotationRetryDelayMs(opts.pending));
3258
3831
  this._groupEpochRotationRetryTimers.set(retryKey, timer);
3259
3832
  this._unrefTimer(timer);
@@ -3265,7 +3838,7 @@ export class AUNClient {
3265
3838
  if (this._closing || this._state !== 'connected')
3266
3839
  return;
3267
3840
  if (Date.now() - started > 20000) {
3268
- _clientLog('warn', 'group epoch create sync still in-flight; skip duplicate sync (group=%s)', groupId);
3841
+ this._clientLog.warn(`group epoch create sync still in-flight; skip duplicate sync (group=${groupId})`);
3269
3842
  return;
3270
3843
  }
3271
3844
  await new Promise((resolve) => setTimeout(resolve, 200));
@@ -3298,31 +3871,39 @@ export class AUNClient {
3298
3871
  const beginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
3299
3872
  const rotation = isJsonObject(beginResult) && isJsonObject(beginResult.rotation) ? beginResult.rotation : null;
3300
3873
  if (!isJsonObject(beginResult) || beginResult.success !== true || !rotation) {
3301
- _clientLog('warn', 'group epoch begin failed; stop key distribution (group=%s, returned=%s)', groupId, JSON.stringify(beginResult));
3874
+ this._clientLog.warn(`group epoch begin failed; stop key distribution (group=${groupId}, returned=${JSON.stringify(beginResult)})`);
3302
3875
  return;
3303
3876
  }
3304
3877
  const activeRotationId = String(rotation.rotation_id ?? rotationId);
3305
3878
  if (!await this._ackGroupRotationKey(activeRotationId, secretData.commitment)) {
3306
- _clientLog('warn', 'group epoch self ack failed (group=%s, rotation=%s)', groupId, activeRotationId);
3879
+ this._clientLog.warn(`group epoch self ack failed (group=${groupId}, rotation=${activeRotationId})`);
3307
3880
  await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
3308
3881
  return;
3309
3882
  }
3310
- const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
3883
+ const commitParams2 = { rotation_id: activeRotationId };
3884
+ const createMembers = secretData.member_aids.length > 0 ? secretData.member_aids : (this._aid ? [this._aid] : []);
3885
+ const encKeys2 = await this._buildEpochEncryptedKeys({ distributions: [{ payload: { group_secret: secretData.secret.toString('base64') } }] }, createMembers, 1, groupId);
3886
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
3887
+ if (encKeys2 && Object.keys(encKeys2).length > 0) {
3888
+ commitParams2.encrypted_keys = encKeys2;
3889
+ }
3890
+ }
3891
+ const commitResult = await this.call('group.e2ee.commit_rotation', commitParams2);
3311
3892
  if (isJsonObject(commitResult) && commitResult.success === true) {
3312
3893
  storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
3313
3894
  return;
3314
3895
  }
3315
- _clientLog('warn', 'group epoch commit failed (group=%s, returned=%s)', groupId, JSON.stringify(commitResult));
3896
+ this._clientLog.warn(`group epoch commit failed (group=${groupId}, returned=${JSON.stringify(commitResult)})`);
3316
3897
  return;
3317
3898
  }
3318
3899
  catch (exc) {
3319
3900
  if (attempt < maxRetries) {
3320
3901
  const delay = 500 * Math.pow(2, attempt - 1);
3321
- _clientLog('warn', '同步 epoch 到服务端失败 (group=%s, 第%d/%d次): %s, %dms后重试', groupId, attempt, maxRetries, formatCaughtError(exc), delay);
3902
+ this._clientLog.warn(`同步 epoch 到服务端失败 (group=${groupId}, 第${attempt}/${maxRetries}次): ${formatCaughtError(exc)}, ${delay}ms后重试`);
3322
3903
  await new Promise(r => setTimeout(r, delay));
3323
3904
  }
3324
3905
  else {
3325
- _clientLog('error', '同步 epoch 到服务端最终失败 (group=%s, 已重试%d次): %s', groupId, maxRetries, formatCaughtError(exc));
3906
+ this._clientLog.error(`同步 epoch 到服务端最终失败 (group=${groupId}, 已重试${maxRetries}次): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3326
3907
  }
3327
3908
  }
3328
3909
  }
@@ -3353,7 +3934,7 @@ export class AUNClient {
3353
3934
  && serverEpoch === expectedEpoch
3354
3935
  && this._rotationExpectedMembersStale(pendingRotation, memberAids));
3355
3936
  if (stalePending && await this._abortGroupRotation(pendingRotationId, 'membership_changed_during_rotation')) {
3356
- _clientLog('info', 'aborted stale pending group epoch rotation: group=%s rotation=%s', groupId, pendingRotationId || '-');
3937
+ this._clientLog.info(`aborted stale pending group epoch rotation: group=${groupId} rotation=${pendingRotationId || '-'}`);
3357
3938
  }
3358
3939
  else {
3359
3940
  this._scheduleGroupRotationRetry(groupId, {
@@ -3368,20 +3949,35 @@ export class AUNClient {
3368
3949
  if (expectedEpoch !== null && serverEpoch !== expectedEpoch) {
3369
3950
  if (triggerId)
3370
3951
  this._groupMembershipRotationDone.add(triggerId);
3371
- _clientLog('info', 'skip membership epoch rotation: group=%s expected_epoch=%d server_epoch=%d trigger=%s', groupId, expectedEpoch, serverEpoch, triggerId || '-');
3952
+ this._clientLog.info(`skip membership epoch rotation: group=${groupId} expected_epoch=${expectedEpoch} server_epoch=${serverEpoch} trigger=${triggerId || '-'}`);
3372
3953
  return;
3373
3954
  }
3374
3955
  const currentEpoch = expectedEpoch ?? serverEpoch;
3375
3956
  const targetEpoch = currentEpoch + 1;
3957
+ // 新成员可能没有 prev epoch key,或有 key 但缺少 epoch_chain(通过 backfill 接收)。
3958
+ // 从 committed_rotation.epoch_chain 获取 prev chain hint。
3959
+ let prevChainHint = null;
3960
+ const localPrev = this._groupE2ee.loadSecret(groupId, currentEpoch);
3961
+ const localPrevChain = String(localPrev?.epoch_chain ?? '');
3962
+ if (!localPrevChain && isJsonObject(epochResult)) {
3963
+ const cr = epochResult.committed_rotation;
3964
+ if (isJsonObject(cr)) {
3965
+ const rawChain = String(cr.epoch_chain ?? '').trim();
3966
+ if (rawChain) {
3967
+ prevChainHint = rawChain;
3968
+ this._clientLog.info(`新成员轮换补充 prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
3969
+ }
3970
+ }
3971
+ }
3376
3972
  const rotationId = `rot-${crypto.randomUUID().replace(/-/g, '')}`;
3377
- const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
3973
+ const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
3378
3974
  this._attachRotationId(info, rotationId);
3379
3975
  const discardGeneratedPending = () => {
3380
3976
  try {
3381
3977
  this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
3382
3978
  }
3383
3979
  catch (cleanupExc) {
3384
- _clientLog('debug', '清理本地 pending group key 失败: group=%s epoch=%s rotation=%s err=%s', groupId, targetEpoch, rotationId, formatCaughtError(cleanupExc));
3980
+ this._clientLog.debug(`清理本地 pending group key 失败: group=${groupId} epoch=${targetEpoch} rotation=${rotationId} err=${formatCaughtError(cleanupExc)}`);
3385
3981
  }
3386
3982
  };
3387
3983
  const rotateParams = {
@@ -3435,14 +4031,14 @@ export class AUNClient {
3435
4031
  pending: null,
3436
4032
  });
3437
4033
  }
3438
- _clientLog('warn', 'group epoch begin failed; stop key distribution (group=%s, current_epoch=%d, returned=%s)', groupId, currentEpoch, JSON.stringify(beginResult));
4034
+ this._clientLog.warn(`group epoch begin failed; stop key distribution (group=${groupId}, current_epoch=${currentEpoch}, returned=${JSON.stringify(beginResult)})`);
3439
4035
  discardGeneratedPending();
3440
4036
  return;
3441
4037
  }
3442
4038
  const activeRotationId = String(rotation.rotation_id ?? rotationId);
3443
4039
  const distributeResult = await this._distributeGroupEpochKey(info, activeRotationId);
3444
4040
  if (distributeResult.failed.length > 0) {
3445
- _clientLog('warn', 'group epoch key distribution incomplete; abort rotation before retry (group=%s rotation=%s failed=%s)', groupId, activeRotationId, distributeResult.failed.join(','));
4041
+ this._clientLog.warn(`group epoch key distribution incomplete; abort rotation before retry (group=${groupId} rotation=${activeRotationId} failed=${distributeResult.failed.join(',')})`);
3446
4042
  await this._abortGroupRotation(activeRotationId, 'distribution_failed');
3447
4043
  this._scheduleGroupRotationRetry(groupId, {
3448
4044
  reason: 'membership_changed',
@@ -3455,7 +4051,7 @@ export class AUNClient {
3455
4051
  }
3456
4052
  await this._heartbeatGroupRotation(activeRotationId);
3457
4053
  if (!await this._ackGroupRotationKey(activeRotationId, String(info.commitment ?? ''))) {
3458
- _clientLog('warn', 'group epoch self ack failed; abort rotation before retry (group=%s rotation=%s)', groupId, activeRotationId);
4054
+ this._clientLog.warn(`group epoch self ack failed; abort rotation before retry (group=${groupId} rotation=${activeRotationId})`);
3459
4055
  await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
3460
4056
  this._scheduleGroupRotationRetry(groupId, {
3461
4057
  reason: 'membership_changed',
@@ -3466,9 +4062,17 @@ export class AUNClient {
3466
4062
  discardGeneratedPending();
3467
4063
  return;
3468
4064
  }
3469
- const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
4065
+ const commitParams = { rotation_id: activeRotationId };
4066
+ // 构建 per-member ECIES 加密的 epoch key 上传到服务端
4067
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
4068
+ const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
4069
+ if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
4070
+ commitParams.encrypted_keys = encryptedKeys;
4071
+ }
4072
+ }
4073
+ const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
3470
4074
  if (!isJsonObject(commitResult) || commitResult.success !== true) {
3471
- _clientLog('warn', 'group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
4075
+ this._clientLog.warn(`group epoch commit failed (group=${groupId}, rotation=${activeRotationId}, returned=${JSON.stringify(commitResult)})`);
3472
4076
  this._scheduleGroupRotationRetry(groupId, {
3473
4077
  reason: 'membership_changed',
3474
4078
  triggerId,
@@ -3492,7 +4096,7 @@ export class AUNClient {
3492
4096
  storeGroupSecret(this._keystore, this._aid, groupId, targetEpoch, committedSecret.secret, committedSecret.commitment, committedSecret.member_aids.length > 0 ? committedSecret.member_aids : memberAids, committedSecret.epoch_chain);
3493
4097
  }
3494
4098
  else {
3495
- _clientLog('warn', 'group epoch commit succeeded but local target key does not match committed rotation; keep pending blocked (group=%s rotation=%s epoch=%s)', groupId, activeRotationId, targetEpoch);
4099
+ this._clientLog.warn(`group epoch commit succeeded but local target key does not match committed rotation; keep pending blocked (group=${groupId} rotation=${activeRotationId} epoch=${targetEpoch})`);
3496
4100
  }
3497
4101
  }
3498
4102
  if (triggerId) {
@@ -3534,7 +4138,7 @@ export class AUNClient {
3534
4138
  if (identity && identity.private_key_pem) {
3535
4139
  manifest = signMembershipManifest(manifest, String(identity.private_key_pem));
3536
4140
  }
3537
- const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest);
4141
+ const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest, String(secretData.epoch_chain ?? ''));
3538
4142
  // 重试 3 次,间隔递增(1s, 2s)
3539
4143
  for (let attempt = 0; attempt < 3; attempt++) {
3540
4144
  try {
@@ -3560,7 +4164,79 @@ export class AUNClient {
3560
4164
  this._logE2eeError('distribute_key', groupId, newMemberAid, exc);
3561
4165
  }
3562
4166
  }
3563
- /** 构建 epoch 轮换签名参数。失败时抛错,调用方需在 try/catch 中决定是否跳过轮换。 */
4167
+ /** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
4168
+ _joinedMemberAidsFromPayload(payload) {
4169
+ const aids = new Set();
4170
+ const addAid = (value) => {
4171
+ const aid = String(value ?? '').trim();
4172
+ if (aid)
4173
+ aids.add(aid);
4174
+ };
4175
+ addAid(payload.aid ?? payload.applicant_aid ?? payload.applicantAid);
4176
+ addAid(payload.actor_aid);
4177
+ for (const key of ['member_aid', 'target_aid', 'new_member_aid', 'used_by']) {
4178
+ addAid(payload[key]);
4179
+ }
4180
+ for (const key of ['member', 'request', 'invite_code']) {
4181
+ const nested = isJsonObject(payload[key]) ? payload[key] : null;
4182
+ if (!nested)
4183
+ continue;
4184
+ addAid(nested.aid ?? nested.applicant_aid ?? nested.applicantAid);
4185
+ for (const nk of ['member_aid', 'target_aid', 'used_by'])
4186
+ addAid(nested[nk]);
4187
+ }
4188
+ if (Array.isArray(payload.results)) {
4189
+ for (const item of payload.results) {
4190
+ if (!isJsonObject(item))
4191
+ continue;
4192
+ const obj = item;
4193
+ const status = String(obj.status ?? '').trim().toLowerCase();
4194
+ if (status !== 'approved' && obj.approved !== true)
4195
+ continue;
4196
+ addAid(obj.aid ?? obj.applicant_aid ?? obj.applicantAid);
4197
+ for (const key of ['member_aid', 'target_aid'])
4198
+ addAid(obj[key]);
4199
+ for (const key of ['member', 'request']) {
4200
+ const nested = isJsonObject(obj[key]) ? obj[key] : null;
4201
+ if (!nested)
4202
+ continue;
4203
+ addAid(nested.aid ?? nested.applicant_aid);
4204
+ for (const nk of ['member_aid', 'target_aid'])
4205
+ addAid(nested[nk]);
4206
+ }
4207
+ }
4208
+ }
4209
+ return Array.from(aids);
4210
+ }
4211
+ // ── 入群密钥恢复策略 ──────────────────────────────────────
4212
+ /** 延迟轮换等待时间(毫秒):给新成员恢复 committed_epoch 的窗口 */
4213
+ static _JOIN_ROTATION_DELAY_MS = 3000;
4214
+ // 新成员自身延迟轮换时间:优先让其他在线成员先轮换
4215
+ static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
4216
+ /** open/invite_code 入群后延迟轮换。 */
4217
+ async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
4218
+ await new Promise(resolve => setTimeout(resolve, delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS));
4219
+ await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
4220
+ }
4221
+ /** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
4222
+ async _maybeBackfillKeyToJoinedMember(groupId, payload, triggerId = '') {
4223
+ const memberAids = this._joinedMemberAidsFromPayload(payload)
4224
+ .filter(aid => aid && aid !== this._aid);
4225
+ if (!groupId || !this._aid || memberAids.length === 0)
4226
+ return;
4227
+ if (!this._groupE2ee.hasSecret(groupId))
4228
+ return;
4229
+ for (const memberAid of memberAids) {
4230
+ const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
4231
+ if (this._groupMemberKeyBackfillDone.has(dedupeKey))
4232
+ continue;
4233
+ this._groupMemberKeyBackfillDone.add(dedupeKey);
4234
+ if (this._groupMemberKeyBackfillDone.size > 2000) {
4235
+ this._groupMemberKeyBackfillDone = new Set(Array.from(this._groupMemberKeyBackfillDone).slice(-1000));
4236
+ }
4237
+ await this._distributeKeyToNewMember(groupId, memberAid);
4238
+ }
4239
+ }
3564
4240
  _buildRotationSignature(groupId, currentEpoch, newEpoch = 0, source) {
3565
4241
  const identity = this._identity;
3566
4242
  if (!identity || !identity.private_key_pem) {
@@ -3613,8 +4289,9 @@ export class AUNClient {
3613
4289
  // 优先从 seq_tracker 表按行读取
3614
4290
  const loadAll = this._keystore.loadAllSeqs;
3615
4291
  if (typeof loadAll === 'function') {
3616
- const state = loadAll.call(this._keystore, this._aid, this._deviceId, this._slotId);
4292
+ let state = loadAll.call(this._keystore, this._aid, this._deviceId, this._slotId);
3617
4293
  if (state && Object.keys(state).length > 0) {
4294
+ state = this._migrateSeqStateGroupIds(state);
3618
4295
  this._seqTracker.restoreState(state);
3619
4296
  return;
3620
4297
  }
@@ -3624,12 +4301,14 @@ export class AUNClient {
3624
4301
  if (typeof loader === 'function') {
3625
4302
  const instanceState = loader.call(this._keystore, this._aid, this._deviceId, this._slotId);
3626
4303
  if (instanceState && typeof instanceState.seq_tracker_state === 'object') {
3627
- this._seqTracker.restoreState(instanceState.seq_tracker_state);
4304
+ let state = instanceState.seq_tracker_state;
4305
+ state = this._migrateSeqStateGroupIds(state);
4306
+ this._seqTracker.restoreState(state);
3628
4307
  }
3629
4308
  }
3630
4309
  }
3631
4310
  catch (exc) {
3632
- _clientLog('warn', '恢复 SeqTracker 状态失败: %s', formatCaughtError(exc));
4311
+ this._clientLog.warn(`恢复 SeqTracker 状态失败: ${formatCaughtError(exc)}`);
3633
4312
  // 通过内部 dispatcher 发布可观测事件,便于上层监控
3634
4313
  this._dispatcher.publish('seq_tracker.persist_error', {
3635
4314
  phase: 'restore',
@@ -3640,6 +4319,59 @@ export class AUNClient {
3640
4319
  }).catch(() => { });
3641
4320
  }
3642
4321
  }
4322
+ /**
4323
+ * 把 seq_tracker state 里 group_event:/group_msg: 前缀的老/污染 group_id 归一化为 canonical。
4324
+ * 冲突取 max。同时落盘删除老 ns、写入新 ns,避免下次启动重复迁移。
4325
+ */
4326
+ _migrateSeqStateGroupIds(state) {
4327
+ if (!state || Object.keys(state).length === 0)
4328
+ return state;
4329
+ const renameMap = {};
4330
+ for (const ns of Object.keys(state)) {
4331
+ for (const prefix of ['group_event:', 'group_msg:']) {
4332
+ if (ns.startsWith(prefix)) {
4333
+ const oldGid = ns.slice(prefix.length);
4334
+ const newGid = normalizeGroupId(oldGid);
4335
+ if (newGid && newGid !== oldGid) {
4336
+ renameMap[ns] = `${prefix}${newGid}`;
4337
+ }
4338
+ break;
4339
+ }
4340
+ }
4341
+ }
4342
+ if (Object.keys(renameMap).length === 0)
4343
+ return state;
4344
+ const newState = { ...state };
4345
+ for (const [oldNs, newNs] of Object.entries(renameMap)) {
4346
+ const oldVal = Number(newState[oldNs] ?? 0);
4347
+ const curVal = Number(newState[newNs] ?? 0);
4348
+ delete newState[oldNs];
4349
+ newState[newNs] = Math.max(oldVal, curVal);
4350
+ }
4351
+ this._clientLog.info(`SeqTracker group_id 迁移:${Object.keys(renameMap).length} 个命名空间重写`);
4352
+ // 落盘
4353
+ const saver = this._keystore.saveSeq;
4354
+ const deleter = this._keystore.deleteSeq;
4355
+ if (typeof saver === 'function' && this._aid) {
4356
+ for (const [oldNs, newNs] of Object.entries(renameMap)) {
4357
+ if (typeof deleter === 'function') {
4358
+ try {
4359
+ deleter.call(this._keystore, this._aid, this._deviceId, this._slotId, oldNs);
4360
+ }
4361
+ catch (e) {
4362
+ this._clientLog.debug(`删除旧 seq ns 失败: ns=${oldNs} err=${formatCaughtError(e)}`);
4363
+ }
4364
+ }
4365
+ try {
4366
+ saver.call(this._keystore, this._aid, this._deviceId, this._slotId, newNs, newState[newNs]);
4367
+ }
4368
+ catch (e) {
4369
+ this._clientLog.debug(`写入新 seq ns 失败: ns=${newNs} err=${formatCaughtError(e)}`);
4370
+ }
4371
+ }
4372
+ }
4373
+ return newState;
4374
+ }
3643
4375
  _currentSeqTrackerContext() {
3644
4376
  if (!this._aid)
3645
4377
  return null;
@@ -3694,7 +4426,7 @@ export class AUNClient {
3694
4426
  }
3695
4427
  }
3696
4428
  catch (exc) {
3697
- _clientLog('warn', '保存 SeqTracker 状态失败: %s', formatCaughtError(exc));
4429
+ this._clientLog.warn(`保存 SeqTracker 状态失败: ${formatCaughtError(exc)}`);
3698
4430
  // 通过内部 dispatcher 发布可观测事件,便于上层监控
3699
4431
  this._dispatcher.publish('seq_tracker.persist_error', {
3700
4432
  phase: 'save',
@@ -3776,8 +4508,8 @@ export class AUNClient {
3776
4508
  if (identity && isJsonObject(identity)) {
3777
4509
  this._identity = identity;
3778
4510
  this._aid = String(identity.aid ?? this._aid ?? '');
3779
- if (_debugLogger)
3780
- _debugLogger.setAid(` [${this._aid}]`);
4511
+ if (this._aid)
4512
+ this._logger.bindAid(this._aid);
3781
4513
  if (this._sessionParams !== null) {
3782
4514
  this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
3783
4515
  }
@@ -3806,7 +4538,7 @@ export class AUNClient {
3806
4538
  await this._uploadPrekey();
3807
4539
  }
3808
4540
  catch (exc) {
3809
- _clientLog('warn', 'prekey 上传失败: %s', formatCaughtError(exc));
4541
+ this._clientLog.warn(`prekey 上传失败: ${formatCaughtError(exc)}`);
3810
4542
  }
3811
4543
  }
3812
4544
  catch (err) {
@@ -3843,8 +4575,8 @@ export class AUNClient {
3843
4575
  identity.access_token = accessToken;
3844
4576
  this._identity = identity;
3845
4577
  this._aid = String(identity.aid ?? this._aid ?? '');
3846
- if (_debugLogger)
3847
- _debugLogger.setAid(` [${this._aid}]`);
4578
+ if (this._aid)
4579
+ this._logger.bindAid(this._aid);
3848
4580
  const persistIdentity = this._auth._persistIdentity;
3849
4581
  if (typeof persistIdentity === 'function') {
3850
4582
  persistIdentity.call(this._auth, identity);
@@ -3923,8 +4655,6 @@ export class AUNClient {
3923
4655
  this._startHeartbeatTask();
3924
4656
  this._startTokenRefreshTask();
3925
4657
  this._startGroupEpochTasks();
3926
- // 上线/重连后一次性补齐群消息和群事件
3927
- this._syncAllGroupsOnce().catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
3928
4658
  }
3929
4659
  /** 停止所有后台任务 */
3930
4660
  _stopBackgroundTasks() {
@@ -3961,9 +4691,10 @@ export class AUNClient {
3961
4691
  _startHeartbeatTask() {
3962
4692
  if (this._heartbeatTimer !== null)
3963
4693
  return;
3964
- const interval = Number(this._sessionOptions.heartbeat_interval ?? 30) * 1000;
3965
- if (interval <= 0)
4694
+ const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
4695
+ if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
3966
4696
  return;
4697
+ const interval = Math.max(rawIntervalSeconds, 30) * 1000;
3967
4698
  // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
3968
4699
  // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
3969
4700
  // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
@@ -3977,10 +4708,10 @@ export class AUNClient {
3977
4708
  consecutiveFailures = 0;
3978
4709
  }).catch((exc) => {
3979
4710
  consecutiveFailures++;
3980
- _clientLog('warn', '心跳失败 (%s/%s): %s', consecutiveFailures, maxFailures, formatCaughtError(exc));
4711
+ this._clientLog.warn(`心跳失败 (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
3981
4712
  this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) }).catch(() => { });
3982
4713
  if (consecutiveFailures >= maxFailures) {
3983
- _clientLog('warn', '连续 %s 次心跳失败,触发断线重连', maxFailures);
4714
+ this._clientLog.warn(`连续 ${maxFailures} 次心跳失败,触发断线重连`);
3984
4715
  this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
3985
4716
  }
3986
4717
  });
@@ -3994,31 +4725,36 @@ export class AUNClient {
3994
4725
  _startTokenRefreshTask() {
3995
4726
  if (this._tokenRefreshTimer !== null)
3996
4727
  return;
3997
- const lead = Number(this._sessionOptions.token_refresh_before ?? 60);
3998
- const minimumSleep = 1000;
3999
- const scheduleNext = () => {
4728
+ const rawLead = Number(this._sessionOptions.token_refresh_before ?? DEFAULT_SESSION_OPTIONS.token_refresh_before);
4729
+ const lead = Number.isFinite(rawLead) && rawLead > 0
4730
+ ? rawLead
4731
+ : DEFAULT_SESSION_OPTIONS.token_refresh_before;
4732
+ const scheduleNext = (delayMs = TOKEN_REFRESH_CHECK_INTERVAL_MS) => {
4000
4733
  if (this._closing)
4001
4734
  return;
4002
- if (this._state !== 'connected' || !this._gatewayUrl) {
4003
- this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
4004
- this._unrefTimer(this._tokenRefreshTimer);
4005
- return;
4006
- }
4007
- let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
4008
- if (identity === null) {
4009
- this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
4010
- this._unrefTimer(this._tokenRefreshTimer);
4011
- return;
4012
- }
4013
- this._identity = identity;
4014
- const expiresAt = this._auth.getAccessTokenExpiry(identity);
4015
- if (expiresAt === null) {
4016
- this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
4017
- this._unrefTimer(this._tokenRefreshTimer);
4018
- return;
4019
- }
4020
- const delay = Math.max((expiresAt - lead - Date.now() / 1000) * 1000, minimumSleep);
4021
4735
  this._tokenRefreshTimer = setTimeout(async () => {
4736
+ if (this._closing)
4737
+ return;
4738
+ this._tokenRefreshTimer = null;
4739
+ if (this._state !== 'connected' || !this._gatewayUrl) {
4740
+ scheduleNext();
4741
+ return;
4742
+ }
4743
+ let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
4744
+ if (identity === null) {
4745
+ scheduleNext();
4746
+ return;
4747
+ }
4748
+ this._identity = identity;
4749
+ const expiresAt = this._auth.getAccessTokenExpiry(identity);
4750
+ if (expiresAt === null) {
4751
+ scheduleNext();
4752
+ return;
4753
+ }
4754
+ if ((expiresAt - Date.now() / 1000) > lead) {
4755
+ scheduleNext();
4756
+ return;
4757
+ }
4022
4758
  if (this._closing || this._state !== 'connected' || !this._gatewayUrl) {
4023
4759
  scheduleNext();
4024
4760
  return;
@@ -4039,7 +4775,7 @@ export class AUNClient {
4039
4775
  if (exc instanceof AuthError) {
4040
4776
  this._tokenRefreshFailures++;
4041
4777
  if (this._tokenRefreshFailures >= 3) {
4042
- _clientLog('warn', 'token 刷新连续失败 %d 次,停止刷新循环并触发重连', this._tokenRefreshFailures);
4778
+ this._clientLog.warn(`token 刷新连续失败 ${this._tokenRefreshFailures} 次,停止刷新循环并触发重连`);
4043
4779
  await this._dispatcher.publish('token.refresh_exhausted', {
4044
4780
  aid: this._identity?.aid ?? null,
4045
4781
  consecutive_failures: this._tokenRefreshFailures,
@@ -4049,17 +4785,17 @@ export class AUNClient {
4049
4785
  this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
4050
4786
  return;
4051
4787
  }
4052
- _clientLog('debug', 'token 刷新失败 (%d/3),下次重试: %s', this._tokenRefreshFailures, exc);
4788
+ this._clientLog.debug(`token 刷新失败 (${this._tokenRefreshFailures}/3),下次重试: ${exc}`);
4053
4789
  }
4054
4790
  else {
4055
4791
  await this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
4056
4792
  }
4057
4793
  }
4058
4794
  scheduleNext();
4059
- }, delay);
4795
+ }, delayMs);
4060
4796
  this._unrefTimer(this._tokenRefreshTimer);
4061
4797
  };
4062
- scheduleNext();
4798
+ scheduleNext(0);
4063
4799
  }
4064
4800
  /** 启动 prekey 刷新任务 */
4065
4801
  _startPrekeyRefreshTask() {
@@ -4151,7 +4887,7 @@ export class AUNClient {
4151
4887
  this._prekeyReplenished.add(prekeyId);
4152
4888
  }
4153
4889
  catch (exc) {
4154
- _clientLog('warn', '消费 prekey %s 后补充 current prekey 失败: %s', prekeyId, formatCaughtError(exc));
4890
+ this._clientLog.warn(`消费 prekey ${prekeyId} 后补充 current prekey 失败: ${formatCaughtError(exc)}`);
4155
4891
  }
4156
4892
  finally {
4157
4893
  this._prekeyReplenishInflight.delete(prekeyId);
@@ -4176,7 +4912,7 @@ export class AUNClient {
4176
4912
  }
4177
4913
  }
4178
4914
  catch (exc) {
4179
- _clientLog('warn', 'epoch 清理失败: %s', formatCaughtError(exc));
4915
+ this._clientLog.warn(`epoch 清理失败: ${formatCaughtError(exc)}`);
4180
4916
  }
4181
4917
  }, 3600_000);
4182
4918
  this._unrefTimer(this._groupEpochCleanupTimer);
@@ -4192,11 +4928,11 @@ export class AUNClient {
4192
4928
  ? this._keystore.listGroupSecretIds(this._aid)
4193
4929
  : [];
4194
4930
  for (const gid of groupIds) {
4195
- this._maybeLeadRotateGroupEpoch(gid).catch((exc) => _clientLog('warn', 'epoch 轮换失败: %s', formatCaughtError(exc)));
4931
+ this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch 轮换失败: ${formatCaughtError(exc)}`));
4196
4932
  }
4197
4933
  }
4198
4934
  catch (exc) {
4199
- _clientLog('warn', 'epoch 轮换失败: %s', formatCaughtError(exc));
4935
+ this._clientLog.warn(`epoch 轮换失败: ${formatCaughtError(exc)}`);
4200
4936
  }
4201
4937
  }, rotateInterval * 1000);
4202
4938
  this._unrefTimer(this._groupEpochRotateTimer);
@@ -4244,7 +4980,7 @@ export class AUNClient {
4244
4980
  _onGatewayDisconnect(data) {
4245
4981
  const code = data?.code;
4246
4982
  const reason = data?.reason ?? '';
4247
- _clientLog('warn', '服务端主动断开: code=%s, reason=%s', code, reason);
4983
+ this._clientLog.warn(`服务端主动断开: code=${code}, reason=${reason}`);
4248
4984
  this._serverKicked = true;
4249
4985
  }
4250
4986
  /** 传输层断线回调 */
@@ -4265,7 +5001,7 @@ export class AUNClient {
4265
5001
  if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
4266
5002
  this._state = 'terminal_failed';
4267
5003
  const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
4268
- _clientLog('warn', '抑制自动重连: %s', reason);
5004
+ this._clientLog.warn(`抑制自动重连: ${reason}`);
4269
5005
  await this._dispatcher.publish('connection.state', {
4270
5006
  state: this._state, error, reason,
4271
5007
  });
@@ -4282,7 +5018,7 @@ export class AUNClient {
4282
5018
  this._reconnectActive = true;
4283
5019
  this._reconnectAbort = new AbortController();
4284
5020
  this._reconnectLoop(serverInitiated).catch((exc) => {
4285
- _clientLog('warn', '重连循环异常: %s', formatCaughtError(exc));
5021
+ this._clientLog.warn(`重连循环异常: ${formatCaughtError(exc)}`);
4286
5022
  });
4287
5023
  }
4288
5024
  /** 重连循环(for 循环 + AbortController,与 JS/Python 对齐) */
@@ -4368,6 +5104,69 @@ export class AUNClient {
4368
5104
  }
4369
5105
  this._reconnectActive = false;
4370
5106
  }
5107
+ // ── Named Group(命名群)高层 API ────────────────────────────
5108
+ /**
5109
+ * 创建命名群:本地生成 P-256 keypair,调用 group.create 传入 public_key,
5110
+ * 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
5111
+ */
5112
+ async createNamedGroup(groupName, opts = {}) {
5113
+ const cp = new CryptoProvider();
5114
+ const identity = cp.generateIdentity();
5115
+ const params = {};
5116
+ for (const [k, v] of Object.entries(opts)) {
5117
+ params[k] = v;
5118
+ }
5119
+ params.group_name = groupName;
5120
+ params.public_key = identity.public_key_der_b64;
5121
+ params.curve = 'P-256';
5122
+ const result = await this.call('group.create', params);
5123
+ const groupInfo = result?.group;
5124
+ const aidCert = result?.aid_cert;
5125
+ const groupAid = String(groupInfo?.group_aid ?? '');
5126
+ if (groupAid && aidCert) {
5127
+ this._keystore.saveIdentity(groupAid, {
5128
+ private_key_pem: identity.private_key_pem,
5129
+ public_key: identity.public_key_der_b64,
5130
+ curve: 'P-256',
5131
+ type: 'group_identity',
5132
+ });
5133
+ const certPem = String(aidCert.cert ?? '');
5134
+ if (certPem) {
5135
+ this._keystore.saveCert(groupAid, certPem);
5136
+ }
5137
+ }
5138
+ return result;
5139
+ }
5140
+ /**
5141
+ * 为已有普通群绑定命名 AID(升级为命名群)。
5142
+ */
5143
+ async bindGroupAid(groupId, groupName) {
5144
+ const cp = new CryptoProvider();
5145
+ const identity = cp.generateIdentity();
5146
+ const params = {
5147
+ group_id: groupId,
5148
+ group_name: groupName,
5149
+ public_key: identity.public_key_der_b64,
5150
+ curve: 'P-256',
5151
+ };
5152
+ const result = await this.call('group.bind_aid', params);
5153
+ const groupInfo = result?.group;
5154
+ const aidCert = result?.aid_cert;
5155
+ const groupAid = String(groupInfo?.group_aid ?? '');
5156
+ if (groupAid && aidCert) {
5157
+ this._keystore.saveIdentity(groupAid, {
5158
+ private_key_pem: identity.private_key_pem,
5159
+ public_key: identity.public_key_der_b64,
5160
+ curve: 'P-256',
5161
+ type: 'group_identity',
5162
+ });
5163
+ const certPem = String(aidCert.cert ?? '');
5164
+ if (certPem) {
5165
+ this._keystore.saveCert(groupAid, certPem);
5166
+ }
5167
+ }
5168
+ return result;
5169
+ }
4371
5170
  /** 判断是否应重试重连 */
4372
5171
  static _shouldRetryReconnect(error) {
4373
5172
  if (error instanceof AuthError) {