@agentunion/fastaun 0.2.15 → 0.2.16

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.
package/dist/client.js CHANGED
@@ -19,7 +19,7 @@ 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';
@@ -83,7 +83,7 @@ const INTERNAL_ONLY_METHODS = new Set([
83
83
  const DEFAULT_SESSION_OPTIONS = {
84
84
  auto_reconnect: true,
85
85
  heartbeat_interval: 30.0,
86
- token_refresh_before: 60.0,
86
+ token_refresh_before: 1800.0,
87
87
  retry: {
88
88
  initial_delay: 1.0,
89
89
  max_delay: 64.0,
@@ -98,6 +98,7 @@ const DEFAULT_SESSION_OPTIONS = {
98
98
  };
99
99
  const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
100
100
  const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
101
+ const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
101
102
  const GROUP_ROTATION_LEASE_MS = 120_000;
102
103
  const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
103
104
  const PENDING_DECRYPT_LIMIT = 100;
@@ -139,9 +140,15 @@ const SIGNED_METHODS = new Set([
139
140
  'group.resources.delete', 'group.resources.request_add',
140
141
  'group.resources.direct_add', 'group.resources.approve_request',
141
142
  'group.resources.reject_request',
143
+ 'group.commit_state',
144
+ 'group.e2ee.begin_rotation', 'group.e2ee.commit_rotation',
145
+ 'group.e2ee.abort_rotation',
146
+ 'group.ban', 'group.unban',
147
+ 'group.dissolve', 'group.suspend', 'group.resume',
142
148
  ]);
143
- /** peer 证书缓存 TTL(10 分钟) */
144
- const PEER_CERT_CACHE_TTL = 600;
149
+ /** peer 证书缓存 TTL(1 小时) */
150
+ const PEER_CERT_CACHE_TTL = 3600;
151
+ const PEER_PREKEYS_CACHE_TTL = 3600;
145
152
  const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
146
153
  function isGroupServiceAid(value) {
147
154
  const text = String(value ?? '').trim();
@@ -213,6 +220,19 @@ function normalizePeerPrekeys(prekeys) {
213
220
  }
214
221
  return filtered;
215
222
  }
223
+ /** 判断加密失败是否由过期的对端证书或 prekey 引起,可通过刷新缓存重试 */
224
+ function isRetryablePeerMaterialError(error) {
225
+ const localCode = String(error?.localCode ?? error?.code ?? '').trim();
226
+ if (localCode === 'PEER_CERT_FINGERPRINT_MISMATCH'
227
+ || localCode === 'PREKEY_CERT_FINGERPRINT_MISMATCH'
228
+ || localCode === 'PREKEY_SIGNATURE_VERIFY_FAILED') {
229
+ return true;
230
+ }
231
+ const message = error instanceof Error ? error.message : String(error ?? '');
232
+ return message.includes('peer cert fingerprint mismatch for ')
233
+ || message.includes('prekey cert fingerprint mismatch')
234
+ || message.includes('prekey 签名验证失败');
235
+ }
216
236
  function formatCaughtError(error) {
217
237
  return error instanceof Error ? error : String(error);
218
238
  }
@@ -352,6 +372,8 @@ export class AUNClient {
352
372
  _groupEpochRotationInflight = new Set();
353
373
  _groupEpochRecoveryInflight = new Map();
354
374
  _groupMembershipRotationDone = new Set();
375
+ /** 群密钥 backfill 去重:已完成/进行中的 key 集合,防止重复分发 */
376
+ _groupMemberKeyBackfillDone = new Set();
355
377
  _groupEpochRotationRetryTimers = new Map();
356
378
  // ── 后台任务定时器 ──────────────────────────────────────────
357
379
  _heartbeatTimer = null;
@@ -425,6 +447,8 @@ export class AUNClient {
425
447
  this._dispatcher.subscribe('_raw.group.message_created', (data) => this._onRawGroupMessageCreated(data));
426
448
  // 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
427
449
  this._dispatcher.subscribe('_raw.group.changed', (data) => this._onRawGroupChanged(data));
450
+ // 群组状态提交事件:验证 state_hash 链并更新本地存储
451
+ this._dispatcher.subscribe('_raw.group.state_committed', (data) => this._onGroupStateCommitted(data));
428
452
  // 其他事件直接透传
429
453
  for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
430
454
  this._dispatcher.subscribe(`_raw.${evt}`, (data) => this._dispatcher.publish(evt, data));
@@ -468,6 +492,7 @@ export class AUNClient {
468
492
  if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
469
493
  throw new StateError(`connect not allowed in state ${this._state}`);
470
494
  }
495
+ this._state = 'connecting';
471
496
  const params = { ...auth };
472
497
  if (options)
473
498
  Object.assign(params, options);
@@ -477,7 +502,16 @@ export class AUNClient {
477
502
  const callTimeoutSec = this._sessionOptions.timeouts.call;
478
503
  this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
479
504
  this._closing = false;
480
- await this._connectOnce(normalized, false);
505
+ try {
506
+ await this._connectOnce(normalized, false);
507
+ }
508
+ catch (err) {
509
+ // 连接失败时回退状态,允许重试
510
+ if (this._state === 'connecting' || this._state === 'authenticating') {
511
+ this._state = 'disconnected';
512
+ }
513
+ throw err;
514
+ }
481
515
  }
482
516
  /** 关闭连接 */
483
517
  async close() {
@@ -722,8 +756,11 @@ export class AUNClient {
722
756
  const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
723
757
  if (groupId && this._membershipRotationChanged(method, result)) {
724
758
  const expectedEpoch = this._membershipRotationExpectedEpoch(result);
759
+ // 自加入方法(request_join/use_invite_code)需要 allowMember=true,
760
+ // 因为新成员角色是 member,必须允许 member 参与 leader 选举。
761
+ const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
725
762
  // P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
726
- const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
763
+ const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
727
764
  const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
728
765
  await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => _clientLog('warn', 'membership RPC epoch rotation fallback failed: %s', formatCaughtError(exc)));
729
766
  }
@@ -780,50 +817,73 @@ export class AUNClient {
780
817
  if (!this._p2pSynced) {
781
818
  await this._lazySyncP2p();
782
819
  }
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({
820
+ // 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
821
+ const sendAttempt = async (refreshPeerMaterial = false) => {
822
+ const recipientPrekeys = refreshPeerMaterial
823
+ ? await this._refreshPeerPrekeys(toAid)
824
+ : await this._fetchPeerPrekeys(toAid);
825
+ const selfSyncCopies = await this._buildSelfSyncCopies({
826
+ logicalToAid: toAid,
827
+ payload,
828
+ messageId,
829
+ timestamp,
830
+ protectedHeaders,
831
+ });
832
+ // 多设备过滤:只保留有有效 device_id 的可路由 prekey,
833
+ // 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
834
+ const routablePrekeys = recipientPrekeys.filter(pk => {
835
+ const did = String(pk.device_id ?? '').trim();
836
+ return did && did !== PREKEY_FALLBACK_DEVICE_ID;
837
+ });
838
+ const canUseMultiDevice = routablePrekeys.length > 0
839
+ && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
840
+ if (!canUseMultiDevice) {
841
+ return await this._sendEncryptedSingle({
842
+ toAid,
843
+ payload,
844
+ messageId,
845
+ timestamp,
846
+ prekey: routablePrekeys[0] ?? recipientPrekeys[0],
847
+ persistRequired,
848
+ protectedHeaders,
849
+ });
850
+ }
851
+ const recipientCopies = await this._buildRecipientDeviceCopies({
793
852
  toAid,
794
853
  payload,
795
854
  messageId,
796
855
  timestamp,
797
- prekey: recipientPrekeys[0],
798
- persistRequired,
856
+ prekeys: routablePrekeys,
799
857
  protectedHeaders,
800
858
  });
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: {
859
+ const sendParams = {
860
+ to: toAid,
861
+ payload: {
862
+ type: 'e2ee.multi_device',
863
+ logical_message_id: messageId,
864
+ recipient_copies: recipientCopies,
865
+ self_copies: selfSyncCopies,
866
+ },
813
867
  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,
868
+ encrypted: true,
869
+ message_id: messageId,
870
+ timestamp,
871
+ };
872
+ if (persistRequired) {
873
+ sendParams.persist_required = true;
874
+ }
875
+ return await this._transport.call('message.send', sendParams);
822
876
  };
823
- if (persistRequired) {
824
- sendParams.persist_required = true;
877
+ // 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
878
+ try {
879
+ return await sendAttempt(false);
825
880
  }
826
- return await this._transport.call('message.send', sendParams);
881
+ catch (exc) {
882
+ if (!isRetryablePeerMaterialError(exc))
883
+ throw exc;
884
+ _clientLog('warn', 'peer cert/prekey mismatch for %s, refreshing and retrying once', toAid);
885
+ }
886
+ return await sendAttempt(true);
827
887
  }
828
888
  async _sendEncryptedSingle(opts) {
829
889
  let prekey = opts.prekey ?? null;
@@ -927,7 +987,15 @@ export class AUNClient {
927
987
  if (deviceId === this._deviceId) {
928
988
  continue;
929
989
  }
930
- const peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
990
+ let peerCertPem;
991
+ try {
992
+ peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
993
+ }
994
+ catch (e) {
995
+ // 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
996
+ _clientLog('warn', `self-sync 跳过设备 ${deviceId}: 证书解析失败 (${e}),可能是旧 prekey`);
997
+ continue;
998
+ }
931
999
  const [envelope, encryptResult] = this._encryptCopyPayload({
932
1000
  logicalToAid: opts.logicalToAid,
933
1001
  payload: opts.payload,
@@ -1247,7 +1315,7 @@ export class AUNClient {
1247
1315
  _clientLog('warn', 'group %s epoch precheck failed: %s', groupId, formatCaughtError(exc));
1248
1316
  return;
1249
1317
  }
1250
- let serverEpoch = Number(epochResult.epoch ?? 0);
1318
+ let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
1251
1319
  if (!Number.isFinite(serverEpoch))
1252
1320
  return;
1253
1321
  const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
@@ -1263,7 +1331,7 @@ export class AUNClient {
1263
1331
  let effectiveLocalEpoch = initialLocalEpoch;
1264
1332
  if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
1265
1333
  epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
1266
- serverEpoch = Number(epochResult.epoch ?? 0);
1334
+ serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
1267
1335
  if (serverEpoch === 0) {
1268
1336
  throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
1269
1337
  }
@@ -1275,7 +1343,9 @@ export class AUNClient {
1275
1343
  while (Date.now() < waitDeadline) {
1276
1344
  await new Promise(resolve => setTimeout(resolve, 150));
1277
1345
  const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
1278
- const refreshedEpoch = isJsonObject(refreshed) ? Number(refreshed.epoch ?? 0) : 0;
1346
+ const refreshedEpoch = isJsonObject(refreshed)
1347
+ ? Number(refreshed.committed_epoch ?? refreshed.epoch ?? 0)
1348
+ : 0;
1279
1349
  const currentLocal = await this._groupE2ee.currentEpoch(groupId);
1280
1350
  if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
1281
1351
  epochResult = refreshed;
@@ -1291,7 +1361,7 @@ export class AUNClient {
1291
1361
  }
1292
1362
  }
1293
1363
  _clientLog('warn', 'group %s local epoch=%s < server epoch=%s; requesting key recovery', groupId, effectiveLocalEpoch, serverEpoch);
1294
- await this._requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult);
1364
+ await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
1295
1365
  const deadline = Date.now() + 5000;
1296
1366
  while (Date.now() < deadline) {
1297
1367
  await new Promise(resolve => setTimeout(resolve, 150));
@@ -1364,8 +1434,23 @@ export class AUNClient {
1364
1434
  async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
1365
1435
  if (committedEpoch <= 0)
1366
1436
  return committedEpoch;
1367
- const secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
1368
- const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
1437
+ let secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
1438
+ let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
1439
+ if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
1440
+ const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
1441
+ _clientLog('warn', '群 %s committed epoch %s 的成员快照与当前成员不一致,触发成员变更轮换修复', groupId, committedEpoch);
1442
+ await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
1443
+ const refreshed = await this._committedGroupEpochState(groupId);
1444
+ const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
1445
+ if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
1446
+ committedEpoch = refreshedCommittedEpoch;
1447
+ committedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
1448
+ secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
1449
+ }
1450
+ if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
1451
+ throw new StateError(`group ${groupId} committed membership is stale at epoch ${committedEpoch}; key rotation repair has not completed`);
1452
+ }
1453
+ }
1369
1454
  if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
1370
1455
  return committedEpoch;
1371
1456
  }
@@ -1386,6 +1471,45 @@ export class AUNClient {
1386
1471
  }
1387
1472
  return committedEpoch;
1388
1473
  }
1474
+ async _committedRotationMembershipGap(groupId, committedEpoch, committedRotation) {
1475
+ if (!this._aid || committedEpoch <= 0 || !committedRotation)
1476
+ return false;
1477
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
1478
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
1479
+ : [];
1480
+ if (expectedMembers.length === 0)
1481
+ return false;
1482
+ try {
1483
+ const membersResult = await this.call('group.get_members', { group_id: groupId });
1484
+ const rawMembers = isJsonObject(membersResult)
1485
+ ? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
1486
+ : [];
1487
+ if (!Array.isArray(rawMembers))
1488
+ return false;
1489
+ const activeMembers = rawMembers
1490
+ .filter((item) => isJsonObject(item))
1491
+ .map((item) => ({
1492
+ aid: String(item.aid ?? '').trim(),
1493
+ status: String(item.status ?? 'active').trim().toLowerCase(),
1494
+ }))
1495
+ .filter((item) => item.aid && ['', 'active'].includes(item.status))
1496
+ .map((item) => item.aid)
1497
+ .sort();
1498
+ if (!activeMembers.includes(this._aid) || activeMembers.length === 0)
1499
+ return false;
1500
+ if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
1501
+ const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
1502
+ const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
1503
+ _clientLog('info', '群 %s committed membership gap: epoch=%s missing=%s extra=%s', groupId, committedEpoch, JSON.stringify(missing), JSON.stringify(extra));
1504
+ return true;
1505
+ }
1506
+ return false;
1507
+ }
1508
+ catch (exc) {
1509
+ _clientLog('debug', '查询当前成员失败,无法判断 committed membership gap: group=%s err=%s', groupId, formatCaughtError(exc));
1510
+ return false;
1511
+ }
1512
+ }
1389
1513
  // ── 客户端签名 ────────────────────────────────────────────
1390
1514
  /**
1391
1515
  * 为关键操作附加客户端 ECDSA 签名(client_signature 字段)。
@@ -1885,6 +2009,11 @@ export class AUNClient {
1885
2009
  // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
1886
2010
  if (et === 'group.message_created')
1887
2011
  continue;
2012
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2013
+ const cs = evt.client_signature;
2014
+ if (cs && typeof cs === 'object') {
2015
+ evt._verified = await this._verifyEventSignatureAsync(evt, cs);
2016
+ }
1888
2017
  // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
1889
2018
  await this._dispatcher.publish('group.changed', evt);
1890
2019
  }
@@ -1899,37 +2028,6 @@ export class AUNClient {
1899
2028
  this._gapFillDone.delete(dedupKey);
1900
2029
  }
1901
2030
  }
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
2031
  /**
1934
2032
  * 处理群组变更事件:透传给用户,并在成员离开/被踢时自动触发 epoch 轮换。
1935
2033
  * 按协议,轮换由剩余在线 admin/owner 负责。
@@ -2105,15 +2203,38 @@ export class AUNClient {
2105
2203
  }
2106
2204
  }
2107
2205
  }
2206
+ // 成员加入:按 action 区分策略
2207
+ // - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
2208
+ // - joined / invite_code_used(开放群/邀请码群):新成员先恢复 committed_epoch,延迟轮换
2108
2209
  if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
2109
2210
  if (groupId) {
2110
2211
  {
2212
+ const action = String(d.action ?? '');
2111
2213
  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 ?? ''));
2214
+ const joinedAids = this._joinedMemberAidsFromPayload(d);
2215
+ const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
2216
+ _clientLog('warn', 'DEBUG: group.changed action=%s groupId=%s joinedAids=%s myAid=%s isSelfJoining=%s expectedEpoch=%s', action, groupId, JSON.stringify(joinedAids), this._aid, String(isSelfJoining), String(expectedEpoch));
2217
+ if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
2218
+ // open/invite_code 群:所有在线成员都参与延迟轮换
2219
+ // 新成员自己延迟更长,优先让其他在线成员先轮换
2220
+ const triggerId = this._membershipRotationTriggerId(groupId, d);
2221
+ if (!isSelfJoining) {
2222
+ this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
2223
+ }
2224
+ if (expectedEpoch !== null) {
2225
+ const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
2226
+ this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
2227
+ }
2114
2228
  }
2115
2229
  else {
2116
- this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
2230
+ // member_added / join_approved:立即轮换
2231
+ if (expectedEpoch === null) {
2232
+ const triggerId = this._membershipRotationTriggerId(groupId, d);
2233
+ this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
2234
+ }
2235
+ else {
2236
+ this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
2237
+ }
2117
2238
  }
2118
2239
  }
2119
2240
  }
@@ -2130,11 +2251,94 @@ export class AUNClient {
2130
2251
  await this._dispatcher.publish('group.changed', data);
2131
2252
  }
2132
2253
  }
2254
+ /**
2255
+ * 处理 event/group.state_committed:验证 state_hash 链并更新本地存储。
2256
+ * 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
2257
+ */
2258
+ async _onGroupStateCommitted(data) {
2259
+ if (!isJsonObject(data))
2260
+ return;
2261
+ const d = data;
2262
+ const groupId = String(d.group_id ?? '').trim();
2263
+ if (!groupId)
2264
+ return;
2265
+ // 提交者签名验证(兼容旧版:无签名时继续)
2266
+ const cs = d.client_signature;
2267
+ if (cs && isJsonObject(cs)) {
2268
+ const verified = await this._verifyEventSignatureAsync(d, cs);
2269
+ if (verified === false) {
2270
+ _clientLog('warn', 'state_committed 提交者签名验证失败 group=%s', groupId);
2271
+ return;
2272
+ }
2273
+ d._verified = verified;
2274
+ }
2275
+ const stateVersion = Number(d.state_version ?? 0);
2276
+ const stateHash = String(d.state_hash ?? '').trim();
2277
+ const prevStateHash = String(d.prev_state_hash ?? '').trim();
2278
+ const keyEpoch = Number(d.key_epoch ?? 0);
2279
+ const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
2280
+ const policySnapshot = String(d.policy_snapshot ?? '').trim();
2281
+ // 1. 验证 prev_state_hash 连续性
2282
+ const loadFn = this._keystore.loadGroupState;
2283
+ const localState = loadFn ? loadFn.call(this._keystore, groupId) : null;
2284
+ if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
2285
+ _clientLog('warn', 'state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
2286
+ // 回源同步
2287
+ try {
2288
+ const serverState = await this._transport.call('group.get_state', { group_id: groupId });
2289
+ if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
2290
+ const sv = Number(serverState.state_version ?? 0);
2291
+ const sHash = String(serverState.state_hash ?? '');
2292
+ const sEpoch = Number(serverState.key_epoch ?? 0);
2293
+ const sMembersJson = String(serverState.membership_snapshot ?? '');
2294
+ const sPolicyJson = String(serverState.policy_snapshot ?? '');
2295
+ const sPrev = String(serverState.prev_state_hash ?? '');
2296
+ // 回源也做 hash 验证
2297
+ if (sMembersJson && sHash) {
2298
+ const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
2299
+ const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
2300
+ const computed = computeStateHash({
2301
+ groupId, stateVersion: sv, keyEpoch: sEpoch,
2302
+ members: sMembers, policy: sPolicy, prevStateHash: sPrev,
2303
+ });
2304
+ if (computed !== sHash) {
2305
+ _clientLog('warn', '回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
2306
+ return;
2307
+ }
2308
+ }
2309
+ const saveFn = this._keystore.saveGroupState;
2310
+ if (saveFn) {
2311
+ saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
2312
+ }
2313
+ }
2314
+ }
2315
+ catch (exc) {
2316
+ _clientLog('warn', 'state 回源失败 group=%s: %s', groupId, formatCaughtError(exc));
2317
+ }
2318
+ return;
2319
+ }
2320
+ // 2. 本地重算验证
2321
+ const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
2322
+ const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
2323
+ const computed = computeStateHash({
2324
+ groupId, stateVersion, keyEpoch,
2325
+ members, policy, prevStateHash,
2326
+ });
2327
+ if (computed !== stateHash) {
2328
+ _clientLog('warn', 'state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
2329
+ return;
2330
+ }
2331
+ // 3. 更新本地存储
2332
+ const saveFn = this._keystore.saveGroupState;
2333
+ if (saveFn) {
2334
+ saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
2335
+ }
2336
+ }
2133
2337
  /**
2134
2338
  * 成员退出/被踢后,判断本地是否为 leader admin 并发起 epoch 轮换。
2135
2339
  * 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
2136
2340
  */
2137
- async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
2341
+ async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
2138
2342
  const myAid = this._aid;
2139
2343
  if (!myAid || this._closing || this._state !== 'connected')
2140
2344
  return;
@@ -2163,24 +2367,46 @@ export class AUNClient {
2163
2367
  if (!Array.isArray(rawList))
2164
2368
  return;
2165
2369
  const admins = [];
2370
+ const members = [];
2166
2371
  for (const m of rawList) {
2167
2372
  if (!isJsonObject(m))
2168
2373
  continue;
2169
2374
  const role = String(m.role ?? '');
2170
2375
  const aid = String(m.aid ?? '');
2171
- if (aid && (role === 'admin' || role === 'owner'))
2376
+ if (!aid)
2377
+ continue;
2378
+ if (role === 'admin' || role === 'owner') {
2172
2379
  admins.push(aid);
2380
+ }
2381
+ else if (allowMember && role === 'member') {
2382
+ members.push(aid);
2383
+ }
2173
2384
  }
2174
- if (admins.length === 0)
2385
+ // 候选列表:admin/owner 排序在前,member 排序在后
2386
+ let candidates = [...admins.sort(), ...members.sort()];
2387
+ if (candidates.length === 0)
2175
2388
  return;
2176
- admins.sort();
2177
- const leader = admins[0];
2389
+ // 没有当前 epoch key 的成员不参与 leader 选举。
2390
+ // open/invite_code 群排除后为空时保留自己兜底(从服务端取 prev chain)。
2391
+ if (expectedEpoch !== null && expectedEpoch > 0) {
2392
+ const localSecret = this._groupE2ee.loadSecret(groupId, expectedEpoch);
2393
+ if (!localSecret) {
2394
+ const filtered = candidates.filter(c => c !== myAid);
2395
+ if (filtered.length > 0) {
2396
+ candidates = filtered;
2397
+ }
2398
+ else if (!allowMember) {
2399
+ return;
2400
+ }
2401
+ }
2402
+ }
2403
+ const leader = candidates[0];
2178
2404
  if (leader === myAid) {
2179
2405
  // 我是 leader,直接发起
2180
2406
  await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
2181
2407
  return;
2182
2408
  }
2183
- if (!admins.includes(myAid))
2409
+ if (!candidates.includes(myAid))
2184
2410
  return;
2185
2411
  // 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
2186
2412
  const jitterMs = 2000 + Math.floor(Math.random() * 4000);
@@ -2370,6 +2596,11 @@ export class AUNClient {
2370
2596
  result = this._groupE2ee.handleIncoming(actualPayload);
2371
2597
  if (result === 'distribution') {
2372
2598
  await this._discardGroupDistributionIfStale(actualPayload);
2599
+ // 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
2600
+ const distGroupId = actualPayload.group_id;
2601
+ if (distGroupId && !this._groupSynced.has(distGroupId)) {
2602
+ this._lazySyncGroup(distGroupId).catch(() => { });
2603
+ }
2373
2604
  }
2374
2605
  // S14: 非控制面消息且 handleIncoming 不识别 → 不拦截
2375
2606
  if (!isControlPlane && result === null)
@@ -2379,7 +2610,9 @@ export class AUNClient {
2379
2610
  const groupId = String(actualPayload.group_id ?? '');
2380
2611
  const requester = String(actualPayload.requester_aid ?? '');
2381
2612
  let members = this._groupE2ee.getMemberAids(groupId);
2382
- // 请求者不在本地成员列表时,回源查询服务端最新成员列表
2613
+ // 请求者不在本地成员列表时,回源查询服务端最新成员列表,
2614
+ // 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
2615
+ // (历史 epoch 的成员隔离由 handleKeyRequest 内部负责)。
2383
2616
  if (requester && !members.includes(requester)) {
2384
2617
  try {
2385
2618
  const membersResult = await this.call('group.get_members', { group_id: groupId });
@@ -2387,15 +2620,6 @@ export class AUNClient {
2387
2620
  ? membersResult.members
2388
2621
  : [];
2389
2622
  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
2623
  }
2400
2624
  catch (exc) {
2401
2625
  _clientLog('warn', '群组 %s 成员列表回源失败: %s', groupId, formatCaughtError(exc));
@@ -2531,7 +2755,7 @@ export class AUNClient {
2531
2755
  if (normalized.length > 0) {
2532
2756
  this._peerPrekeysCache.set(peerAid, {
2533
2757
  items: normalized.map((item) => ({ ...item })),
2534
- expireAt: Date.now() / 1000 + 300,
2758
+ expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
2535
2759
  });
2536
2760
  this._e2ee.cachePrekey(peerAid, normalized[0]);
2537
2761
  return normalized;
@@ -2546,7 +2770,7 @@ export class AUNClient {
2546
2770
  if (normalized.length > 0) {
2547
2771
  this._peerPrekeysCache.set(peerAid, {
2548
2772
  items: normalized.map((item) => ({ ...item })),
2549
- expireAt: Date.now() / 1000 + 300,
2773
+ expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
2550
2774
  });
2551
2775
  this._e2ee.cachePrekey(peerAid, normalized[0]);
2552
2776
  return normalized.map((item) => ({ ...item }));
@@ -2579,6 +2803,25 @@ export class AUNClient {
2579
2803
  }
2580
2804
  return { ...prekeys[0] };
2581
2805
  }
2806
+ /** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
2807
+ _invalidatePeerPrekeyCache(peerAid) {
2808
+ this._peerPrekeysCache.delete(peerAid);
2809
+ this._e2ee.invalidatePrekeyCache(peerAid);
2810
+ }
2811
+ /** 清除对端证书缓存(精确匹配 aid 或 aid# 前缀的所有条目) */
2812
+ _clearPeerCertCache(peerAid) {
2813
+ for (const cacheKey of this._certCache.keys()) {
2814
+ if (cacheKey === peerAid || cacheKey.startsWith(`${peerAid}#`)) {
2815
+ this._certCache.delete(cacheKey);
2816
+ }
2817
+ }
2818
+ }
2819
+ /** 清除对端所有缓存后重新拉取 prekey(用于指纹不匹配时的强制刷新) */
2820
+ async _refreshPeerPrekeys(peerAid) {
2821
+ this._invalidatePeerPrekeyCache(peerAid);
2822
+ this._clearPeerCertCache(peerAid);
2823
+ return await this._fetchPeerPrekeys(peerAid);
2824
+ }
2582
2825
  /** 生成 prekey 并上传到服务端 */
2583
2826
  async _uploadPrekey() {
2584
2827
  const prekeyMaterial = this._e2ee.generatePrekey();
@@ -2642,7 +2885,11 @@ export class AUNClient {
2642
2885
  * 零信任:不直接信任 keystore 中可能由恶意服务端注入的证书。
2643
2886
  */
2644
2887
  _getVerifiedPeerCert(aid, certFingerprint) {
2645
- const cached = this._certCache.get(AUNClient._certCacheKey(aid, certFingerprint));
2888
+ let cached = this._certCache.get(AUNClient._certCacheKey(aid, certFingerprint));
2889
+ // 带 fingerprint 查不到时,降级用 aid 再查一次
2890
+ if (!cached && certFingerprint) {
2891
+ cached = this._certCache.get(AUNClient._certCacheKey(aid, undefined));
2892
+ }
2646
2893
  const now = Date.now() / 1000;
2647
2894
  if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
2648
2895
  return cached.certPem;
@@ -2783,7 +3030,254 @@ export class AUNClient {
2783
3030
  this._groupEpochRecoveryInflight.set(key, promise);
2784
3031
  return promise;
2785
3032
  }
3033
+ static _extractGroupJoinMode(payload) {
3034
+ if (!isJsonObject(payload))
3035
+ return '';
3036
+ for (const key of ['join_mode', 'mode']) {
3037
+ const v = String(payload[key] ?? '').trim().toLowerCase();
3038
+ if (v)
3039
+ return v;
3040
+ }
3041
+ for (const key of ['join_requirements', 'join']) {
3042
+ const nested = payload[key];
3043
+ if (isJsonObject(nested)) {
3044
+ for (const nk of ['mode', 'join_mode']) {
3045
+ const v = String(nested[nk] ?? '').trim().toLowerCase();
3046
+ if (v)
3047
+ return v;
3048
+ }
3049
+ }
3050
+ }
3051
+ if (isJsonObject(payload.group)) {
3052
+ const v = AUNClient._extractGroupJoinMode(payload.group);
3053
+ if (v)
3054
+ return v;
3055
+ }
3056
+ const settings = payload.settings;
3057
+ if (isJsonObject(settings)) {
3058
+ for (const key of ['join.mode', 'join_mode', 'mode']) {
3059
+ const v = String(settings[key] ?? '').trim().toLowerCase();
3060
+ if (v)
3061
+ return v;
3062
+ }
3063
+ }
3064
+ if (Array.isArray(settings)) {
3065
+ for (const item of settings) {
3066
+ if (!isJsonObject(item))
3067
+ continue;
3068
+ const k = String(item.key ?? item.name ?? '').trim().toLowerCase();
3069
+ if (k === 'join.mode' || k === 'join_mode' || k === 'mode') {
3070
+ const v = String(item.value ?? '').trim().toLowerCase();
3071
+ if (v)
3072
+ return v;
3073
+ }
3074
+ }
3075
+ }
3076
+ return '';
3077
+ }
3078
+ static _joinModeAllowsMemberEpochRotation(mode) {
3079
+ const m = mode.trim().toLowerCase();
3080
+ return m === 'open' || m === 'invite_only' || m === 'invite_code';
3081
+ }
3082
+ async _groupAllowsMemberEpochRotation(groupId) {
3083
+ try {
3084
+ const resp = await this.call('group.get_join_requirements', { group_id: groupId });
3085
+ const mode = AUNClient._extractGroupJoinMode(resp);
3086
+ if (mode)
3087
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
3088
+ }
3089
+ catch { /* best effort */ }
3090
+ try {
3091
+ const resp = await this.call('group.get_settings', { group_id: groupId, keys: ['join.mode'] });
3092
+ const mode = AUNClient._extractGroupJoinMode(resp);
3093
+ if (mode)
3094
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
3095
+ }
3096
+ catch { /* best effort */ }
3097
+ try {
3098
+ const resp = await this.call('group.get', { group_id: groupId });
3099
+ const mode = AUNClient._extractGroupJoinMode(resp);
3100
+ if (mode)
3101
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
3102
+ }
3103
+ catch { /* best effort */ }
3104
+ return false;
3105
+ }
3106
+ /** 尝试从服务端拉取 ECIES 加密的 epoch key 并解密存入 keystore */
3107
+ async _tryRecoverEpochKeyFromServer(groupId, epoch) {
3108
+ try {
3109
+ const params = { group_id: groupId };
3110
+ if (epoch > 0)
3111
+ params.epoch = epoch;
3112
+ const result = await this.call('group.e2ee.get_epoch_key', params);
3113
+ if (!isJsonObject(result))
3114
+ return false;
3115
+ const encryptedB64 = result.encrypted_key;
3116
+ if (!encryptedB64 || typeof encryptedB64 !== 'string')
3117
+ return false;
3118
+ const serverEpoch = Number(result.epoch ?? epoch);
3119
+ const encryptedBytes = Buffer.from(encryptedB64, 'base64');
3120
+ // 用自己的 AID 私钥 ECIES 解密
3121
+ const myAid = this._aid || '';
3122
+ const keyPair = this._keystore.loadKeyPair(myAid);
3123
+ if (!keyPair?.private_key_pem) {
3124
+ _clientLog('warn', '无法加载 AID 私钥用于 ECIES 解密: aid=%s', myAid);
3125
+ return false;
3126
+ }
3127
+ const { eciesDecrypt } = await import('./e2ee-group.js');
3128
+ const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
3129
+ if (!groupSecret || groupSecret.length !== 32) {
3130
+ _clientLog('warn', '服务端 epoch key ECIES 解密结果长度异常: group=%s epoch=%d len=%d', groupId, serverEpoch, groupSecret?.length ?? 0);
3131
+ return false;
3132
+ }
3133
+ // 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
3134
+ let memberAids = [];
3135
+ let committedRotation = null;
3136
+ let epochChain = '';
3137
+ try {
3138
+ const epochInfo = await this.call('group.e2ee.get_epoch', { group_id: groupId });
3139
+ if (isJsonObject(epochInfo)) {
3140
+ if (Array.isArray(epochInfo.members)) {
3141
+ memberAids = epochInfo.members
3142
+ .map((m) => {
3143
+ if (typeof m === 'string')
3144
+ return m;
3145
+ if (isJsonObject(m) && typeof m.aid === 'string')
3146
+ return m.aid;
3147
+ return '';
3148
+ })
3149
+ .filter((s) => s.length > 0);
3150
+ }
3151
+ if (isJsonObject(epochInfo.committed_rotation)) {
3152
+ committedRotation = epochInfo.committed_rotation;
3153
+ const rawChain = String(committedRotation.epoch_chain ?? '').trim();
3154
+ if (rawChain)
3155
+ epochChain = rawChain;
3156
+ // 如果有 expected_members,用它覆盖 memberAids
3157
+ if (Array.isArray(committedRotation.expected_members) && committedRotation.expected_members.length > 0) {
3158
+ memberAids = committedRotation.expected_members
3159
+ .map(item => String(item ?? '').trim())
3160
+ .filter(s => s.length > 0);
3161
+ }
3162
+ }
3163
+ }
3164
+ }
3165
+ catch { /* best effort */ }
3166
+ if (memberAids.length === 0) {
3167
+ _clientLog('warn', '服务端 epoch key 恢复缺少成员快照: group=%s epoch=%d', groupId, serverEpoch);
3168
+ return false;
3169
+ }
3170
+ const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
3171
+ // committed_rotation 存在时验证 commitment 和 epoch_chain
3172
+ let epochChainUnverified = null;
3173
+ let epochChainUnverifiedReason = null;
3174
+ if (committedRotation) {
3175
+ const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
3176
+ const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3177
+ if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
3178
+ _clientLog('warn', '服务端 epoch key 恢复 commitment 不匹配: group=%s epoch=%d', groupId, serverEpoch);
3179
+ return false;
3180
+ }
3181
+ if (epochChain && committedEpoch === serverEpoch) {
3182
+ let rotatorAid = '';
3183
+ for (const key of ['rotated_by', 'lease_owner', 'committed_by']) {
3184
+ const v = String(committedRotation[key] ?? '').trim();
3185
+ if (v) {
3186
+ rotatorAid = v;
3187
+ break;
3188
+ }
3189
+ }
3190
+ const prevData = this._groupE2ee.loadSecret(groupId, serverEpoch - 1);
3191
+ const prevChain = String(prevData?.epoch_chain ?? '').trim();
3192
+ if (prevChain && rotatorAid) {
3193
+ if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
3194
+ _clientLog('warn', '服务端 epoch key 恢复 epoch_chain 验证失败: group=%s epoch=%d rotator=%s', groupId, serverEpoch, rotatorAid);
3195
+ return false;
3196
+ }
3197
+ epochChainUnverified = false;
3198
+ }
3199
+ else {
3200
+ epochChainUnverified = true;
3201
+ epochChainUnverifiedReason = prevChain ? 'missing_rotator_aid' : 'missing_prev_chain';
3202
+ }
3203
+ }
3204
+ }
3205
+ const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
3206
+ if (!stored) {
3207
+ _clientLog('warn', '服务端 epoch key 恢复存储失败: group=%s epoch=%d', groupId, serverEpoch);
3208
+ return false;
3209
+ }
3210
+ _clientLog('info', '从服务端恢复 epoch key 成功: group=%s epoch=%d', groupId, serverEpoch);
3211
+ return true;
3212
+ }
3213
+ catch (exc) {
3214
+ _clientLog('debug', '从服务端恢复 epoch key 失败: group=%s epoch=%d err=%s', groupId, epoch, formatCaughtError(exc));
3215
+ return false;
3216
+ }
3217
+ }
3218
+ /** 为每个成员用其 AID 证书公钥 ECIES 加密 group_secret,返回 {aid: base64_ciphertext} */
3219
+ async _buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId) {
3220
+ try {
3221
+ const { eciesEncrypt } = await import('./e2ee-group.js');
3222
+ // 从 distribution payload 中提取 group_secret
3223
+ let groupSecretBytes = null;
3224
+ const distributions = Array.isArray(info.distributions) ? info.distributions : [];
3225
+ for (const dist of distributions) {
3226
+ if (isJsonObject(dist) && isJsonObject(dist.payload)) {
3227
+ const gsB64 = dist.payload.group_secret;
3228
+ if (typeof gsB64 === 'string' && gsB64.length > 0) {
3229
+ groupSecretBytes = Buffer.from(gsB64, 'base64');
3230
+ break;
3231
+ }
3232
+ }
3233
+ }
3234
+ if (!groupSecretBytes) {
3235
+ // fallback: 从本地 keystore 加载
3236
+ const loaded = this._groupE2ee.loadSecret(groupId, targetEpoch);
3237
+ if (loaded?.secret) {
3238
+ groupSecretBytes = loaded.secret;
3239
+ }
3240
+ else {
3241
+ _clientLog('debug', '无法获取 group_secret 用于 ECIES 加密: group=%s epoch=%d', groupId, targetEpoch);
3242
+ return {};
3243
+ }
3244
+ }
3245
+ const encryptedKeys = {};
3246
+ for (const aid of memberAids) {
3247
+ try {
3248
+ const certPem = await this._fetchPeerCert(aid);
3249
+ const x509Cert = new crypto.X509Certificate(certPem);
3250
+ const pubKey = x509Cert.publicKey;
3251
+ // 导出未压缩 EC 公钥点
3252
+ const jwk = pubKey.export({ format: 'jwk' });
3253
+ if (jwk.crv !== 'P-256' || !jwk.x || !jwk.y)
3254
+ continue;
3255
+ const xBuf = Buffer.from(jwk.x, 'base64url');
3256
+ const yBuf = Buffer.from(jwk.y, 'base64url');
3257
+ const pubkeyBytes = Buffer.concat([Buffer.from([0x04]), xBuf, yBuf]);
3258
+ const ciphertext = eciesEncrypt(pubkeyBytes, groupSecretBytes);
3259
+ encryptedKeys[aid] = ciphertext.toString('base64');
3260
+ }
3261
+ catch (exc) {
3262
+ _clientLog('debug', '为成员 %s 构建 ECIES epoch key 失败: %s', aid, formatCaughtError(exc));
3263
+ continue;
3264
+ }
3265
+ }
3266
+ return encryptedKeys;
3267
+ }
3268
+ catch (exc) {
3269
+ _clientLog('debug', '构建 encrypted_keys 失败: group=%s err=%s', groupId, formatCaughtError(exc));
3270
+ return {};
3271
+ }
3272
+ }
2786
3273
  async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
3274
+ // 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
3275
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
3276
+ if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
3277
+ this._scheduleRetryPendingDecryptMsgs(groupId);
3278
+ return true;
3279
+ }
3280
+ }
2787
3281
  let epochResult = { epoch };
2788
3282
  try {
2789
3283
  const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
@@ -2797,7 +3291,31 @@ export class AUNClient {
2797
3291
  const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
2798
3292
  epochResult.recovery_candidates = [senderAid, ...current];
2799
3293
  }
2800
- await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
3294
+ // 在线优先恢复:先查在线成员列表,只向在线成员发送密钥请求
3295
+ let onlineAids = null;
3296
+ try {
3297
+ const onlineResp = await this.call('group.get_online_members', { group_id: groupId });
3298
+ if (isJsonObject(onlineResp)) {
3299
+ const rawMembers = Array.isArray(onlineResp.members) ? onlineResp.members
3300
+ : Array.isArray(onlineResp.items) ? onlineResp.items : [];
3301
+ onlineAids = rawMembers
3302
+ .filter((m) => isJsonObject(m) && m.online === true && String(m.aid ?? '') !== this._aid)
3303
+ .map(m => String(m.aid ?? ''));
3304
+ }
3305
+ }
3306
+ catch {
3307
+ _clientLog('debug', '群 %s 查询在线成员失败,回退全量候选', groupId);
3308
+ }
3309
+ if (onlineAids !== null) {
3310
+ if (onlineAids.length === 0) {
3311
+ _clientLog('info', '群 %s epoch %s 恢复失败:无在线成员可请求密钥', groupId, String(epoch));
3312
+ return false;
3313
+ }
3314
+ await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
3315
+ }
3316
+ else {
3317
+ await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
3318
+ }
2801
3319
  const deadline = Date.now() + timeoutMs;
2802
3320
  while (Date.now() < deadline) {
2803
3321
  await new Promise(resolve => setTimeout(resolve, 150));
@@ -2813,6 +3331,22 @@ export class AUNClient {
2813
3331
  this._scheduleRetryPendingDecryptMsgs(groupId);
2814
3332
  return ready;
2815
3333
  }
3334
+ /** 只向在线成员发送密钥恢复请求 */
3335
+ async _requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult) {
3336
+ const candidates = this._groupKeyRecoveryCandidates(groupId, epochResult);
3337
+ const ordered = [];
3338
+ for (const aid of candidates) {
3339
+ if (onlineAids.includes(aid) && !ordered.includes(aid))
3340
+ ordered.push(aid);
3341
+ }
3342
+ for (const aid of onlineAids) {
3343
+ if (!ordered.includes(aid))
3344
+ ordered.push(aid);
3345
+ }
3346
+ for (const aid of ordered) {
3347
+ await this._requestGroupKeyFrom(groupId, aid, epoch);
3348
+ }
3349
+ }
2816
3350
  async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
2817
3351
  if (!isJsonObject(secret))
2818
3352
  return false;
@@ -2943,18 +3477,27 @@ export class AUNClient {
2943
3477
  payload: payload ?? {},
2944
3478
  created_at: Number(item.created_at ?? 0),
2945
3479
  };
3480
+ if (isJsonObject(item.context))
3481
+ message.context = item.context;
2946
3482
  const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
3483
+ let decryptFailed = false;
2947
3484
  if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
2948
- this._enqueuePendingDecrypt(groupId, message);
2949
- continue;
3485
+ decryptFailed = true;
3486
+ // 安全网:触发 epoch key 恢复(内部有去重,重复调用安全)
3487
+ const epoch = Number(payload.epoch ?? 0);
3488
+ if (epoch > 0) {
3489
+ this._recoverGroupEpochKey(groupId, epoch, senderAid, 5000).catch(() => { });
3490
+ }
2950
3491
  }
2951
3492
  const thought = {
2952
3493
  thought_id: thoughtId,
2953
3494
  message_id: thoughtId,
2954
- payload: decrypted.payload,
3495
+ payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
2955
3496
  created_at: item.created_at,
2956
3497
  e2ee: decrypted.e2ee,
2957
3498
  };
3499
+ if (decryptFailed)
3500
+ thought.decrypt_failed = true;
2958
3501
  if ('context' in item)
2959
3502
  thought.context = item.context;
2960
3503
  thoughts.push(thought);
@@ -2985,19 +3528,25 @@ export class AUNClient {
2985
3528
  encrypted: item.encrypted !== false,
2986
3529
  timestamp: Number(item.created_at ?? 0),
2987
3530
  };
3531
+ if (isJsonObject(item.context))
3532
+ message.context = item.context;
2988
3533
  let decrypted = message;
3534
+ let decryptFailed = false;
2989
3535
  if (payload?.type === 'e2ee.encrypted') {
2990
3536
  const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2991
3537
  if (fromAid) {
2992
3538
  const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2993
3539
  if (!certReady) {
2994
3540
  _clientLog('warn', '无法获取发送方 %s 的证书,跳过 message.thought.get 解密', fromAid);
2995
- continue;
3541
+ decryptFailed = true;
2996
3542
  }
2997
3543
  }
2998
- decrypted = this._e2ee._decryptMessage(message);
2999
- if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
3000
- continue;
3544
+ if (!decryptFailed) {
3545
+ decrypted = this._e2ee._decryptMessage(message);
3546
+ if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
3547
+ decryptFailed = true;
3548
+ decrypted = message;
3549
+ }
3001
3550
  }
3002
3551
  }
3003
3552
  const thought = {
@@ -3005,10 +3554,12 @@ export class AUNClient {
3005
3554
  message_id: thoughtId,
3006
3555
  from: fromAid,
3007
3556
  to: toAid,
3008
- payload: decrypted.payload,
3557
+ payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
3009
3558
  created_at: item.created_at,
3010
- e2ee: decrypted.e2ee,
3559
+ e2ee: decryptFailed ? undefined : decrypted.e2ee,
3011
3560
  };
3561
+ if (decryptFailed)
3562
+ thought.decrypt_failed = true;
3012
3563
  if ('context' in item)
3013
3564
  thought.context = item.context;
3014
3565
  thoughts.push(thought);
@@ -3128,8 +3679,17 @@ export class AUNClient {
3128
3679
  if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
3129
3680
  if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
3130
3681
  const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3131
- if (committedCommitment && commitment && committedCommitment !== commitment)
3132
- return false;
3682
+ if (committedCommitment && commitment && committedCommitment !== commitment) {
3683
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
3684
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
3685
+ : [];
3686
+ if (this._aid && !expectedMembers.includes(this._aid)) {
3687
+ _clientLog('debug', '放行 group key 分发:新成员恢复 commitment 不匹配属正常 group=%s epoch=%s', groupId, epoch);
3688
+ }
3689
+ else {
3690
+ return false;
3691
+ }
3692
+ }
3133
3693
  }
3134
3694
  return true;
3135
3695
  }
@@ -3198,8 +3758,17 @@ export class AUNClient {
3198
3758
  const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
3199
3759
  if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
3200
3760
  const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3201
- if (committedCommitment && commitment && committedCommitment !== commitment)
3202
- return false;
3761
+ if (committedCommitment && commitment && committedCommitment !== commitment) {
3762
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
3763
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
3764
+ : [];
3765
+ if (this._aid && !expectedMembers.includes(this._aid)) {
3766
+ _clientLog('debug', '放行 group key response:新成员恢复 commitment 不匹配属正常 group=%s epoch=%s', groupId, epoch);
3767
+ }
3768
+ else {
3769
+ return false;
3770
+ }
3771
+ }
3203
3772
  }
3204
3773
  return true;
3205
3774
  }
@@ -3307,7 +3876,15 @@ export class AUNClient {
3307
3876
  await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
3308
3877
  return;
3309
3878
  }
3310
- const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
3879
+ const commitParams2 = { rotation_id: activeRotationId };
3880
+ const createMembers = secretData.member_aids.length > 0 ? secretData.member_aids : (this._aid ? [this._aid] : []);
3881
+ const encKeys2 = await this._buildEpochEncryptedKeys({ distributions: [{ payload: { group_secret: secretData.secret.toString('base64') } }] }, createMembers, 1, groupId);
3882
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
3883
+ if (encKeys2 && Object.keys(encKeys2).length > 0) {
3884
+ commitParams2.encrypted_keys = encKeys2;
3885
+ }
3886
+ }
3887
+ const commitResult = await this.call('group.e2ee.commit_rotation', commitParams2);
3311
3888
  if (isJsonObject(commitResult) && commitResult.success === true) {
3312
3889
  storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
3313
3890
  return;
@@ -3373,8 +3950,23 @@ export class AUNClient {
3373
3950
  }
3374
3951
  const currentEpoch = expectedEpoch ?? serverEpoch;
3375
3952
  const targetEpoch = currentEpoch + 1;
3953
+ // 新成员可能没有 prev epoch key,或有 key 但缺少 epoch_chain(通过 backfill 接收)。
3954
+ // 从 committed_rotation.epoch_chain 获取 prev chain hint。
3955
+ let prevChainHint = null;
3956
+ const localPrev = this._groupE2ee.loadSecret(groupId, currentEpoch);
3957
+ const localPrevChain = String(localPrev?.epoch_chain ?? '');
3958
+ if (!localPrevChain && isJsonObject(epochResult)) {
3959
+ const cr = epochResult.committed_rotation;
3960
+ if (isJsonObject(cr)) {
3961
+ const rawChain = String(cr.epoch_chain ?? '').trim();
3962
+ if (rawChain) {
3963
+ prevChainHint = rawChain;
3964
+ _clientLog('info', '新成员轮换补充 prev epoch chain from server: group=%s epoch=%d', groupId, currentEpoch);
3965
+ }
3966
+ }
3967
+ }
3376
3968
  const rotationId = `rot-${crypto.randomUUID().replace(/-/g, '')}`;
3377
- const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
3969
+ const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
3378
3970
  this._attachRotationId(info, rotationId);
3379
3971
  const discardGeneratedPending = () => {
3380
3972
  try {
@@ -3466,7 +4058,15 @@ export class AUNClient {
3466
4058
  discardGeneratedPending();
3467
4059
  return;
3468
4060
  }
3469
- const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
4061
+ const commitParams = { rotation_id: activeRotationId };
4062
+ // 构建 per-member ECIES 加密的 epoch key 上传到服务端
4063
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
4064
+ const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
4065
+ if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
4066
+ commitParams.encrypted_keys = encryptedKeys;
4067
+ }
4068
+ }
4069
+ const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
3470
4070
  if (!isJsonObject(commitResult) || commitResult.success !== true) {
3471
4071
  _clientLog('warn', 'group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
3472
4072
  this._scheduleGroupRotationRetry(groupId, {
@@ -3534,7 +4134,7 @@ export class AUNClient {
3534
4134
  if (identity && identity.private_key_pem) {
3535
4135
  manifest = signMembershipManifest(manifest, String(identity.private_key_pem));
3536
4136
  }
3537
- const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest);
4137
+ const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest, String(secretData.epoch_chain ?? ''));
3538
4138
  // 重试 3 次,间隔递增(1s, 2s)
3539
4139
  for (let attempt = 0; attempt < 3; attempt++) {
3540
4140
  try {
@@ -3560,7 +4160,79 @@ export class AUNClient {
3560
4160
  this._logE2eeError('distribute_key', groupId, newMemberAid, exc);
3561
4161
  }
3562
4162
  }
3563
- /** 构建 epoch 轮换签名参数。失败时抛错,调用方需在 try/catch 中决定是否跳过轮换。 */
4163
+ /** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
4164
+ _joinedMemberAidsFromPayload(payload) {
4165
+ const aids = new Set();
4166
+ const addAid = (value) => {
4167
+ const aid = String(value ?? '').trim();
4168
+ if (aid)
4169
+ aids.add(aid);
4170
+ };
4171
+ addAid(payload.aid ?? payload.applicant_aid ?? payload.applicantAid);
4172
+ addAid(payload.actor_aid);
4173
+ for (const key of ['member_aid', 'target_aid', 'new_member_aid', 'used_by']) {
4174
+ addAid(payload[key]);
4175
+ }
4176
+ for (const key of ['member', 'request', 'invite_code']) {
4177
+ const nested = isJsonObject(payload[key]) ? payload[key] : null;
4178
+ if (!nested)
4179
+ continue;
4180
+ addAid(nested.aid ?? nested.applicant_aid ?? nested.applicantAid);
4181
+ for (const nk of ['member_aid', 'target_aid', 'used_by'])
4182
+ addAid(nested[nk]);
4183
+ }
4184
+ if (Array.isArray(payload.results)) {
4185
+ for (const item of payload.results) {
4186
+ if (!isJsonObject(item))
4187
+ continue;
4188
+ const obj = item;
4189
+ const status = String(obj.status ?? '').trim().toLowerCase();
4190
+ if (status !== 'approved' && obj.approved !== true)
4191
+ continue;
4192
+ addAid(obj.aid ?? obj.applicant_aid ?? obj.applicantAid);
4193
+ for (const key of ['member_aid', 'target_aid'])
4194
+ addAid(obj[key]);
4195
+ for (const key of ['member', 'request']) {
4196
+ const nested = isJsonObject(obj[key]) ? obj[key] : null;
4197
+ if (!nested)
4198
+ continue;
4199
+ addAid(nested.aid ?? nested.applicant_aid);
4200
+ for (const nk of ['member_aid', 'target_aid'])
4201
+ addAid(nested[nk]);
4202
+ }
4203
+ }
4204
+ }
4205
+ return Array.from(aids);
4206
+ }
4207
+ // ── 入群密钥恢复策略 ──────────────────────────────────────
4208
+ /** 延迟轮换等待时间(毫秒):给新成员恢复 committed_epoch 的窗口 */
4209
+ static _JOIN_ROTATION_DELAY_MS = 3000;
4210
+ // 新成员自身延迟轮换时间:优先让其他在线成员先轮换
4211
+ static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
4212
+ /** open/invite_code 入群后延迟轮换。 */
4213
+ async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
4214
+ await new Promise(resolve => setTimeout(resolve, delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS));
4215
+ await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
4216
+ }
4217
+ /** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
4218
+ async _maybeBackfillKeyToJoinedMember(groupId, payload, triggerId = '') {
4219
+ const memberAids = this._joinedMemberAidsFromPayload(payload)
4220
+ .filter(aid => aid && aid !== this._aid);
4221
+ if (!groupId || !this._aid || memberAids.length === 0)
4222
+ return;
4223
+ if (!this._groupE2ee.hasSecret(groupId))
4224
+ return;
4225
+ for (const memberAid of memberAids) {
4226
+ const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
4227
+ if (this._groupMemberKeyBackfillDone.has(dedupeKey))
4228
+ continue;
4229
+ this._groupMemberKeyBackfillDone.add(dedupeKey);
4230
+ if (this._groupMemberKeyBackfillDone.size > 2000) {
4231
+ this._groupMemberKeyBackfillDone = new Set(Array.from(this._groupMemberKeyBackfillDone).slice(-1000));
4232
+ }
4233
+ await this._distributeKeyToNewMember(groupId, memberAid);
4234
+ }
4235
+ }
3564
4236
  _buildRotationSignature(groupId, currentEpoch, newEpoch = 0, source) {
3565
4237
  const identity = this._identity;
3566
4238
  if (!identity || !identity.private_key_pem) {
@@ -3923,8 +4595,6 @@ export class AUNClient {
3923
4595
  this._startHeartbeatTask();
3924
4596
  this._startTokenRefreshTask();
3925
4597
  this._startGroupEpochTasks();
3926
- // 上线/重连后一次性补齐群消息和群事件
3927
- this._syncAllGroupsOnce().catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
3928
4598
  }
3929
4599
  /** 停止所有后台任务 */
3930
4600
  _stopBackgroundTasks() {
@@ -3961,9 +4631,10 @@ export class AUNClient {
3961
4631
  _startHeartbeatTask() {
3962
4632
  if (this._heartbeatTimer !== null)
3963
4633
  return;
3964
- const interval = Number(this._sessionOptions.heartbeat_interval ?? 30) * 1000;
3965
- if (interval <= 0)
4634
+ const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
4635
+ if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
3966
4636
  return;
4637
+ const interval = Math.max(rawIntervalSeconds, 30) * 1000;
3967
4638
  // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
3968
4639
  // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
3969
4640
  // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
@@ -3994,31 +4665,36 @@ export class AUNClient {
3994
4665
  _startTokenRefreshTask() {
3995
4666
  if (this._tokenRefreshTimer !== null)
3996
4667
  return;
3997
- const lead = Number(this._sessionOptions.token_refresh_before ?? 60);
3998
- const minimumSleep = 1000;
3999
- const scheduleNext = () => {
4668
+ const rawLead = Number(this._sessionOptions.token_refresh_before ?? DEFAULT_SESSION_OPTIONS.token_refresh_before);
4669
+ const lead = Number.isFinite(rawLead) && rawLead > 0
4670
+ ? rawLead
4671
+ : DEFAULT_SESSION_OPTIONS.token_refresh_before;
4672
+ const scheduleNext = (delayMs = TOKEN_REFRESH_CHECK_INTERVAL_MS) => {
4000
4673
  if (this._closing)
4001
4674
  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
4675
  this._tokenRefreshTimer = setTimeout(async () => {
4676
+ if (this._closing)
4677
+ return;
4678
+ this._tokenRefreshTimer = null;
4679
+ if (this._state !== 'connected' || !this._gatewayUrl) {
4680
+ scheduleNext();
4681
+ return;
4682
+ }
4683
+ let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
4684
+ if (identity === null) {
4685
+ scheduleNext();
4686
+ return;
4687
+ }
4688
+ this._identity = identity;
4689
+ const expiresAt = this._auth.getAccessTokenExpiry(identity);
4690
+ if (expiresAt === null) {
4691
+ scheduleNext();
4692
+ return;
4693
+ }
4694
+ if ((expiresAt - Date.now() / 1000) > lead) {
4695
+ scheduleNext();
4696
+ return;
4697
+ }
4022
4698
  if (this._closing || this._state !== 'connected' || !this._gatewayUrl) {
4023
4699
  scheduleNext();
4024
4700
  return;
@@ -4056,10 +4732,10 @@ export class AUNClient {
4056
4732
  }
4057
4733
  }
4058
4734
  scheduleNext();
4059
- }, delay);
4735
+ }, delayMs);
4060
4736
  this._unrefTimer(this._tokenRefreshTimer);
4061
4737
  };
4062
- scheduleNext();
4738
+ scheduleNext(0);
4063
4739
  }
4064
4740
  /** 启动 prekey 刷新任务 */
4065
4741
  _startPrekeyRefreshTask() {
@@ -4368,6 +5044,69 @@ export class AUNClient {
4368
5044
  }
4369
5045
  this._reconnectActive = false;
4370
5046
  }
5047
+ // ── Named Group(命名群)高层 API ────────────────────────────
5048
+ /**
5049
+ * 创建命名群:本地生成 P-256 keypair,调用 group.create 传入 public_key,
5050
+ * 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
5051
+ */
5052
+ async createNamedGroup(groupName, opts = {}) {
5053
+ const cp = new CryptoProvider();
5054
+ const identity = cp.generateIdentity();
5055
+ const params = {};
5056
+ for (const [k, v] of Object.entries(opts)) {
5057
+ params[k] = v;
5058
+ }
5059
+ params.group_name = groupName;
5060
+ params.public_key = identity.public_key_der_b64;
5061
+ params.curve = 'P-256';
5062
+ const result = await this.call('group.create', params);
5063
+ const groupInfo = result?.group;
5064
+ const aidCert = result?.aid_cert;
5065
+ const groupAid = String(groupInfo?.group_aid ?? '');
5066
+ if (groupAid && aidCert) {
5067
+ this._keystore.saveIdentity(groupAid, {
5068
+ private_key_pem: identity.private_key_pem,
5069
+ public_key: identity.public_key_der_b64,
5070
+ curve: 'P-256',
5071
+ type: 'group_identity',
5072
+ });
5073
+ const certPem = String(aidCert.cert ?? '');
5074
+ if (certPem) {
5075
+ this._keystore.saveCert(groupAid, certPem);
5076
+ }
5077
+ }
5078
+ return result;
5079
+ }
5080
+ /**
5081
+ * 为已有普通群绑定命名 AID(升级为命名群)。
5082
+ */
5083
+ async bindGroupAid(groupId, groupName) {
5084
+ const cp = new CryptoProvider();
5085
+ const identity = cp.generateIdentity();
5086
+ const params = {
5087
+ group_id: groupId,
5088
+ group_name: groupName,
5089
+ public_key: identity.public_key_der_b64,
5090
+ curve: 'P-256',
5091
+ };
5092
+ const result = await this.call('group.bind_aid', params);
5093
+ const groupInfo = result?.group;
5094
+ const aidCert = result?.aid_cert;
5095
+ const groupAid = String(groupInfo?.group_aid ?? '');
5096
+ if (groupAid && aidCert) {
5097
+ this._keystore.saveIdentity(groupAid, {
5098
+ private_key_pem: identity.private_key_pem,
5099
+ public_key: identity.public_key_der_b64,
5100
+ curve: 'P-256',
5101
+ type: 'group_identity',
5102
+ });
5103
+ const certPem = String(aidCert.cert ?? '');
5104
+ if (certPem) {
5105
+ this._keystore.saveCert(groupAid, certPem);
5106
+ }
5107
+ }
5108
+ return result;
5109
+ }
4371
5110
  /** 判断是否应重试重连 */
4372
5111
  static _shouldRetryReconnect(error) {
4373
5112
  if (error instanceof AuthError) {