@agentunion/fastaun-browser 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
@@ -15,7 +15,7 @@ import { CustodyNamespace } from './namespaces/custody.js';
15
15
  import { MetaNamespace } from './namespaces/meta.js';
16
16
  import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363ToDer, toBufferSource } from './crypto.js';
17
17
  import { E2EEManager, _certificateSha256Fingerprint as certificateSha256Fingerprint, _ecdsaVerifyDer as ecdsaVerifyDer, _importCertPublicKeyEcdsa as importCertPublicKeyEcdsa, } from './e2ee.js';
18
- import { GroupE2EEManager, computeMembershipCommitment, storeGroupSecret, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, } from './e2ee-group.js';
18
+ import { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, } from './e2ee-group.js';
19
19
  import { IndexedDBKeyStore } from './keystore/indexeddb.js';
20
20
  import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, ValidationError, } from './errors.js';
21
21
  import { isJsonObject, } from './types.js';
@@ -67,11 +67,16 @@ const SIGNED_METHODS = new Set([
67
67
  'group.resources.delete', 'group.resources.request_add',
68
68
  'group.resources.direct_add', 'group.resources.approve_request',
69
69
  'group.resources.reject_request',
70
+ 'group.commit_state',
71
+ 'group.e2ee.begin_rotation', 'group.e2ee.commit_rotation',
72
+ 'group.e2ee.abort_rotation',
73
+ 'group.ban', 'group.unban',
74
+ 'group.dissolve', 'group.suspend', 'group.resume',
70
75
  ]);
71
76
  const DEFAULT_SESSION_OPTIONS = {
72
77
  auto_reconnect: true,
73
78
  heartbeat_interval: 30.0,
74
- token_refresh_before: 60.0,
79
+ token_refresh_before: 1800.0,
75
80
  retry: {
76
81
  initial_delay: 1.0,
77
82
  max_delay: 64.0,
@@ -86,6 +91,7 @@ const DEFAULT_SESSION_OPTIONS = {
86
91
  };
87
92
  const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
88
93
  const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
94
+ const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
89
95
  const GROUP_ROTATION_LEASE_MS = 120_000;
90
96
  const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
91
97
  const PENDING_DECRYPT_LIMIT = 100;
@@ -112,7 +118,8 @@ function reconnectSleepDelaySeconds(baseDelay, maxBaseDelay) {
112
118
  return baseDelay + Math.random() * maxBaseDelay;
113
119
  }
114
120
  /** 对端证书缓存 TTL(秒) */
115
- const PEER_CERT_CACHE_TTL = 600;
121
+ const PEER_CERT_CACHE_TTL = 3600;
122
+ const PEER_PREKEYS_CACHE_TTL = 3600;
116
123
  /**
117
124
  * 将 WebSocket URL 转为对应的 HTTP URL
118
125
  */
@@ -234,6 +241,19 @@ function normalizePeerPrekeys(prekeys) {
234
241
  }
235
242
  return filtered;
236
243
  }
244
+ /** 判断加密失败是否由过期的对端证书或 prekey 引起,可通过刷新缓存重试 */
245
+ function isRetryablePeerMaterialError(error) {
246
+ const localCode = String(error?.localCode ?? error?.code ?? '').trim();
247
+ if (localCode === 'PEER_CERT_FINGERPRINT_MISMATCH'
248
+ || localCode === 'PREKEY_CERT_FINGERPRINT_MISMATCH'
249
+ || localCode === 'PREKEY_SIGNATURE_VERIFY_FAILED') {
250
+ return true;
251
+ }
252
+ const message = error instanceof Error ? error.message : String(error ?? '');
253
+ return message.includes('peer cert fingerprint mismatch for ')
254
+ || message.includes('prekey cert fingerprint mismatch')
255
+ || message.includes('prekey 签名验证失败');
256
+ }
237
257
  function formatCaughtError(error) {
238
258
  return error instanceof Error ? error : String(error);
239
259
  }
@@ -325,8 +345,6 @@ export class AUNClient {
325
345
  // 后台任务 handle(浏览器 setInterval/setTimeout)
326
346
  _heartbeatTimer = null;
327
347
  _tokenRefreshTimer = null;
328
- /** 非连接状态下 token 刷新的退避计数器 */
329
- _tokenDisconnectedRetries = 0;
330
348
  _tokenRefreshFailures = 0;
331
349
  _prekeyRefreshTimer = null;
332
350
  _groupEpochCleanupTimer = null;
@@ -345,6 +363,8 @@ export class AUNClient {
345
363
  _groupEpochRotationInflight = new Set();
346
364
  _groupEpochRecoveryInflight = new Map();
347
365
  _groupMembershipRotationDone = new Set();
366
+ /** 群密钥 backfill 去重:已完成/进行中的 key 集合,防止重复分发 */
367
+ _groupMemberKeyBackfillDone = new Set();
348
368
  _groupEpochRotationRetryTimers = new Map();
349
369
  /** Lazy group sync:首次发送群消息前自动拉取历史 */
350
370
  _groupSynced = new Set();
@@ -413,6 +433,10 @@ export class AUNClient {
413
433
  this._dispatcher.subscribe('_raw.group.changed', (data) => {
414
434
  this._onRawGroupChanged(data);
415
435
  });
436
+ // 群组状态提交事件:验证 state_hash 链并更新本地存储
437
+ this._dispatcher.subscribe('_raw.group.state_committed', (data) => {
438
+ this._safeAsync(this._onGroupStateCommitted(data));
439
+ });
416
440
  // 其他事件直接透传
417
441
  for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
418
442
  this._dispatcher.subscribe(`_raw.${evt}`, (data) => {
@@ -465,13 +489,23 @@ export class AUNClient {
465
489
  if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
466
490
  throw new StateError(`connect not allowed in state ${this._state}`);
467
491
  }
492
+ this._state = 'connecting';
468
493
  const params = { ...auth, ...options };
469
494
  const normalized = this._normalizeConnectParams(params);
470
495
  this._sessionParams = normalized;
471
496
  this._sessionOptions = this._buildSessionOptions(normalized);
472
497
  this._transport.setTimeout(this._sessionOptions.timeouts.call);
473
498
  this._closing = false;
474
- await this._connectOnce(normalized, false);
499
+ try {
500
+ await this._connectOnce(normalized, false);
501
+ }
502
+ catch (err) {
503
+ // 连接失败时回退状态,允许重试
504
+ if (this._state === 'connecting' || this._state === 'authenticating') {
505
+ this._state = 'disconnected';
506
+ }
507
+ throw err;
508
+ }
475
509
  }
476
510
  /** 断开连接但保留本地状态,可再次 connect */
477
511
  async disconnect() {
@@ -741,8 +775,11 @@ export class AUNClient {
741
775
  const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
742
776
  if (groupId && this._membershipRotationChanged(method, result)) {
743
777
  const expectedEpoch = this._membershipRotationExpectedEpoch(result);
778
+ // 自加入方法(request_join/use_invite_code)需要 allowMember=true,
779
+ // 因为新成员角色是 member,必须允许 member 参与 leader 选举。
780
+ const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
744
781
  // P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
745
- const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
782
+ const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
746
783
  const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
747
784
  await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => console.warn('membership RPC epoch rotation fallback failed:', exc));
748
785
  }
@@ -1082,6 +1119,11 @@ export class AUNClient {
1082
1119
  // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
1083
1120
  if (et === 'group.message_created')
1084
1121
  continue;
1122
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
1123
+ const cs = evt.client_signature;
1124
+ if (cs && typeof cs === 'object') {
1125
+ evt._verified = await this._verifyEventSignature(evt, cs);
1126
+ }
1085
1127
  // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
1086
1128
  await this._dispatcher.publish('group.changed', evt);
1087
1129
  }
@@ -1277,51 +1319,6 @@ export class AUNClient {
1277
1319
  await this._drainOrderedMessages(ns);
1278
1320
  return true;
1279
1321
  }
1280
- /**
1281
- * 上线/重连后一次性同步所有已加入群:
1282
- * 1. 有 epoch key 的群 → 补消息 + 补事件
1283
- * 2. 无 epoch key 的群 → 主动向 owner 请求密钥恢复 + 补事件
1284
- */
1285
- async _syncAllGroupsOnce() {
1286
- try {
1287
- const result = await this.call('group.list_my', {});
1288
- if (!isJsonObject(result))
1289
- return;
1290
- const items = result.items;
1291
- if (!Array.isArray(items))
1292
- return;
1293
- for (const g of items) {
1294
- if (isJsonObject(g)) {
1295
- const gid = (g.group_id ?? '');
1296
- if (gid) {
1297
- const hasSecret = await this._groupE2ee.hasSecret(gid);
1298
- if (!hasSecret) {
1299
- // 没有 epoch key → 主动向 owner 请求密钥恢复(与 Python 对齐)
1300
- const ownerAid = (g.owner_aid ?? '');
1301
- if (ownerAid && ownerAid !== this._aid) {
1302
- await this._requestGroupKeyFrom(gid, ownerAid);
1303
- }
1304
- else {
1305
- console.debug(`[aun_core] 群 ${gid} 无 epoch key 且无法确定 owner,等待推送触发恢复`);
1306
- }
1307
- }
1308
- else {
1309
- // 有 epoch key → 补消息
1310
- await this._fillGroupGap(gid);
1311
- }
1312
- // 所有群都补事件(事件不加密)
1313
- await this._fillGroupEventGap(gid);
1314
- }
1315
- }
1316
- }
1317
- }
1318
- catch (exc) {
1319
- console.warn('[aun_core] 上线群组同步失败,群消息可能不完整:', exc);
1320
- this._dispatcher.publish('group.sync_failed', {
1321
- error: exc instanceof Error ? exc.message : String(exc),
1322
- }).catch(() => { });
1323
- }
1324
- }
1325
1322
  /** 主动向指定成员请求群组密钥(用于重连时无 epoch key 的群)(与 Python 对齐) */
1326
1323
  async _requestGroupKeyFrom(groupId, targetAid, epoch = 0) {
1327
1324
  try {
@@ -1497,14 +1494,35 @@ export class AUNClient {
1497
1494
  }
1498
1495
  }
1499
1496
  }
1497
+ // 成员加入:按 action 区分策略
1498
+ // - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
1499
+ // - joined / invite_code_used(开放群/邀请码群):所有在线成员延迟轮换,新成员自己延迟更长
1500
1500
  if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
1501
1501
  if (groupId) {
1502
+ const action = String(d.action ?? '');
1502
1503
  const expectedEpoch = this._membershipRotationExpectedEpoch(d);
1503
- if (expectedEpoch === null) {
1504
- console.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 ?? ''));
1504
+ const joinedAids = this._joinedMemberAidsFromPayload(d);
1505
+ const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
1506
+ if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
1507
+ // open/invite_code 群:所有在线成员都参与延迟轮换
1508
+ // 新成员自己延迟更长,优先让其他在线成员先轮换
1509
+ const triggerId = this._membershipRotationTriggerId(groupId, d);
1510
+ if (!isSelfJoining) {
1511
+ this._safeAsync(this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId));
1512
+ }
1513
+ if (expectedEpoch !== null) {
1514
+ const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
1515
+ this._safeAsync(this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay));
1516
+ }
1505
1517
  }
1506
1518
  else {
1507
- this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
1519
+ if (expectedEpoch === null) {
1520
+ const triggerId = this._membershipRotationTriggerId(groupId, d);
1521
+ this._safeAsync(this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId));
1522
+ }
1523
+ else {
1524
+ this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
1525
+ }
1508
1526
  }
1509
1527
  }
1510
1528
  }
@@ -1520,6 +1538,107 @@ export class AUNClient {
1520
1538
  await this._dispatcher.publish('group.changed', data);
1521
1539
  }
1522
1540
  }
1541
+ /**
1542
+ * 处理 event/group.state_committed:验证 state_hash 链并更新本地存储。
1543
+ * 当 prev_state_hash 与本地不连续时回源 group.get_state,并对回源数据做 hash 验证。
1544
+ */
1545
+ async _onGroupStateCommitted(data) {
1546
+ if (!isJsonObject(data))
1547
+ return;
1548
+ const d = data;
1549
+ const groupId = String(d.group_id ?? '').trim();
1550
+ if (!groupId)
1551
+ return;
1552
+ // 提交者签名验证
1553
+ const cs = d.client_signature;
1554
+ if (cs && isJsonObject(cs)) {
1555
+ const verified = await this._verifyEventSignature(d, cs);
1556
+ if (verified === false) {
1557
+ console.warn('[aun_core] state_committed 提交者签名验证失败 group=%s', groupId);
1558
+ return;
1559
+ }
1560
+ d._verified = verified;
1561
+ }
1562
+ const stateVersion = Number(d.state_version ?? 0);
1563
+ const stateHash = String(d.state_hash ?? '').trim();
1564
+ const prevStateHash = String(d.prev_state_hash ?? '').trim();
1565
+ const keyEpoch = Number(d.key_epoch ?? 0);
1566
+ const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
1567
+ const policySnapshot = String(d.policy_snapshot ?? '').trim();
1568
+ // 1. 验证 prev_state_hash 连续性
1569
+ const loadFn = this._keystore.loadGroupState;
1570
+ const localState = loadFn
1571
+ ? await loadFn.call(this._keystore, groupId)
1572
+ : null;
1573
+ if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
1574
+ console.warn('[aun_core] state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
1575
+ // 回源同步
1576
+ try {
1577
+ const serverState = await this._transport.call('group.get_state', { group_id: groupId });
1578
+ if (serverState && typeof serverState.state_version !== 'undefined') {
1579
+ const sv = Number(serverState.state_version);
1580
+ const sHash = String(serverState.state_hash ?? '');
1581
+ const sEpoch = Number(serverState.key_epoch ?? 0);
1582
+ const sMembersJson = String(serverState.membership_snapshot ?? '');
1583
+ const sPolicyJson = String(serverState.policy_snapshot ?? '');
1584
+ const sPrev = String(serverState.prev_state_hash ?? '');
1585
+ // 回源也做 hash 验证
1586
+ if (sMembersJson && sHash) {
1587
+ const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
1588
+ const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
1589
+ const computed = await computeStateHash({
1590
+ groupId, stateVersion: sv, keyEpoch: sEpoch,
1591
+ members: sMembers, policy: sPolicy, prevStateHash: sPrev,
1592
+ });
1593
+ if (computed !== sHash) {
1594
+ console.warn('[aun_core] 回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
1595
+ return;
1596
+ }
1597
+ }
1598
+ const saveFn = this._keystore.saveGroupState;
1599
+ if (saveFn) {
1600
+ await saveFn.call(this._keystore, groupId, {
1601
+ group_id: groupId,
1602
+ state_version: sv,
1603
+ state_hash: sHash,
1604
+ key_epoch: sEpoch,
1605
+ membership_json: sMembersJson || membershipSnapshot,
1606
+ policy_json: sPolicyJson || policySnapshot,
1607
+ updated_at: Date.now(),
1608
+ });
1609
+ }
1610
+ }
1611
+ }
1612
+ catch (exc) {
1613
+ console.warn('[aun_core] state 回源失败 group=%s:', groupId, exc);
1614
+ }
1615
+ return;
1616
+ }
1617
+ // 2. 本地重算验证
1618
+ const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
1619
+ const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
1620
+ const computed = await computeStateHash({
1621
+ groupId, stateVersion, keyEpoch,
1622
+ members, policy, prevStateHash,
1623
+ });
1624
+ if (computed !== stateHash) {
1625
+ console.warn('[aun_core] state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
1626
+ return;
1627
+ }
1628
+ // 3. 更新本地存储
1629
+ const saveFn = this._keystore.saveGroupState;
1630
+ if (saveFn) {
1631
+ await saveFn.call(this._keystore, groupId, {
1632
+ group_id: groupId,
1633
+ state_version: stateVersion,
1634
+ state_hash: stateHash,
1635
+ key_epoch: keyEpoch,
1636
+ membership_json: membershipSnapshot,
1637
+ policy_json: policySnapshot,
1638
+ updated_at: Date.now(),
1639
+ });
1640
+ }
1641
+ }
1523
1642
  /**
1524
1643
  * 群组解散后清理本地状态:
1525
1644
  * - keystore 中的 epoch key 数据
@@ -1624,50 +1743,60 @@ export class AUNClient {
1624
1743
  if (!this._p2pSynced) {
1625
1744
  await this._lazySyncP2p();
1626
1745
  }
1627
- const recipientPrekeys = await this._fetchPeerPrekeys(toAid);
1628
- const selfSyncCopies = await this._buildSelfSyncCopies({
1629
- logicalToAid: toAid,
1630
- payload,
1631
- messageId,
1632
- timestamp,
1633
- protectedHeaders,
1634
- });
1635
- if (recipientPrekeys.length <= 1 && selfSyncCopies.length === 0) {
1636
- return await this._sendEncryptedSingle({
1637
- toAid,
1638
- payload,
1639
- messageId,
1640
- timestamp,
1641
- prekey: recipientPrekeys[0],
1642
- persistRequired,
1643
- protectedHeaders,
1746
+ // 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
1747
+ const sendAttempt = async (refreshPeerMaterial = false) => {
1748
+ const recipientPrekeys = refreshPeerMaterial
1749
+ ? await this._refreshPeerPrekeys(toAid)
1750
+ : await this._fetchPeerPrekeys(toAid);
1751
+ const selfSyncCopies = await this._buildSelfSyncCopies({
1752
+ logicalToAid: toAid, payload, messageId, timestamp, protectedHeaders,
1644
1753
  });
1645
- }
1646
- const recipientCopies = await this._buildRecipientDeviceCopies({
1647
- toAid,
1648
- payload,
1649
- messageId,
1650
- timestamp,
1651
- prekeys: recipientPrekeys,
1652
- protectedHeaders,
1653
- });
1654
- const sendParams = {
1655
- to: toAid,
1656
- payload: {
1754
+ // 多设备过滤:只保留有有效 device_id 的可路由 prekey,
1755
+ // 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
1756
+ const routablePrekeys = recipientPrekeys.filter(pk => {
1757
+ const did = String(pk.device_id ?? '').trim();
1758
+ return did && did !== PREKEY_FALLBACK_DEVICE_ID;
1759
+ });
1760
+ const canUseMultiDevice = routablePrekeys.length > 0
1761
+ && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
1762
+ if (!canUseMultiDevice) {
1763
+ return await this._sendEncryptedSingle({
1764
+ toAid, payload, messageId, timestamp,
1765
+ prekey: routablePrekeys[0] ?? recipientPrekeys[0],
1766
+ persistRequired, protectedHeaders,
1767
+ });
1768
+ }
1769
+ const recipientCopies = await this._buildRecipientDeviceCopies({
1770
+ toAid, payload, messageId, timestamp,
1771
+ prekeys: routablePrekeys, protectedHeaders,
1772
+ });
1773
+ const sendParams = {
1774
+ to: toAid,
1775
+ payload: {
1776
+ type: 'e2ee.multi_device',
1777
+ logical_message_id: messageId,
1778
+ recipient_copies: recipientCopies,
1779
+ self_copies: selfSyncCopies,
1780
+ },
1657
1781
  type: 'e2ee.multi_device',
1658
- logical_message_id: messageId,
1659
- recipient_copies: recipientCopies,
1660
- self_copies: selfSyncCopies,
1661
- },
1662
- type: 'e2ee.multi_device',
1663
- encrypted: true,
1664
- message_id: messageId,
1665
- timestamp,
1782
+ encrypted: true,
1783
+ message_id: messageId,
1784
+ timestamp,
1785
+ };
1786
+ if (persistRequired)
1787
+ sendParams.persist_required = true;
1788
+ return this._transport.call('message.send', sendParams);
1666
1789
  };
1667
- if (persistRequired) {
1668
- sendParams.persist_required = true;
1790
+ // 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
1791
+ try {
1792
+ return await sendAttempt(false);
1669
1793
  }
1670
- return this._transport.call('message.send', sendParams);
1794
+ catch (exc) {
1795
+ if (!isRetryablePeerMaterialError(exc))
1796
+ throw exc;
1797
+ console.warn(`[aun_core] peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
1798
+ }
1799
+ return await sendAttempt(true);
1671
1800
  }
1672
1801
  /**
1673
1802
  * 首次发送 P2P 消息前懒拉取历史消息,同步 seqTracker 避免空洞。
@@ -1731,6 +1860,8 @@ export class AUNClient {
1731
1860
  const certCache = new Map();
1732
1861
  for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
1733
1862
  const deviceId = String(prekey.device_id ?? '').trim();
1863
+ if (!deviceId)
1864
+ continue; // 跳过无 device_id 的 prekey,防止构造不可路由的多设备副本
1734
1865
  const peerCertFingerprint = String(prekey.cert_fingerprint ?? '').trim().toLowerCase();
1735
1866
  const cacheKey = peerCertFingerprint || '__default__';
1736
1867
  let peerCertPem = certCache.get(cacheKey);
@@ -1796,7 +1927,15 @@ export class AUNClient {
1796
1927
  if (deviceId && deviceId === this._deviceId) {
1797
1928
  continue;
1798
1929
  }
1799
- const peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
1930
+ let peerCertPem;
1931
+ try {
1932
+ peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
1933
+ }
1934
+ catch (e) {
1935
+ // 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
1936
+ console.warn(`self-sync 跳过设备 ${deviceId}: 证书解析失败 (${e}),可能是旧 prekey`);
1937
+ continue;
1938
+ }
1800
1939
  const [envelope, encryptResult] = await this._encryptCopyPayload({
1801
1940
  logicalToAid: opts.logicalToAid,
1802
1941
  payload: opts.payload,
@@ -2164,7 +2303,7 @@ export class AUNClient {
2164
2303
  console.warn(`[aun_core] group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
2165
2304
  return;
2166
2305
  }
2167
- let serverEpoch = Number(epochResult.epoch ?? 0);
2306
+ let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
2168
2307
  if (!Number.isFinite(serverEpoch))
2169
2308
  return;
2170
2309
  const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
@@ -2180,7 +2319,7 @@ export class AUNClient {
2180
2319
  let effectiveLocalEpoch = initialLocalEpoch;
2181
2320
  if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
2182
2321
  epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
2183
- serverEpoch = Number(epochResult.epoch ?? 0);
2322
+ serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
2184
2323
  if (serverEpoch === 0) {
2185
2324
  throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
2186
2325
  }
@@ -2192,7 +2331,9 @@ export class AUNClient {
2192
2331
  while (Date.now() < waitDeadline) {
2193
2332
  await new Promise(resolve => setTimeout(resolve, 150));
2194
2333
  const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
2195
- const refreshedEpoch = isJsonObject(refreshed) ? Number(refreshed.epoch ?? 0) : 0;
2334
+ const refreshedEpoch = isJsonObject(refreshed)
2335
+ ? Number(refreshed.committed_epoch ?? refreshed.epoch ?? 0)
2336
+ : 0;
2196
2337
  const currentLocal = await this._groupE2ee.currentEpoch(groupId);
2197
2338
  if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
2198
2339
  epochResult = refreshed;
@@ -2208,7 +2349,7 @@ export class AUNClient {
2208
2349
  }
2209
2350
  }
2210
2351
  console.warn(`[aun_core] group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
2211
- await this._requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult);
2352
+ await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
2212
2353
  const deadline = Date.now() + 5000;
2213
2354
  while (Date.now() < deadline) {
2214
2355
  await new Promise(resolve => setTimeout(resolve, 150));
@@ -2281,8 +2422,23 @@ export class AUNClient {
2281
2422
  async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
2282
2423
  if (committedEpoch <= 0)
2283
2424
  return committedEpoch;
2284
- const secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
2285
- const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
2425
+ let secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
2426
+ let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
2427
+ if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
2428
+ const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
2429
+ console.warn(`[aun_core] 群 ${groupId} committed epoch ${committedEpoch} 的成员快照与当前成员不一致,触发成员变更轮换修复`);
2430
+ await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
2431
+ const refreshed = await this._committedGroupEpochState(groupId);
2432
+ const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
2433
+ if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
2434
+ committedEpoch = refreshedCommittedEpoch;
2435
+ committedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
2436
+ secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
2437
+ }
2438
+ if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
2439
+ throw new StateError(`group ${groupId} committed membership is stale at epoch ${committedEpoch}; key rotation repair has not completed`);
2440
+ }
2441
+ }
2286
2442
  if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
2287
2443
  return committedEpoch;
2288
2444
  }
@@ -2303,6 +2459,45 @@ export class AUNClient {
2303
2459
  }
2304
2460
  return committedEpoch;
2305
2461
  }
2462
+ async _committedRotationMembershipGap(groupId, committedEpoch, committedRotation) {
2463
+ if (!this._aid || committedEpoch <= 0 || !committedRotation)
2464
+ return false;
2465
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
2466
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
2467
+ : [];
2468
+ if (expectedMembers.length === 0)
2469
+ return false;
2470
+ try {
2471
+ const membersResult = await this.call('group.get_members', { group_id: groupId });
2472
+ const rawMembers = isJsonObject(membersResult)
2473
+ ? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
2474
+ : [];
2475
+ if (!Array.isArray(rawMembers))
2476
+ return false;
2477
+ const activeMembers = rawMembers
2478
+ .filter((item) => isJsonObject(item))
2479
+ .map((item) => ({
2480
+ aid: String(item.aid ?? '').trim(),
2481
+ status: String(item.status ?? 'active').trim().toLowerCase(),
2482
+ }))
2483
+ .filter((item) => item.aid && ['', 'active'].includes(item.status))
2484
+ .map((item) => item.aid)
2485
+ .sort();
2486
+ if (!activeMembers.includes(this._aid) || activeMembers.length === 0)
2487
+ return false;
2488
+ if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
2489
+ const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
2490
+ const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
2491
+ console.info(`[aun_core] 群 ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
2492
+ return true;
2493
+ }
2494
+ return false;
2495
+ }
2496
+ catch (exc) {
2497
+ console.debug(`[aun_core] 查询当前成员失败,无法判断 committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
2498
+ return false;
2499
+ }
2500
+ }
2306
2501
  // ── E2EE 自动解密 ────────────────────────────────
2307
2502
  /** 解密单条 P2P 消息 */
2308
2503
  async _decryptSingleMessage(message) {
@@ -2348,19 +2543,25 @@ export class AUNClient {
2348
2543
  if (payload !== null
2349
2544
  && payload.type === 'e2ee.encrypted'
2350
2545
  && (msg.encrypted === true || !('encrypted' in msg))) {
2351
- const fromAid = (msg.from ?? '');
2352
- const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2353
- if (fromAid) {
2354
- const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2355
- if (!certReady) {
2356
- console.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
2357
- continue;
2546
+ try {
2547
+ const fromAid = (msg.from ?? '');
2548
+ const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2549
+ if (fromAid) {
2550
+ const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2551
+ if (!certReady) {
2552
+ console.warn('[aun_core] 无法获取发送方 %s 的证书,跳过解密', fromAid);
2553
+ continue;
2554
+ }
2555
+ }
2556
+ // Pull 场景:跳过防重放和 timestamp 窗口检查(push 已处理过的消息仍需要能解密)
2557
+ const decrypted = await this._e2ee.decryptMessage(msg, { skipReplay: true });
2558
+ if (decrypted !== null) {
2559
+ result.push(decrypted);
2358
2560
  }
2359
2561
  }
2360
- // Pull 场景:跳过防重放和 timestamp 窗口检查(push 已处理过的消息仍需要能解密)
2361
- const decrypted = await this._e2ee.decryptMessage(msg, { skipReplay: true });
2362
- if (decrypted !== null) {
2363
- result.push(decrypted);
2562
+ catch (decryptExc) {
2563
+ console.warn('[aun_core] pull 消息解密失败,跳过: from=%s mid=%s err=%s', (msg.from ?? ''), mid, decryptExc instanceof Error ? decryptExc.message : String(decryptExc));
2564
+ continue;
2364
2565
  }
2365
2566
  }
2366
2567
  else {
@@ -2431,7 +2632,251 @@ export class AUNClient {
2431
2632
  this._groupEpochRecoveryInflight.set(key, promise);
2432
2633
  return promise;
2433
2634
  }
2635
+ static _extractGroupJoinMode(payload) {
2636
+ if (!isJsonObject(payload))
2637
+ return '';
2638
+ for (const key of ['join_mode', 'mode']) {
2639
+ const v = String(payload[key] ?? '').trim().toLowerCase();
2640
+ if (v)
2641
+ return v;
2642
+ }
2643
+ for (const key of ['join_requirements', 'join']) {
2644
+ const nested = payload[key];
2645
+ if (isJsonObject(nested)) {
2646
+ for (const nk of ['mode', 'join_mode']) {
2647
+ const v = String(nested[nk] ?? '').trim().toLowerCase();
2648
+ if (v)
2649
+ return v;
2650
+ }
2651
+ }
2652
+ }
2653
+ if (isJsonObject(payload.group)) {
2654
+ const v = AUNClient._extractGroupJoinMode(payload.group);
2655
+ if (v)
2656
+ return v;
2657
+ }
2658
+ const settings = payload.settings;
2659
+ if (isJsonObject(settings)) {
2660
+ for (const key of ['join.mode', 'join_mode', 'mode']) {
2661
+ const v = String(settings[key] ?? '').trim().toLowerCase();
2662
+ if (v)
2663
+ return v;
2664
+ }
2665
+ }
2666
+ if (Array.isArray(settings)) {
2667
+ for (const item of settings) {
2668
+ if (!isJsonObject(item))
2669
+ continue;
2670
+ const k = String(item.key ?? item.name ?? '').trim().toLowerCase();
2671
+ if (k === 'join.mode' || k === 'join_mode' || k === 'mode') {
2672
+ const v = String(item.value ?? '').trim().toLowerCase();
2673
+ if (v)
2674
+ return v;
2675
+ }
2676
+ }
2677
+ }
2678
+ return '';
2679
+ }
2680
+ static _joinModeAllowsMemberEpochRotation(mode) {
2681
+ const m = mode.trim().toLowerCase();
2682
+ return m === 'open' || m === 'invite_only' || m === 'invite_code';
2683
+ }
2684
+ async _groupAllowsMemberEpochRotation(groupId) {
2685
+ try {
2686
+ const resp = await this.call('group.get_join_requirements', { group_id: groupId });
2687
+ const mode = AUNClient._extractGroupJoinMode(resp);
2688
+ if (mode)
2689
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
2690
+ }
2691
+ catch { /* best effort */ }
2692
+ try {
2693
+ const resp = await this.call('group.get_settings', { group_id: groupId, keys: ['join.mode'] });
2694
+ const mode = AUNClient._extractGroupJoinMode(resp);
2695
+ if (mode)
2696
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
2697
+ }
2698
+ catch { /* best effort */ }
2699
+ try {
2700
+ const resp = await this.call('group.get', { group_id: groupId });
2701
+ const mode = AUNClient._extractGroupJoinMode(resp);
2702
+ if (mode)
2703
+ return AUNClient._joinModeAllowsMemberEpochRotation(mode);
2704
+ }
2705
+ catch { /* best effort */ }
2706
+ return false;
2707
+ }
2708
+ /** 尝试从服务端拉取 ECIES 加密的 epoch key 并解密存入 keystore */
2709
+ async _tryRecoverEpochKeyFromServer(groupId, epoch) {
2710
+ try {
2711
+ const params = { group_id: groupId };
2712
+ if (epoch > 0)
2713
+ params.epoch = epoch;
2714
+ const result = await this.call('group.e2ee.get_epoch_key', params);
2715
+ if (!isJsonObject(result))
2716
+ return false;
2717
+ const encryptedB64 = result.encrypted_key;
2718
+ if (!encryptedB64 || typeof encryptedB64 !== 'string')
2719
+ return false;
2720
+ const serverEpoch = Number(result.epoch ?? epoch);
2721
+ const encryptedBytes = base64ToUint8(encryptedB64);
2722
+ // 用自己的 AID 私钥 ECIES 解密
2723
+ const myAid = this._aid || '';
2724
+ const keyPair = await this._keystore.loadKeyPair(myAid);
2725
+ if (!keyPair?.private_key_pem)
2726
+ return false;
2727
+ const { eciesDecrypt } = await import('./e2ee-group.js');
2728
+ const groupSecret = await eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
2729
+ if (!groupSecret || groupSecret.length !== 32)
2730
+ return false;
2731
+ // 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
2732
+ let memberAids = [];
2733
+ let committedRotation = null;
2734
+ let epochChain = '';
2735
+ try {
2736
+ const epochInfo = await this.call('group.e2ee.get_epoch', { group_id: groupId });
2737
+ if (isJsonObject(epochInfo)) {
2738
+ if (Array.isArray(epochInfo.members)) {
2739
+ memberAids = epochInfo.members
2740
+ .map((m) => {
2741
+ if (typeof m === 'string')
2742
+ return m;
2743
+ if (isJsonObject(m) && typeof m.aid === 'string')
2744
+ return m.aid;
2745
+ return '';
2746
+ })
2747
+ .filter((s) => s.length > 0);
2748
+ }
2749
+ if (isJsonObject(epochInfo.committed_rotation)) {
2750
+ committedRotation = epochInfo.committed_rotation;
2751
+ const rawChain = String(committedRotation.epoch_chain ?? '').trim();
2752
+ if (rawChain)
2753
+ epochChain = rawChain;
2754
+ if (Array.isArray(committedRotation.expected_members) && committedRotation.expected_members.length > 0) {
2755
+ memberAids = committedRotation.expected_members
2756
+ .map(item => String(item ?? '').trim())
2757
+ .filter(s => s.length > 0);
2758
+ }
2759
+ }
2760
+ }
2761
+ }
2762
+ catch { /* best effort */ }
2763
+ if (memberAids.length === 0)
2764
+ return false;
2765
+ const commitment = await computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
2766
+ let epochChainUnverified = null;
2767
+ let epochChainUnverifiedReason = null;
2768
+ if (committedRotation) {
2769
+ const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
2770
+ const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
2771
+ if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
2772
+ return false;
2773
+ }
2774
+ if (epochChain && committedEpoch === serverEpoch) {
2775
+ let rotatorAid = '';
2776
+ for (const key of ['rotated_by', 'lease_owner', 'committed_by']) {
2777
+ const v = String(committedRotation[key] ?? '').trim();
2778
+ if (v) {
2779
+ rotatorAid = v;
2780
+ break;
2781
+ }
2782
+ }
2783
+ const prevData = await this._groupE2ee.loadSecret(groupId, serverEpoch - 1);
2784
+ const prevChain = String(prevData?.epoch_chain ?? '').trim();
2785
+ if (prevChain && rotatorAid) {
2786
+ if (!await verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
2787
+ return false;
2788
+ }
2789
+ epochChainUnverified = false;
2790
+ }
2791
+ else {
2792
+ epochChainUnverified = true;
2793
+ epochChainUnverifiedReason = prevChain ? 'missing_rotator_aid' : 'missing_prev_chain';
2794
+ }
2795
+ }
2796
+ }
2797
+ await storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
2798
+ return true;
2799
+ }
2800
+ catch {
2801
+ return false;
2802
+ }
2803
+ }
2804
+ /** 为每个成员用其 AID 证书公钥 ECIES 加密 group_secret */
2805
+ async _buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId) {
2806
+ try {
2807
+ const { eciesEncrypt } = await import('./e2ee-group.js');
2808
+ // 从 distribution payload 中提取 group_secret
2809
+ let groupSecretBytes = null;
2810
+ const distributions = Array.isArray(info.distributions) ? info.distributions : [];
2811
+ for (const dist of distributions) {
2812
+ if (isJsonObject(dist) && isJsonObject(dist.payload)) {
2813
+ const gsB64 = dist.payload.group_secret;
2814
+ if (typeof gsB64 === 'string' && gsB64.length > 0) {
2815
+ groupSecretBytes = base64ToUint8(gsB64);
2816
+ break;
2817
+ }
2818
+ }
2819
+ }
2820
+ if (!groupSecretBytes) {
2821
+ const loaded = await this._groupE2ee.loadSecret(groupId, targetEpoch);
2822
+ if (loaded?.secret) {
2823
+ groupSecretBytes = loaded.secret;
2824
+ }
2825
+ else {
2826
+ return {};
2827
+ }
2828
+ }
2829
+ const encryptedKeys = {};
2830
+ for (const aid of memberAids) {
2831
+ try {
2832
+ const certPem = await this._fetchPeerCert(aid);
2833
+ // 从 PEM 证书提取 EC 公钥(未压缩 65 字节)
2834
+ const pubkeyBytes = await this._extractEcPubkeyFromCert(certPem);
2835
+ if (!pubkeyBytes)
2836
+ continue;
2837
+ const ciphertext = await eciesEncrypt(pubkeyBytes, groupSecretBytes);
2838
+ encryptedKeys[aid] = uint8ToBase64(ciphertext);
2839
+ }
2840
+ catch {
2841
+ continue;
2842
+ }
2843
+ }
2844
+ return encryptedKeys;
2845
+ }
2846
+ catch {
2847
+ return {};
2848
+ }
2849
+ }
2850
+ /** 从 PEM 证书中提取 EC 公钥的未压缩字节(65B) */
2851
+ async _extractEcPubkeyFromCert(certPem) {
2852
+ try {
2853
+ // 导入证书公钥为 ECDSA CryptoKey(exportable)
2854
+ const pubKey = await importCertPublicKeyEcdsa(certPem);
2855
+ // 导出 JWK 获取 x, y 坐标
2856
+ const jwk = await crypto.subtle.exportKey('jwk', pubKey);
2857
+ if (jwk.crv !== 'P-256' || !jwk.x || !jwk.y)
2858
+ return null;
2859
+ // base64url → 标准 base64 → Uint8Array
2860
+ const xBytes = base64ToUint8(jwk.x.replace(/-/g, '+').replace(/_/g, '/'));
2861
+ const yBytes = base64ToUint8(jwk.y.replace(/-/g, '+').replace(/_/g, '/'));
2862
+ const result = new Uint8Array(65);
2863
+ result[0] = 0x04;
2864
+ result.set(xBytes, 1);
2865
+ result.set(yBytes, 33);
2866
+ return result;
2867
+ }
2868
+ catch {
2869
+ return null;
2870
+ }
2871
+ }
2434
2872
  async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
2873
+ // 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
2874
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
2875
+ if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
2876
+ this._scheduleRetryPendingDecryptMsgs(groupId);
2877
+ return true;
2878
+ }
2879
+ }
2435
2880
  let epochResult = { epoch };
2436
2881
  try {
2437
2882
  const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
@@ -2445,7 +2890,30 @@ export class AUNClient {
2445
2890
  const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
2446
2891
  epochResult.recovery_candidates = [senderAid, ...current];
2447
2892
  }
2448
- await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
2893
+ // 在线优先恢复:先查在线成员列表,只向在线成员发送密钥请求
2894
+ let onlineAids = null;
2895
+ try {
2896
+ const onlineResp = await this.call('group.get_online_members', { group_id: groupId });
2897
+ if (isJsonObject(onlineResp)) {
2898
+ const rawMembers = Array.isArray(onlineResp.members) ? onlineResp.members
2899
+ : Array.isArray(onlineResp.items) ? onlineResp.items : [];
2900
+ onlineAids = rawMembers
2901
+ .filter((m) => isJsonObject(m) && m.online === true && String(m.aid ?? '') !== this._aid)
2902
+ .map(m => String(m.aid ?? ''));
2903
+ }
2904
+ }
2905
+ catch {
2906
+ // get_online_members 不可用时回退全量候选
2907
+ }
2908
+ if (onlineAids !== null) {
2909
+ if (onlineAids.length === 0) {
2910
+ return false;
2911
+ }
2912
+ await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
2913
+ }
2914
+ else {
2915
+ await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
2916
+ }
2449
2917
  const deadline = Date.now() + timeoutMs;
2450
2918
  while (Date.now() < deadline) {
2451
2919
  await new Promise(resolve => setTimeout(resolve, 150));
@@ -2461,6 +2929,22 @@ export class AUNClient {
2461
2929
  this._scheduleRetryPendingDecryptMsgs(groupId);
2462
2930
  return ready;
2463
2931
  }
2932
+ /** 只向在线成员发送密钥恢复请求 */
2933
+ async _requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult) {
2934
+ const candidates = await this._groupKeyRecoveryCandidates(groupId, epochResult);
2935
+ const ordered = [];
2936
+ for (const aid of candidates) {
2937
+ if (onlineAids.includes(aid) && !ordered.includes(aid))
2938
+ ordered.push(aid);
2939
+ }
2940
+ for (const aid of onlineAids) {
2941
+ if (!ordered.includes(aid))
2942
+ ordered.push(aid);
2943
+ }
2944
+ for (const aid of ordered) {
2945
+ await this._requestGroupKeyFrom(groupId, aid, epoch);
2946
+ }
2947
+ }
2464
2948
  async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
2465
2949
  if (!isJsonObject(secret))
2466
2950
  return false;
@@ -2588,19 +3072,27 @@ export class AUNClient {
2588
3072
  message_id: thoughtId,
2589
3073
  payload: payload ?? {},
2590
3074
  created_at: Number(item.created_at ?? 0),
3075
+ ...(isJsonObject(item.context) ? { context: item.context } : {}),
2591
3076
  };
2592
3077
  const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
3078
+ let decryptFailed = false;
2593
3079
  if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
2594
- this._enqueuePendingDecrypt(groupId, message);
2595
- continue;
3080
+ decryptFailed = true;
3081
+ // 安全网:触发 epoch key 恢复(内部有去重,重复调用安全)
3082
+ const epoch = Number(payload.epoch ?? 0);
3083
+ if (epoch > 0) {
3084
+ this._recoverGroupEpochKey(groupId, epoch, senderAid, 5000).catch(() => { });
3085
+ }
2596
3086
  }
2597
3087
  const thought = {
2598
3088
  thought_id: thoughtId,
2599
3089
  message_id: thoughtId,
2600
- payload: decrypted.payload,
3090
+ payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
2601
3091
  created_at: item.created_at,
2602
3092
  e2ee: decrypted.e2ee,
2603
3093
  };
3094
+ if (decryptFailed)
3095
+ thought.decrypt_failed = true;
2604
3096
  if ('context' in item)
2605
3097
  thought.context = item.context;
2606
3098
  thoughts.push(thought);
@@ -2631,19 +3123,26 @@ export class AUNClient {
2631
3123
  encrypted: item.encrypted !== false,
2632
3124
  timestamp: Number(item.created_at ?? 0),
2633
3125
  };
3126
+ if (isJsonObject(item.context))
3127
+ message.context = item.context;
2634
3128
  let decrypted = message;
3129
+ let decryptFailed = false;
2635
3130
  if (payload?.type === 'e2ee.encrypted') {
2636
3131
  const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2637
3132
  if (fromAid) {
2638
3133
  const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2639
3134
  if (!certReady) {
2640
- console.warn('[aun_core] 无法获取发送方证书,跳过 message.thought.get 解密:', fromAid);
2641
- continue;
3135
+ console.warn('[aun_core] p2p.thought.decrypt failed: 无法获取发送方证书 thought_id=' + thoughtId + ' from=' + fromAid);
3136
+ decryptFailed = true;
2642
3137
  }
2643
3138
  }
2644
- decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
2645
- if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
2646
- continue;
3139
+ if (!decryptFailed) {
3140
+ decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
3141
+ if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
3142
+ console.warn('[aun_core] p2p.thought.decrypt failed thought_id=' + thoughtId);
3143
+ decryptFailed = true;
3144
+ decrypted = message;
3145
+ }
2647
3146
  }
2648
3147
  }
2649
3148
  const thought = {
@@ -2655,6 +3154,8 @@ export class AUNClient {
2655
3154
  created_at: item.created_at,
2656
3155
  e2ee: decrypted.e2ee,
2657
3156
  };
3157
+ if (decryptFailed)
3158
+ thought.decrypt_failed = true;
2658
3159
  if ('context' in item)
2659
3160
  thought.context = item.context;
2660
3161
  thoughts.push(thought);
@@ -2718,6 +3219,11 @@ export class AUNClient {
2718
3219
  result = await this._groupE2ee.handleIncoming(actualPayload);
2719
3220
  if (result === 'distribution') {
2720
3221
  await this._discardGroupDistributionIfStale(actualPayload);
3222
+ // 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
3223
+ const distGroupId = actualPayload.group_id;
3224
+ if (distGroupId && !this._groupSynced.has(distGroupId)) {
3225
+ this._lazySyncGroup(distGroupId).catch(() => { });
3226
+ }
2721
3227
  }
2722
3228
  }
2723
3229
  catch (exc) {
@@ -2737,7 +3243,9 @@ export class AUNClient {
2737
3243
  const groupId = (actualPayload.group_id ?? '');
2738
3244
  const requester = (actualPayload.requester_aid ?? '');
2739
3245
  let members = await this._groupE2ee.getMemberAids(groupId);
2740
- // 请求者不在本地成员列表时,回源查询服务端最新成员列表
3246
+ // 请求者不在本地成员列表时,回源查询服务端最新成员列表(仅用于判断是否响应,不写回本地存储)
3247
+ // P0 历史隔离:不再将回源结果更新到当前 epoch 的 member_aids/commitment,
3248
+ // 避免用当前成员覆盖已固化的历史 epoch 快照。
2741
3249
  if (requester && !members.includes(requester)) {
2742
3250
  try {
2743
3251
  const membersResult = await this.call('group.get_members', { group_id: groupId });
@@ -2745,15 +3253,6 @@ export class AUNClient {
2745
3253
  ? membersResult.members
2746
3254
  : [];
2747
3255
  members = memberList.map(m => String(m.aid ?? ''));
2748
- // 更新本地当前 epoch 的 member_aids/commitment
2749
- if (members.includes(requester)) {
2750
- const secretData = await this._groupE2ee.loadSecret(groupId);
2751
- if (secretData && this._aid) {
2752
- const epoch = secretData.epoch;
2753
- const commitment = await computeMembershipCommitment(members, epoch, groupId, secretData.secret);
2754
- await storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, members);
2755
- }
2756
- }
2757
3256
  }
2758
3257
  catch (exc) {
2759
3258
  console.warn(`群组 ${groupId} 成员列表回源失败:`, exc);
@@ -2906,7 +3405,7 @@ export class AUNClient {
2906
3405
  if (normalized.length > 0) {
2907
3406
  this._peerPrekeysCache.set(peerAid, {
2908
3407
  items: normalized.map((item) => ({ ...item })),
2909
- expireAt: Date.now() / 1000 + 300,
3408
+ expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
2910
3409
  });
2911
3410
  this._e2ee.cachePrekey(peerAid, normalized[0]);
2912
3411
  return normalized;
@@ -2920,7 +3419,7 @@ export class AUNClient {
2920
3419
  if (normalized.length > 0) {
2921
3420
  this._peerPrekeysCache.set(peerAid, {
2922
3421
  items: normalized.map((item) => ({ ...item })),
2923
- expireAt: Date.now() / 1000 + 300,
3422
+ expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
2924
3423
  });
2925
3424
  this._e2ee.cachePrekey(peerAid, normalized[0]);
2926
3425
  return normalized.map((item) => ({ ...item }));
@@ -2931,6 +3430,25 @@ export class AUNClient {
2931
3430
  }
2932
3431
  return [];
2933
3432
  }
3433
+ /** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
3434
+ _invalidatePeerPrekeyCache(peerAid) {
3435
+ this._peerPrekeysCache.delete(peerAid);
3436
+ this._e2ee.invalidatePrekeyCache(peerAid);
3437
+ }
3438
+ /** 清除对端证书缓存(精确匹配 aid 或 aid# 前缀的所有条目) */
3439
+ _clearPeerCertCache(peerAid) {
3440
+ for (const cacheKey of this._certCache.keys()) {
3441
+ if (cacheKey === peerAid || cacheKey.startsWith(`${peerAid}#`)) {
3442
+ this._certCache.delete(cacheKey);
3443
+ }
3444
+ }
3445
+ }
3446
+ /** 清除对端所有缓存后重新拉取 prekey(用于指纹不匹配时的强制刷新) */
3447
+ async _refreshPeerPrekeys(peerAid) {
3448
+ this._invalidatePeerPrekeyCache(peerAid);
3449
+ this._clearPeerCertCache(peerAid);
3450
+ return await this._fetchPeerPrekeys(peerAid);
3451
+ }
2934
3452
  /** 获取对方 prekey(兼容接口,优先返回第一条 device prekey)。 */
2935
3453
  async _fetchPeerPrekey(peerAid) {
2936
3454
  const cachedList = this._peerPrekeysCache.get(peerAid);
@@ -3001,7 +3519,11 @@ export class AUNClient {
3001
3519
  * 零信任要求:不直接信任 keystore 中可能由恶意服务端注入的证书。
3002
3520
  */
3003
3521
  _getVerifiedPeerCert(aid, certFingerprint) {
3004
- const cached = this._certCache.get(certCacheKey(aid, certFingerprint));
3522
+ let cached = this._certCache.get(certCacheKey(aid, certFingerprint));
3523
+ // 带 fingerprint 查不到时,降级用 aid 再查一次
3524
+ if (!cached && certFingerprint) {
3525
+ cached = this._certCache.get(certCacheKey(aid, undefined));
3526
+ }
3005
3527
  const now = Date.now() / 1000;
3006
3528
  if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
3007
3529
  return cached.certPem;
@@ -3173,8 +3695,17 @@ export class AUNClient {
3173
3695
  if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
3174
3696
  if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
3175
3697
  const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3176
- if (committedCommitment && commitment && committedCommitment !== commitment)
3177
- return false;
3698
+ if (committedCommitment && commitment && committedCommitment !== commitment) {
3699
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
3700
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
3701
+ : [];
3702
+ if (this._aid && !expectedMembers.includes(this._aid)) {
3703
+ console.debug(`[aun_core] 放行 group key 分发:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
3704
+ }
3705
+ else {
3706
+ return false;
3707
+ }
3708
+ }
3178
3709
  }
3179
3710
  return true;
3180
3711
  }
@@ -3243,8 +3774,17 @@ export class AUNClient {
3243
3774
  const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
3244
3775
  if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
3245
3776
  const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
3246
- if (committedCommitment && commitment && committedCommitment !== commitment)
3247
- return false;
3777
+ if (committedCommitment && commitment && committedCommitment !== commitment) {
3778
+ const expectedMembers = Array.isArray(committedRotation.expected_members)
3779
+ ? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
3780
+ : [];
3781
+ if (this._aid && !expectedMembers.includes(this._aid)) {
3782
+ console.debug(`[aun_core] 放行 group key response:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
3783
+ }
3784
+ else {
3785
+ return false;
3786
+ }
3787
+ }
3248
3788
  }
3249
3789
  return true;
3250
3790
  }
@@ -3351,7 +3891,15 @@ export class AUNClient {
3351
3891
  await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
3352
3892
  return;
3353
3893
  }
3354
- const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
3894
+ const commitParams2 = { rotation_id: activeRotationId };
3895
+ const createMembers = secretData.member_aids.length > 0 ? secretData.member_aids : (this._aid ? [this._aid] : []);
3896
+ const encKeys2 = await this._buildEpochEncryptedKeys({ distributions: [{ payload: { group_secret: uint8ToBase64(secretData.secret) } }] }, createMembers, 1, groupId);
3897
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
3898
+ if (encKeys2 && Object.keys(encKeys2).length > 0) {
3899
+ commitParams2.encrypted_keys = encKeys2;
3900
+ }
3901
+ }
3902
+ const commitResult = await this.call('group.e2ee.commit_rotation', commitParams2);
3355
3903
  if (isJsonObject(commitResult) && commitResult.success === true) {
3356
3904
  await storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
3357
3905
  return;
@@ -3379,7 +3927,7 @@ export class AUNClient {
3379
3927
  * H21: 基于"排序最小 admin = leader"选举,其他 admin 走 jitter 兜底重试。
3380
3928
  * 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
3381
3929
  */
3382
- async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
3930
+ async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
3383
3931
  const myAid = this._aid;
3384
3932
  if (!myAid || this._closing || this._state !== 'connected')
3385
3933
  return;
@@ -3408,24 +3956,45 @@ export class AUNClient {
3408
3956
  if (!Array.isArray(rawList))
3409
3957
  return;
3410
3958
  const admins = [];
3959
+ const members = [];
3411
3960
  for (const m of rawList) {
3412
3961
  if (!isJsonObject(m))
3413
3962
  continue;
3414
3963
  const role = String(m.role ?? '');
3415
3964
  const aid = String(m.aid ?? '');
3416
- if (aid && (role === 'admin' || role === 'owner'))
3965
+ if (!aid)
3966
+ continue;
3967
+ if (role === 'admin' || role === 'owner') {
3417
3968
  admins.push(aid);
3969
+ }
3970
+ else if (allowMember && role === 'member') {
3971
+ members.push(aid);
3972
+ }
3418
3973
  }
3419
- if (admins.length === 0)
3974
+ // 候选列表:admin/owner 排序在前,member 排序在后
3975
+ let candidates = [...admins.sort(), ...members.sort()];
3976
+ if (candidates.length === 0)
3420
3977
  return;
3421
- admins.sort();
3422
- const leader = admins[0];
3978
+ // 没有当前 epoch key 的成员不参与 leader 选举
3979
+ if (expectedEpoch !== null && expectedEpoch > 0) {
3980
+ const localSecret = await this._groupE2ee.loadSecret(groupId, expectedEpoch);
3981
+ if (!localSecret) {
3982
+ const filtered = candidates.filter(c => c !== myAid);
3983
+ if (filtered.length > 0) {
3984
+ candidates = filtered;
3985
+ }
3986
+ else if (!allowMember) {
3987
+ return;
3988
+ }
3989
+ }
3990
+ }
3991
+ const leader = candidates[0];
3423
3992
  if (leader === myAid) {
3424
3993
  // 我是 leader,直接发起
3425
3994
  await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
3426
3995
  return;
3427
3996
  }
3428
- if (!admins.includes(myAid))
3997
+ if (!candidates.includes(myAid))
3429
3998
  return;
3430
3999
  // 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
3431
4000
  const jitterMs = 2000 + Math.floor(Math.random() * 4000);
@@ -3515,8 +4084,21 @@ export class AUNClient {
3515
4084
  }
3516
4085
  const currentEpoch = expectedEpoch ?? serverEpoch;
3517
4086
  const targetEpoch = currentEpoch + 1;
4087
+ let prevChainHint = null;
4088
+ const localPrev = await this._groupE2ee.loadSecret(groupId, currentEpoch);
4089
+ const localPrevChain = String(localPrev?.epoch_chain ?? '').trim();
4090
+ if (!localPrevChain && isJsonObject(epochResult)) {
4091
+ const cr = epochResult.committed_rotation;
4092
+ if (isJsonObject(cr)) {
4093
+ const rawChain = String(cr.epoch_chain ?? '').trim();
4094
+ if (rawChain) {
4095
+ prevChainHint = rawChain;
4096
+ console.info(`[aun_core] 轮换补充 prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
4097
+ }
4098
+ }
4099
+ }
3518
4100
  const rotationId = `rot-${_uuidV4().replace(/-/g, '')}`;
3519
- const info = await this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
4101
+ const info = await this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
3520
4102
  this._attachRotationId(info, rotationId);
3521
4103
  const discardGeneratedPending = async () => {
3522
4104
  try {
@@ -3609,7 +4191,15 @@ export class AUNClient {
3609
4191
  await discardGeneratedPending();
3610
4192
  return;
3611
4193
  }
3612
- const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
4194
+ const commitParams = { rotation_id: activeRotationId };
4195
+ // 构建 per-member ECIES 加密的 epoch key 上传到服务端
4196
+ if (await this._groupAllowsMemberEpochRotation(groupId)) {
4197
+ const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
4198
+ if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
4199
+ commitParams.encrypted_keys = encryptedKeys;
4200
+ }
4201
+ }
4202
+ const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
3613
4203
  if (!isJsonObject(commitResult) || commitResult.success !== true) {
3614
4204
  console.warn('group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
3615
4205
  this._scheduleGroupRotationRetry(groupId, {
@@ -3649,6 +4239,78 @@ export class AUNClient {
3649
4239
  this._logE2eeError('rotate_epoch', groupId, '', exc);
3650
4240
  }
3651
4241
  }
4242
+ /** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
4243
+ _joinedMemberAidsFromPayload(payload) {
4244
+ const aids = new Set();
4245
+ const addAid = (value) => {
4246
+ const aid = String(value ?? '').trim();
4247
+ if (aid)
4248
+ aids.add(aid);
4249
+ };
4250
+ addAid(payload.aid ?? payload.applicant_aid ?? payload.applicantAid);
4251
+ for (const key of ['member_aid', 'target_aid', 'new_member_aid', 'used_by']) {
4252
+ addAid(payload[key]);
4253
+ }
4254
+ for (const key of ['member', 'request', 'invite_code']) {
4255
+ const nested = isJsonObject(payload[key]) ? payload[key] : null;
4256
+ if (!nested)
4257
+ continue;
4258
+ addAid(nested.aid ?? nested.applicant_aid ?? nested.applicantAid);
4259
+ for (const nk of ['member_aid', 'target_aid', 'used_by'])
4260
+ addAid(nested[nk]);
4261
+ }
4262
+ if (Array.isArray(payload.results)) {
4263
+ for (const item of payload.results) {
4264
+ if (!isJsonObject(item))
4265
+ continue;
4266
+ const obj = item;
4267
+ const status = String(obj.status ?? '').trim().toLowerCase();
4268
+ if (status !== 'approved' && obj.approved !== true)
4269
+ continue;
4270
+ addAid(obj.aid ?? obj.applicant_aid ?? obj.applicantAid);
4271
+ for (const key of ['member_aid', 'target_aid'])
4272
+ addAid(obj[key]);
4273
+ for (const key of ['member', 'request']) {
4274
+ const nested = isJsonObject(obj[key]) ? obj[key] : null;
4275
+ if (!nested)
4276
+ continue;
4277
+ addAid(nested.aid ?? nested.applicant_aid);
4278
+ for (const nk of ['member_aid', 'target_aid'])
4279
+ addAid(nested[nk]);
4280
+ }
4281
+ }
4282
+ }
4283
+ return Array.from(aids);
4284
+ }
4285
+ // ── 入群密钥恢复策略 ──────────────────────────────────────
4286
+ /** 延迟轮换等待时间(毫秒) */
4287
+ static _JOIN_ROTATION_DELAY_MS = 3000;
4288
+ // 新成员自身延迟轮换时间:优先让其他在线成员先轮换
4289
+ static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
4290
+ /** open/invite_code 入群后延迟轮换。 */
4291
+ async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
4292
+ await new Promise(resolve => setTimeout(resolve, delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS));
4293
+ await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
4294
+ }
4295
+ /** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
4296
+ async _maybeBackfillKeyToJoinedMember(groupId, payload, triggerId = '') {
4297
+ const memberAids = this._joinedMemberAidsFromPayload(payload)
4298
+ .filter(aid => aid && aid !== this._aid);
4299
+ if (!groupId || !this._aid || memberAids.length === 0)
4300
+ return;
4301
+ if (!(await this._groupE2ee.hasSecret(groupId)))
4302
+ return;
4303
+ for (const memberAid of memberAids) {
4304
+ const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
4305
+ if (this._groupMemberKeyBackfillDone.has(dedupeKey))
4306
+ continue;
4307
+ this._groupMemberKeyBackfillDone.add(dedupeKey);
4308
+ if (this._groupMemberKeyBackfillDone.size > 2000) {
4309
+ this._groupMemberKeyBackfillDone = new Set(Array.from(this._groupMemberKeyBackfillDone).slice(-1000));
4310
+ }
4311
+ await this._distributeKeyToNewMember(groupId, memberAid);
4312
+ }
4313
+ }
3652
4314
  /**
3653
4315
  * 将当前 group_secret 通过 P2P E2EE 分发给新成员。
3654
4316
  * 先拉服务端最新成员列表,更新本地,构建签名 manifest,再分发。
@@ -3678,7 +4340,7 @@ export class AUNClient {
3678
4340
  if (identity && identity.private_key_pem) {
3679
4341
  manifest = await signMembershipManifest(manifest, identity.private_key_pem);
3680
4342
  }
3681
- const distPayload = await buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid, manifest);
4343
+ const distPayload = await buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid, manifest, String(secretData.epoch_chain ?? ''));
3682
4344
  // 重试 3 次,间隔递增(1s, 2s)
3683
4345
  for (let attempt = 0; attempt < 3; attempt++) {
3684
4346
  try {
@@ -3934,8 +4596,6 @@ export class AUNClient {
3934
4596
  this._startTokenRefresh();
3935
4597
  this._startPrekeyRefresh();
3936
4598
  this._startGroupEpochTasks();
3937
- // 上线/重连后一次性补齐群消息和群事件
3938
- this._safeAsync(this._syncAllGroupsOnce());
3939
4599
  }
3940
4600
  _stopBackgroundTasks() {
3941
4601
  if (this._heartbeatTimer !== null) {
@@ -3971,9 +4631,10 @@ export class AUNClient {
3971
4631
  _startHeartbeat() {
3972
4632
  if (this._heartbeatTimer !== null)
3973
4633
  return;
3974
- const interval = this._sessionOptions.heartbeat_interval * 1000;
3975
- if (interval <= 0)
4634
+ const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
4635
+ if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
3976
4636
  return;
4637
+ const interval = Math.max(rawIntervalSeconds, 30) * 1000;
3977
4638
  // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
3978
4639
  // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个,避免 RPC 长时间挂起。
3979
4640
  // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
@@ -4002,32 +4663,35 @@ export class AUNClient {
4002
4663
  _startTokenRefresh() {
4003
4664
  if (this._tokenRefreshTimer !== null)
4004
4665
  return;
4005
- const scheduleRefresh = () => {
4666
+ const rawLead = Number(this._sessionOptions.token_refresh_before ?? DEFAULT_SESSION_OPTIONS.token_refresh_before);
4667
+ const lead = Number.isFinite(rawLead) && rawLead > 0
4668
+ ? rawLead
4669
+ : DEFAULT_SESSION_OPTIONS.token_refresh_before;
4670
+ const scheduleRefresh = (delayMs = TOKEN_REFRESH_CHECK_INTERVAL_MS) => {
4006
4671
  if (this._closing)
4007
4672
  return;
4008
- const lead = this._sessionOptions.token_refresh_before;
4009
- const minimumDelay = 1000;
4010
- if (this._state !== 'connected' || !this._gatewayUrl) {
4011
- // 非连接状态下使用指数退避,避免 1s 轮询浪费 CPU
4012
- this._tokenDisconnectedRetries++;
4013
- const backoff = Math.min(minimumDelay * Math.pow(2, this._tokenDisconnectedRetries), 60_000);
4014
- this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, backoff);
4015
- return;
4016
- }
4017
- // 连接恢复后重置退避计数器
4018
- this._tokenDisconnectedRetries = 0;
4019
- let identity = this._identity;
4020
- if (!identity) {
4021
- this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, minimumDelay);
4022
- return;
4023
- }
4024
- const expiresAt = this._auth.getAccessTokenExpiry(identity);
4025
- if (expiresAt === null) {
4026
- this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, minimumDelay);
4027
- return;
4028
- }
4029
- const delay = Math.max((expiresAt - lead) * 1000 - Date.now(), minimumDelay);
4030
4673
  this._tokenRefreshTimer = globalThis.setTimeout(async () => {
4674
+ if (this._closing)
4675
+ return;
4676
+ this._tokenRefreshTimer = null;
4677
+ if (this._state !== 'connected' || !this._gatewayUrl) {
4678
+ scheduleRefresh();
4679
+ return;
4680
+ }
4681
+ let identity = this._identity;
4682
+ if (!identity) {
4683
+ scheduleRefresh();
4684
+ return;
4685
+ }
4686
+ const expiresAt = this._auth.getAccessTokenExpiry(identity);
4687
+ if (expiresAt === null) {
4688
+ scheduleRefresh();
4689
+ return;
4690
+ }
4691
+ if ((expiresAt - Date.now() / 1000) > lead) {
4692
+ scheduleRefresh();
4693
+ return;
4694
+ }
4031
4695
  if (this._state !== 'connected' || !this._gatewayUrl || this._closing) {
4032
4696
  scheduleRefresh();
4033
4697
  return;
@@ -4065,9 +4729,9 @@ export class AUNClient {
4065
4729
  }
4066
4730
  }
4067
4731
  scheduleRefresh();
4068
- }, delay);
4732
+ }, delayMs);
4069
4733
  };
4070
- scheduleRefresh();
4734
+ scheduleRefresh(0);
4071
4735
  }
4072
4736
  /** Prekey 轮换定时器:定期检查本地 prekey 数量,不足时自动补充上传 */
4073
4737
  _startPrekeyRefresh() {
@@ -4387,6 +5051,69 @@ export class AUNClient {
4387
5051
  this._reconnectActive = false;
4388
5052
  this._reconnectAbort = null;
4389
5053
  }
5054
+ // ── Named Group(命名群)高层 API ────────────────────────────
5055
+ /**
5056
+ * 创建命名群:本地生成 P-256 keypair,调用 group.create 传入 public_key,
5057
+ * 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
5058
+ */
5059
+ async createNamedGroup(groupName, opts = {}) {
5060
+ const cp = new CryptoProvider();
5061
+ const identity = await cp.generateIdentity();
5062
+ const params = {};
5063
+ for (const [k, v] of Object.entries(opts)) {
5064
+ params[k] = v;
5065
+ }
5066
+ params.group_name = groupName;
5067
+ params.public_key = identity.public_key_der_b64;
5068
+ params.curve = 'P-256';
5069
+ const result = await this.call('group.create', params);
5070
+ const groupInfo = result?.group;
5071
+ const aidCert = result?.aid_cert;
5072
+ const groupAid = String(groupInfo?.group_aid ?? '');
5073
+ if (groupAid && aidCert) {
5074
+ await this._keystore.saveIdentity(groupAid, {
5075
+ private_key_pem: identity.private_key_pem,
5076
+ public_key: identity.public_key_der_b64,
5077
+ curve: 'P-256',
5078
+ type: 'group_identity',
5079
+ });
5080
+ const certPem = String(aidCert.cert ?? '');
5081
+ if (certPem) {
5082
+ await this._keystore.saveCert(groupAid, certPem);
5083
+ }
5084
+ }
5085
+ return result;
5086
+ }
5087
+ /**
5088
+ * 为已有普通群绑定命名 AID(升级为命名群)。
5089
+ */
5090
+ async bindGroupAid(groupId, groupName) {
5091
+ const cp = new CryptoProvider();
5092
+ const identity = await cp.generateIdentity();
5093
+ const params = {
5094
+ group_id: groupId,
5095
+ group_name: groupName,
5096
+ public_key: identity.public_key_der_b64,
5097
+ curve: 'P-256',
5098
+ };
5099
+ const result = await this.call('group.bind_aid', params);
5100
+ const groupInfo = result?.group;
5101
+ const aidCert = result?.aid_cert;
5102
+ const groupAid = String(groupInfo?.group_aid ?? '');
5103
+ if (groupAid && aidCert) {
5104
+ await this._keystore.saveIdentity(groupAid, {
5105
+ private_key_pem: identity.private_key_pem,
5106
+ public_key: identity.public_key_der_b64,
5107
+ curve: 'P-256',
5108
+ type: 'group_identity',
5109
+ });
5110
+ const certPem = String(aidCert.cert ?? '');
5111
+ if (certPem) {
5112
+ await this._keystore.saveCert(groupAid, certPem);
5113
+ }
5114
+ }
5115
+ return result;
5116
+ }
4390
5117
  /** 判断是否应重试重连 */
4391
5118
  _shouldRetryReconnect(error) {
4392
5119
  if (error instanceof AuthError) {