@agentunion/fastaun-browser 0.2.17 → 0.2.19
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/auth.d.ts +12 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +370 -215
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +24 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1307 -849
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/discovery.d.ts +3 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +15 -1
- package/dist/discovery.js.map +1 -1
- package/dist/e2ee-group.d.ts +4 -0
- package/dist/e2ee-group.d.ts.map +1 -1
- package/dist/e2ee-group.js +327 -201
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +4 -0
- package/dist/e2ee.d.ts.map +1 -1
- package/dist/e2ee.js +196 -117
- package/dist/e2ee.js.map +1 -1
- package/dist/events.d.ts +3 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +4 -1
- package/dist/events.js.map +1 -1
- package/dist/keystore/index.d.ts +11 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb.d.ts +38 -0
- package/dist/keystore/indexeddb.d.ts.map +1 -1
- package/dist/keystore/indexeddb.js +245 -98
- package/dist/keystore/indexeddb.js.map +1 -1
- package/dist/logger.d.ts +37 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +112 -0
- package/dist/logger.js.map +1 -0
- package/dist/namespaces/auth.d.ts +13 -3
- package/dist/namespaces/auth.d.ts.map +1 -1
- package/dist/namespaces/auth.js +284 -106
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/namespaces/custody.d.ts +3 -0
- package/dist/namespaces/custody.d.ts.map +1 -1
- package/dist/namespaces/custody.js +147 -75
- package/dist/namespaces/custody.js.map +1 -1
- package/dist/namespaces/meta.d.ts +3 -0
- package/dist/namespaces/meta.d.ts.map +1 -1
- package/dist/namespaces/meta.js +94 -43
- package/dist/namespaces/meta.js.map +1 -1
- package/dist/secret-store/indexeddb-store.d.ts +3 -0
- package/dist/secret-store/indexeddb-store.d.ts.map +1 -1
- package/dist/secret-store/indexeddb-store.js +57 -29
- package/dist/secret-store/indexeddb-store.js.map +1 -1
- package/dist/transport.d.ts +3 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +74 -4
- package/dist/transport.js.map +1 -1
- package/package.json +37 -37
package/dist/client.js
CHANGED
|
@@ -15,9 +15,10 @@ import { AuthNamespace } from './namespaces/auth.js';
|
|
|
15
15
|
import { CustodyNamespace } from './namespaces/custody.js';
|
|
16
16
|
import { MetaNamespace } from './namespaces/meta.js';
|
|
17
17
|
import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363ToDer, toBufferSource } from './crypto.js';
|
|
18
|
-
import { E2EEManager, _certificateSha256Fingerprint as certificateSha256Fingerprint, _ecdsaVerifyDer as ecdsaVerifyDer, _importCertPublicKeyEcdsa as importCertPublicKeyEcdsa, } from './e2ee.js';
|
|
19
|
-
import { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, } from './e2ee-group.js';
|
|
18
|
+
import { E2EEManager, _certificateSha256Fingerprint as certificateSha256Fingerprint, _ecdsaVerifyDer as ecdsaVerifyDer, _importCertPublicKeyEcdsa as importCertPublicKeyEcdsa, setModuleLogger as setE2eeModuleLogger, } from './e2ee.js';
|
|
19
|
+
import { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, setModuleLogger as setE2eeGroupModuleLogger, } from './e2ee-group.js';
|
|
20
20
|
import { IndexedDBKeyStore } from './keystore/indexeddb.js';
|
|
21
|
+
import { AUNLogger } from './logger.js';
|
|
21
22
|
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, ValidationError, } from './errors.js';
|
|
22
23
|
import { isJsonObject, } from './types.js';
|
|
23
24
|
/**
|
|
@@ -342,6 +343,8 @@ export class AUNClient {
|
|
|
342
343
|
_certCache = new Map();
|
|
343
344
|
_prekeyReplenishInflight = new Set();
|
|
344
345
|
_prekeyReplenished = new Set();
|
|
346
|
+
// 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
|
|
347
|
+
_activePrekeyId = '';
|
|
345
348
|
_peerPrekeysCache = new Map();
|
|
346
349
|
// 后台任务 handle(浏览器 setInterval/setTimeout)
|
|
347
350
|
_heartbeatTimer = null;
|
|
@@ -377,14 +380,41 @@ export class AUNClient {
|
|
|
377
380
|
_reconnectActive = false;
|
|
378
381
|
_reconnectAbort = null;
|
|
379
382
|
_serverKicked = false;
|
|
383
|
+
/**
|
|
384
|
+
* 缓存最近一次服务端 gateway.disconnect 信息(含 code/reason/detail),
|
|
385
|
+
* 让后续 connection.state(terminal_failed) 也能携带 detail(如配额超限信息)。
|
|
386
|
+
*/
|
|
387
|
+
_lastDisconnectInfo = null;
|
|
388
|
+
// Logger(per-client 单例 + 各模块子 logger)
|
|
389
|
+
_logger;
|
|
390
|
+
_clientLog;
|
|
391
|
+
_logE2;
|
|
392
|
+
_logEG;
|
|
393
|
+
_logAuth;
|
|
394
|
+
_logTransport;
|
|
395
|
+
_logKeystore;
|
|
396
|
+
_logDiscovery;
|
|
397
|
+
_logEvents;
|
|
380
398
|
constructor(config, _debug = false) {
|
|
381
399
|
const rawConfig = config ?? {};
|
|
382
400
|
this.configModel = createConfig(rawConfig);
|
|
401
|
+
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
383
402
|
this.config = {
|
|
384
403
|
aun_path: this.configModel.aunPath,
|
|
385
404
|
root_ca_path: this.configModel.rootCaPem,
|
|
386
405
|
seed_password: this.configModel.seedPassword,
|
|
387
406
|
};
|
|
407
|
+
// Logger 必须最早初始化(其他子模块构造时通过 logger 输出)
|
|
408
|
+
this._logger = new AUNLogger({ debug: _debug });
|
|
409
|
+
this._clientLog = this._logger.for('aun_core.client');
|
|
410
|
+
this._logE2 = this._logger.for('aun_core.e2ee');
|
|
411
|
+
this._logEG = this._logger.for('aun_core.e2ee-group');
|
|
412
|
+
this._logAuth = this._logger.for('aun_core.auth');
|
|
413
|
+
this._logTransport = this._logger.for('aun_core.transport');
|
|
414
|
+
this._logKeystore = this._logger.for('aun_core.keystore');
|
|
415
|
+
this._logDiscovery = this._logger.for('aun_core.discovery');
|
|
416
|
+
this._logEvents = this._logger.for('aun_core.events');
|
|
417
|
+
this._clientLog.info(`AUNClient initialized: debug=${_debug} aunPath=${this.configModel.aunPath} aid=${initAid ?? '-'}`);
|
|
388
418
|
this._dispatcher = new EventDispatcher();
|
|
389
419
|
this._discovery = new GatewayDiscovery();
|
|
390
420
|
this._keystore = new IndexedDBKeyStore();
|
|
@@ -395,12 +425,13 @@ export class AUNClient {
|
|
|
395
425
|
this._auth = new AuthFlow({
|
|
396
426
|
keystore: this._keystore,
|
|
397
427
|
crypto: new CryptoProvider(),
|
|
398
|
-
aid:
|
|
428
|
+
aid: initAid,
|
|
399
429
|
deviceId: this._deviceId,
|
|
400
430
|
slotId: this._slotId,
|
|
401
431
|
rootCaPem: this.configModel.rootCaPem,
|
|
402
432
|
verifySsl: this.configModel.verifySsl,
|
|
403
433
|
});
|
|
434
|
+
this._aid = initAid;
|
|
404
435
|
this._transport = new RPCTransport({
|
|
405
436
|
eventDispatcher: this._dispatcher,
|
|
406
437
|
timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
|
|
@@ -422,6 +453,29 @@ export class AUNClient {
|
|
|
422
453
|
this.auth = new AuthNamespace(this);
|
|
423
454
|
this.custody = new CustodyNamespace(this);
|
|
424
455
|
this.meta = new MetaNamespace(this);
|
|
456
|
+
// 注入 logger 到各子模块(构造时未传 logger,构造后通过 setLogger 注入)
|
|
457
|
+
this._auth.setLogger(this._logAuth);
|
|
458
|
+
this._transport.setLogger(this._logTransport);
|
|
459
|
+
this._dispatcher.setLogger(this._logEvents);
|
|
460
|
+
this._e2ee.setLogger(this._logE2);
|
|
461
|
+
this._groupE2ee.setLogger(this._logEG);
|
|
462
|
+
setE2eeModuleLogger(this._logE2);
|
|
463
|
+
setE2eeGroupModuleLogger(this._logEG);
|
|
464
|
+
if (typeof this._discovery.setLogger === 'function') {
|
|
465
|
+
this._discovery.setLogger(this._logger.for('aun_core.discovery'));
|
|
466
|
+
}
|
|
467
|
+
if (typeof this.auth.setLogger === 'function') {
|
|
468
|
+
this.auth.setLogger(this._logger.for('aun_core.namespace.auth'));
|
|
469
|
+
}
|
|
470
|
+
if (typeof this.custody.setLogger === 'function') {
|
|
471
|
+
this.custody.setLogger(this._logger.for('aun_core.namespace.custody'));
|
|
472
|
+
}
|
|
473
|
+
if (typeof this._keystore.setLogger === 'function') {
|
|
474
|
+
this._keystore.setLogger(this._logKeystore);
|
|
475
|
+
}
|
|
476
|
+
if (typeof this.meta.setLogger === 'function') {
|
|
477
|
+
this.meta.setLogger(this._logger.for('aun_core.namespace.meta'));
|
|
478
|
+
}
|
|
425
479
|
// 内部订阅:推送消息自动解密后 re-publish 给用户
|
|
426
480
|
this._dispatcher.subscribe('_raw.message.received', (data) => {
|
|
427
481
|
this._onRawMessageReceived(data);
|
|
@@ -445,8 +499,8 @@ export class AUNClient {
|
|
|
445
499
|
});
|
|
446
500
|
}
|
|
447
501
|
// 服务端主动断开通知:记录日志并标记不重连
|
|
448
|
-
this._dispatcher.subscribe('_raw.gateway.disconnect', (data) => {
|
|
449
|
-
this._onGatewayDisconnect(data);
|
|
502
|
+
this._dispatcher.subscribe('_raw.gateway.disconnect', async (data) => {
|
|
503
|
+
await this._onGatewayDisconnect(data);
|
|
450
504
|
});
|
|
451
505
|
}
|
|
452
506
|
// ── 属性 ──────────────────────────────────────────
|
|
@@ -471,7 +525,17 @@ export class AUNClient {
|
|
|
471
525
|
}
|
|
472
526
|
/** 主动检查 gateway 可用性(GET /health) */
|
|
473
527
|
async checkGatewayHealth(gatewayUrl, timeout = 5000) {
|
|
474
|
-
|
|
528
|
+
const tStart = Date.now();
|
|
529
|
+
this._clientLog.debug(`checkGatewayHealth enter: gateway=${gatewayUrl} timeout=${timeout}`);
|
|
530
|
+
try {
|
|
531
|
+
const result = await this._discovery.checkHealth(gatewayUrl, timeout);
|
|
532
|
+
this._clientLog.debug(`checkGatewayHealth exit: elapsed=${Date.now() - tStart}ms healthy=${result}`);
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
this._clientLog.debug(`checkGatewayHealth exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
475
539
|
}
|
|
476
540
|
get e2ee() {
|
|
477
541
|
return this._e2ee;
|
|
@@ -487,7 +551,10 @@ export class AUNClient {
|
|
|
487
551
|
* @param options - 可选的会话选项(auto_reconnect, heartbeat_interval 等)
|
|
488
552
|
*/
|
|
489
553
|
async connect(auth, options) {
|
|
554
|
+
const tStart = Date.now();
|
|
555
|
+
this._clientLog.debug(`connect enter: state=${this._state} aid=${this._aid ?? '-'}`);
|
|
490
556
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
557
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=invalid_state state=${this._state}`);
|
|
491
558
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
492
559
|
}
|
|
493
560
|
this._state = 'connecting';
|
|
@@ -499,18 +566,23 @@ export class AUNClient {
|
|
|
499
566
|
this._closing = false;
|
|
500
567
|
try {
|
|
501
568
|
await this._connectOnce(normalized, false);
|
|
569
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
|
|
502
570
|
}
|
|
503
571
|
catch (err) {
|
|
504
572
|
// 连接失败时回退状态,允许重试
|
|
505
573
|
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
506
574
|
this._state = 'disconnected';
|
|
507
575
|
}
|
|
576
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
508
577
|
throw err;
|
|
509
578
|
}
|
|
510
579
|
}
|
|
511
580
|
/** 断开连接但保留本地状态,可再次 connect */
|
|
512
581
|
async disconnect() {
|
|
582
|
+
const tStart = Date.now();
|
|
583
|
+
this._clientLog.debug(`disconnect enter: state=${this._state}`);
|
|
513
584
|
if (this._state !== 'connected' && this._state !== 'reconnecting') {
|
|
585
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms reason=not_connected`);
|
|
514
586
|
return;
|
|
515
587
|
}
|
|
516
588
|
this._saveSeqTrackerState();
|
|
@@ -523,45 +595,59 @@ export class AUNClient {
|
|
|
523
595
|
await this._transport.close();
|
|
524
596
|
this._state = 'disconnected';
|
|
525
597
|
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
598
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms`);
|
|
526
599
|
}
|
|
527
600
|
/** 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID) */
|
|
528
601
|
async listIdentities() {
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
for (const [key, value] of Object.entries(identity)) {
|
|
551
|
-
if (!['aid', 'private_key_pem', 'public_key_der_b64', 'curve', 'cert'].includes(key)) {
|
|
552
|
-
metadata[key] = value;
|
|
602
|
+
const tStart = Date.now();
|
|
603
|
+
this._clientLog.debug('listIdentities enter');
|
|
604
|
+
try {
|
|
605
|
+
const listFn = this._keystore.listIdentities;
|
|
606
|
+
if (typeof listFn !== 'function') {
|
|
607
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=0 reason=keystore_no_list`);
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
const aids = await listFn.call(this._keystore);
|
|
611
|
+
const summaries = [];
|
|
612
|
+
for (const aid of [...aids].sort()) {
|
|
613
|
+
const identity = await this._keystore.loadIdentity(aid);
|
|
614
|
+
if (!identity || !identity.private_key_pem)
|
|
615
|
+
continue;
|
|
616
|
+
const summary = { aid };
|
|
617
|
+
// 优先从 loadMetadata 获取
|
|
618
|
+
const loadMeta = this._keystore.loadMetadata;
|
|
619
|
+
if (typeof loadMeta === 'function') {
|
|
620
|
+
const md = await loadMeta.call(this._keystore, aid);
|
|
621
|
+
if (md && Object.keys(md).length > 0) {
|
|
622
|
+
summary.metadata = md;
|
|
553
623
|
}
|
|
554
624
|
}
|
|
555
|
-
|
|
556
|
-
|
|
625
|
+
// 回退:从 identity 中提取非核心字段
|
|
626
|
+
if (!summary.metadata) {
|
|
627
|
+
const metadata = {};
|
|
628
|
+
for (const [key, value] of Object.entries(identity)) {
|
|
629
|
+
if (!['aid', 'private_key_pem', 'public_key_der_b64', 'curve', 'cert'].includes(key)) {
|
|
630
|
+
metadata[key] = value;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (Object.keys(metadata).length > 0) {
|
|
634
|
+
summary.metadata = metadata;
|
|
635
|
+
}
|
|
557
636
|
}
|
|
637
|
+
summaries.push(summary);
|
|
558
638
|
}
|
|
559
|
-
|
|
639
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
|
|
640
|
+
return summaries;
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
this._clientLog.debug(`listIdentities exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
644
|
+
throw err;
|
|
560
645
|
}
|
|
561
|
-
return summaries;
|
|
562
646
|
}
|
|
563
647
|
/** 关闭连接 */
|
|
564
648
|
async close() {
|
|
649
|
+
const tStart = Date.now();
|
|
650
|
+
this._clientLog.debug(`close enter: state=${this._state}`);
|
|
565
651
|
this._closing = true;
|
|
566
652
|
this._saveSeqTrackerState();
|
|
567
653
|
this._stopBackgroundTasks();
|
|
@@ -574,6 +660,7 @@ export class AUNClient {
|
|
|
574
660
|
if (this._state === 'idle' || this._state === 'closed') {
|
|
575
661
|
this._state = 'closed';
|
|
576
662
|
this._resetSeqTrackingState();
|
|
663
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms reason=already_idle`);
|
|
577
664
|
return;
|
|
578
665
|
}
|
|
579
666
|
// 关闭前通知服务端主动退出(best-effort,失败不阻塞)
|
|
@@ -587,6 +674,7 @@ export class AUNClient {
|
|
|
587
674
|
this._state = 'closed';
|
|
588
675
|
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
589
676
|
this._resetSeqTrackingState();
|
|
677
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms`);
|
|
590
678
|
}
|
|
591
679
|
// ── RPC ───────────────────────────────────────────
|
|
592
680
|
/**
|
|
@@ -596,6 +684,19 @@ export class AUNClient {
|
|
|
596
684
|
* 自动解密 message.pull/group.pull、Group E2EE 生命周期编排。
|
|
597
685
|
*/
|
|
598
686
|
async call(method, params) {
|
|
687
|
+
const tStart = Date.now();
|
|
688
|
+
this._clientLog.debug(`call enter: method=${method}`);
|
|
689
|
+
try {
|
|
690
|
+
const result = await this._callImpl(method, params);
|
|
691
|
+
this._clientLog.debug(`call exit: elapsed=${Date.now() - tStart}ms method=${method}`);
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
this._clientLog.debug(`call exit (error): elapsed=${Date.now() - tStart}ms method=${method} err=${err instanceof Error ? err.message : String(err)}`);
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
async _callImpl(method, params) {
|
|
599
700
|
if (this._state !== 'connected') {
|
|
600
701
|
throw new ConnectionError('client is not connected');
|
|
601
702
|
}
|
|
@@ -605,10 +706,6 @@ export class AUNClient {
|
|
|
605
706
|
const p = { ...(params ?? {}) };
|
|
606
707
|
this._validateOutboundCall(method, p);
|
|
607
708
|
this._injectMessageCursorContext(method, p);
|
|
608
|
-
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
609
|
-
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null && p.group_id !== '') {
|
|
610
|
-
p.group_id = normalizeGroupId(p.group_id);
|
|
611
|
-
}
|
|
612
709
|
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
613
710
|
if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
|
|
614
711
|
p.device_id = this._deviceId;
|
|
@@ -678,7 +775,7 @@ export class AUNClient {
|
|
|
678
775
|
if (serverAck > 0) {
|
|
679
776
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
680
777
|
if (contig < serverAck) {
|
|
681
|
-
|
|
778
|
+
this._clientLog.info('message.pull retention-floor advance: ns=' + ns + ' contiguous=' + contig + ' -> server_ack_seq=' + serverAck);
|
|
682
779
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
683
780
|
}
|
|
684
781
|
}
|
|
@@ -690,7 +787,7 @@ export class AUNClient {
|
|
|
690
787
|
seq: contig,
|
|
691
788
|
device_id: this._deviceId,
|
|
692
789
|
slot_id: this._slotId,
|
|
693
|
-
}).catch((e) => {
|
|
790
|
+
}).catch((e) => { this._clientLog.warn(`message.pull auto-ack failed:${String(e)}`); });
|
|
694
791
|
}
|
|
695
792
|
}
|
|
696
793
|
}
|
|
@@ -698,7 +795,6 @@ export class AUNClient {
|
|
|
698
795
|
if (method === 'group.pull' && isJsonObject(result)) {
|
|
699
796
|
const r = result;
|
|
700
797
|
const messages = r.messages;
|
|
701
|
-
// 先保存原始消息(解密前),用于喂 SeqTracker(与 P2P message.pull 路径对齐)
|
|
702
798
|
const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
|
|
703
799
|
if (rawMessages.length) {
|
|
704
800
|
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
@@ -706,35 +802,54 @@ export class AUNClient {
|
|
|
706
802
|
const gid = (p.group_id ?? '');
|
|
707
803
|
if (gid) {
|
|
708
804
|
const ns = `group:${gid}`;
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
|
|
805
|
+
// 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
|
|
806
|
+
const decryptedOnly = [];
|
|
807
|
+
let failedCount = 0;
|
|
808
|
+
const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
|
|
809
|
+
for (const m of decryptedMessages) {
|
|
810
|
+
if (!isJsonObject(m))
|
|
811
|
+
continue;
|
|
812
|
+
const payload = isJsonObject(m.payload) ? m.payload : {};
|
|
813
|
+
const ptype = payload.type;
|
|
814
|
+
if (ptype === 'e2ee.group_encrypted') {
|
|
815
|
+
failedCount++;
|
|
816
|
+
this._enqueuePendingDecrypt(gid, m);
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
decryptedOnly.push(m);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (decryptedOnly.length) {
|
|
823
|
+
// 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
|
|
824
|
+
this._seqTracker.onPullResult(ns, decryptedOnly);
|
|
712
825
|
}
|
|
713
826
|
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
714
|
-
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
715
|
-
// 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
|
|
716
827
|
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
717
828
|
if (cursor) {
|
|
718
829
|
const serverAck = Number(cursor.current_seq ?? 0);
|
|
719
830
|
if (serverAck > 0) {
|
|
720
831
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
721
832
|
if (contig < serverAck) {
|
|
722
|
-
|
|
833
|
+
this._clientLog.info('group.pull retention-floor advance: ns=' + ns + ' contiguous=' + contig + ' -> cursor.current_seq=' + serverAck);
|
|
723
834
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
724
835
|
}
|
|
725
836
|
}
|
|
726
837
|
}
|
|
727
838
|
this._saveSeqTrackerState();
|
|
728
|
-
// auto-ack
|
|
839
|
+
// auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
|
|
729
840
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
730
|
-
const shouldAck =
|
|
841
|
+
const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
|
|
731
842
|
if (contig > 0 && shouldAck) {
|
|
732
843
|
this._transport.call('group.ack_messages', {
|
|
733
844
|
group_id: gid,
|
|
734
845
|
msg_seq: contig,
|
|
735
846
|
device_id: this._deviceId,
|
|
736
847
|
slot_id: this._slotId,
|
|
737
|
-
}).catch((e) => {
|
|
848
|
+
}).catch((e) => { this._clientLog.warn('group.pull auto-ack failed: group=' + gid, e); });
|
|
849
|
+
}
|
|
850
|
+
// 有解密失败时调度 recovery 兜底定时
|
|
851
|
+
if (failedCount > 0) {
|
|
852
|
+
this._scheduleRecoveryTimeout(gid);
|
|
738
853
|
}
|
|
739
854
|
}
|
|
740
855
|
}
|
|
@@ -781,7 +896,7 @@ export class AUNClient {
|
|
|
781
896
|
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
782
897
|
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
|
|
783
898
|
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
784
|
-
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) =>
|
|
899
|
+
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => this._clientLog.warn(`membership RPC epoch rotation fallback failed:${String(exc)}`));
|
|
785
900
|
}
|
|
786
901
|
}
|
|
787
902
|
return result;
|
|
@@ -813,7 +928,9 @@ export class AUNClient {
|
|
|
813
928
|
// ── 事件管道:消息解密 ────────────────────────────
|
|
814
929
|
/** 处理 transport 层推送的原始消息:解密后 re-publish 给用户 */
|
|
815
930
|
_onRawMessageReceived(data) {
|
|
931
|
+
this._clientLog.debug(`_onRawMessageReceived enter: from=${data?.from ?? '-'} mid=${data?.message_id ?? '-'} seq=${data?.seq ?? '-'}`);
|
|
816
932
|
this._safeAsync(this._processAndPublishMessage(data));
|
|
933
|
+
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=0ms (dispatched async)`);
|
|
817
934
|
}
|
|
818
935
|
/** 实际处理推送消息的异步任务 */
|
|
819
936
|
async _processAndPublishMessage(data) {
|
|
@@ -828,6 +945,22 @@ export class AUNClient {
|
|
|
828
945
|
}
|
|
829
946
|
// 拦截 P2P 传输的群组密钥分发/请求/响应消息
|
|
830
947
|
if (await this._tryHandleGroupKeyMessage(msg)) {
|
|
948
|
+
// group_key 控制消息也要推进 seq tracker + auto-ack,
|
|
949
|
+
// 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
|
|
950
|
+
const seq = msg.seq;
|
|
951
|
+
if (seq !== undefined && seq !== null && this._aid) {
|
|
952
|
+
const ns = `p2p:${this._aid}`;
|
|
953
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
954
|
+
this._saveSeqTrackerState();
|
|
955
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
956
|
+
if (contig > 0) {
|
|
957
|
+
this._transport.call('message.ack', {
|
|
958
|
+
seq: contig,
|
|
959
|
+
device_id: this._deviceId,
|
|
960
|
+
slot_id: this._slotId,
|
|
961
|
+
}).catch(() => { });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
831
964
|
return;
|
|
832
965
|
}
|
|
833
966
|
// P2P 空洞检测
|
|
@@ -847,7 +980,7 @@ export class AUNClient {
|
|
|
847
980
|
seq: contig,
|
|
848
981
|
device_id: this._deviceId,
|
|
849
982
|
slot_id: this._slotId,
|
|
850
|
-
}).catch((e) => {
|
|
983
|
+
}).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
|
|
851
984
|
}
|
|
852
985
|
// 即时持久化 cursor,异常断连后不回退
|
|
853
986
|
this._saveSeqTrackerState();
|
|
@@ -862,7 +995,7 @@ export class AUNClient {
|
|
|
862
995
|
}
|
|
863
996
|
}
|
|
864
997
|
catch (exc) {
|
|
865
|
-
|
|
998
|
+
this._clientLog.warn(`messagedecryptfailed:${String(exc)}`);
|
|
866
999
|
// H26: 解密失败不再投递原始密文 payload(避免元数据泄漏 + 语义混淆),
|
|
867
1000
|
// 改为发布 message.undecryptable 事件,仅携带安全的 header 信息。
|
|
868
1001
|
if (isJsonObject(data)) {
|
|
@@ -881,7 +1014,9 @@ export class AUNClient {
|
|
|
881
1014
|
}
|
|
882
1015
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
883
1016
|
_onRawGroupMessageCreated(data) {
|
|
1017
|
+
this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${data?.group_id ?? '-'} from=${data?.from ?? '-'} seq=${data?.seq ?? '-'}`);
|
|
884
1018
|
this._safeAsync(this._processAndPublishGroupMessage(data));
|
|
1019
|
+
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=0ms (dispatched async)`);
|
|
885
1020
|
}
|
|
886
1021
|
/**
|
|
887
1022
|
* 处理群组推送消息的异步任务。
|
|
@@ -910,8 +1045,12 @@ export class AUNClient {
|
|
|
910
1045
|
return;
|
|
911
1046
|
}
|
|
912
1047
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
913
|
-
//
|
|
914
|
-
|
|
1048
|
+
// 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
|
|
1049
|
+
// 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
|
|
1050
|
+
// _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
|
|
1051
|
+
const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
|
|
1052
|
+
const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
|
|
1053
|
+
if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
|
|
915
1054
|
const ns = `group:${groupId}`;
|
|
916
1055
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
917
1056
|
if (needPull) {
|
|
@@ -924,15 +1063,17 @@ export class AUNClient {
|
|
|
924
1063
|
msg_seq: contig,
|
|
925
1064
|
device_id: this._deviceId,
|
|
926
1065
|
slot_id: this._slotId,
|
|
927
|
-
}).catch((e) => {
|
|
1066
|
+
}).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
|
|
928
1067
|
}
|
|
929
1068
|
this._saveSeqTrackerState();
|
|
930
1069
|
}
|
|
931
1070
|
// R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (groupId)
|
|
1071
|
+
if (isDecryptFail) {
|
|
1072
|
+
if (groupId) {
|
|
935
1073
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
1074
|
+
// 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
|
|
1075
|
+
this._scheduleRecoveryTimeout(groupId);
|
|
1076
|
+
}
|
|
936
1077
|
await this._publishAppEvent('group.message_undecryptable', {
|
|
937
1078
|
message_id: msg.message_id ?? null,
|
|
938
1079
|
group_id: groupId,
|
|
@@ -952,7 +1093,7 @@ export class AUNClient {
|
|
|
952
1093
|
}
|
|
953
1094
|
}
|
|
954
1095
|
catch (exc) {
|
|
955
|
-
|
|
1096
|
+
this._clientLog.warn(`group message decrypt failed:${String(exc)}`);
|
|
956
1097
|
// H26: 解密失败改发 group.message_undecryptable 事件,不投递原始密文 payload。
|
|
957
1098
|
if (isJsonObject(data)) {
|
|
958
1099
|
const src = data;
|
|
@@ -1009,7 +1150,7 @@ export class AUNClient {
|
|
|
1009
1150
|
}
|
|
1010
1151
|
}
|
|
1011
1152
|
catch (exc) {
|
|
1012
|
-
|
|
1153
|
+
this._clientLog.warn(`auto pull group message failed:${String(exc)}`);
|
|
1013
1154
|
}
|
|
1014
1155
|
// pull 失败时仍透传原始通知
|
|
1015
1156
|
await this._publishAppEvent('group.message_created', notification);
|
|
@@ -1059,7 +1200,7 @@ export class AUNClient {
|
|
|
1059
1200
|
}
|
|
1060
1201
|
}
|
|
1061
1202
|
catch (exc) {
|
|
1062
|
-
|
|
1203
|
+
this._clientLog.warn(`group message gap-fill failed:${String(exc)}`);
|
|
1063
1204
|
}
|
|
1064
1205
|
finally {
|
|
1065
1206
|
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
@@ -1097,7 +1238,7 @@ export class AUNClient {
|
|
|
1097
1238
|
if (serverAck > 0) {
|
|
1098
1239
|
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1099
1240
|
if (contigBefore < serverAck) {
|
|
1100
|
-
|
|
1241
|
+
this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBefore + ' -> cursor.current_seq=' + serverAck);
|
|
1101
1242
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1102
1243
|
}
|
|
1103
1244
|
}
|
|
@@ -1110,7 +1251,7 @@ export class AUNClient {
|
|
|
1110
1251
|
event_seq: contig,
|
|
1111
1252
|
device_id: this._deviceId,
|
|
1112
1253
|
slot_id: this._slotId,
|
|
1113
|
-
}).catch((e) => {
|
|
1254
|
+
}).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
|
|
1114
1255
|
}
|
|
1115
1256
|
for (const evt of events) {
|
|
1116
1257
|
if (isJsonObject(evt)) {
|
|
@@ -1132,7 +1273,7 @@ export class AUNClient {
|
|
|
1132
1273
|
}
|
|
1133
1274
|
}
|
|
1134
1275
|
catch (exc) {
|
|
1135
|
-
|
|
1276
|
+
this._clientLog.warn(`group event gap-fill failed:${String(exc)}`);
|
|
1136
1277
|
}
|
|
1137
1278
|
finally {
|
|
1138
1279
|
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
@@ -1185,7 +1326,7 @@ export class AUNClient {
|
|
|
1185
1326
|
}
|
|
1186
1327
|
}
|
|
1187
1328
|
catch (exc) {
|
|
1188
|
-
|
|
1329
|
+
this._clientLog.warn(`P2P message gap-fill failed:${String(exc)}`);
|
|
1189
1330
|
}
|
|
1190
1331
|
finally {
|
|
1191
1332
|
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
@@ -1330,10 +1471,10 @@ export class AUNClient {
|
|
|
1330
1471
|
encrypt: true,
|
|
1331
1472
|
persist_required: true,
|
|
1332
1473
|
});
|
|
1333
|
-
|
|
1474
|
+
this._clientLog.info(`to ${targetAid} request group ${groupId} key`);
|
|
1334
1475
|
}
|
|
1335
1476
|
catch (exc) {
|
|
1336
|
-
|
|
1477
|
+
this._clientLog.warn(`to ${targetAid} request group ${groupId} key failed: ${String(exc)}`);
|
|
1337
1478
|
}
|
|
1338
1479
|
}
|
|
1339
1480
|
/**
|
|
@@ -1460,82 +1601,93 @@ export class AUNClient {
|
|
|
1460
1601
|
return member ? String(member.group_id ?? '') : '';
|
|
1461
1602
|
}
|
|
1462
1603
|
async _onRawGroupChanged(data) {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
}
|
|
1486
|
-
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
1487
|
-
if (groupId) {
|
|
1488
|
-
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
1489
|
-
if (expectedEpoch === null) {
|
|
1490
|
-
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 ?? ''));
|
|
1491
|
-
}
|
|
1492
|
-
else {
|
|
1493
|
-
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
|
|
1604
|
+
const tStart = Date.now();
|
|
1605
|
+
const action = String(data?.action ?? '');
|
|
1606
|
+
const groupIdInit = String(data?.group_id ?? '');
|
|
1607
|
+
this._clientLog.debug(`_onRawGroupChanged enter: group_id=${groupIdInit} action=${action}`);
|
|
1608
|
+
try {
|
|
1609
|
+
if (isJsonObject(data)) {
|
|
1610
|
+
const d = data;
|
|
1611
|
+
// 验签:有 client_signature 就验,没有默认安全
|
|
1612
|
+
const cs = d.client_signature;
|
|
1613
|
+
if (cs && isJsonObject(cs)) {
|
|
1614
|
+
d._verified = await this._verifyEventSignature(d, cs);
|
|
1615
|
+
}
|
|
1616
|
+
await this._dispatcher.publish('group.changed', d);
|
|
1617
|
+
const groupId = (d.group_id ?? '');
|
|
1618
|
+
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
1619
|
+
// 用 onMessageSeq 返回值决定是否补拉,与 P2P / group.message 路径对齐。
|
|
1620
|
+
let needPull = false;
|
|
1621
|
+
const rawEventSeq = d.event_seq;
|
|
1622
|
+
if (rawEventSeq != null && groupId) {
|
|
1623
|
+
const es = Number(rawEventSeq);
|
|
1624
|
+
if (Number.isFinite(es) && es > 0) {
|
|
1625
|
+
needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
|
|
1494
1626
|
}
|
|
1495
1627
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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));
|
|
1628
|
+
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
1629
|
+
if (needPull && groupId && !d._from_gap_fill) {
|
|
1630
|
+
this._safeAsync(this._fillGroupEventGap(groupId));
|
|
1631
|
+
}
|
|
1632
|
+
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
1633
|
+
if (groupId) {
|
|
1634
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
1635
|
+
if (expectedEpoch === null) {
|
|
1636
|
+
this._clientLog.debug(`membership event without old_epoch skipped for epoch rotation: aid=${this._aid ?? ''} group=${groupId} action=${String(d.action ?? '')} event_seq=${String(d.event_seq ?? '')}`);
|
|
1512
1637
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
this._safeAsync(this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay));
|
|
1638
|
+
else {
|
|
1639
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
|
|
1516
1640
|
}
|
|
1517
1641
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1642
|
+
}
|
|
1643
|
+
// 成员加入:按 action 区分策略
|
|
1644
|
+
// - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
|
|
1645
|
+
// - joined / invite_code_used(开放群/邀请码群):所有在线成员延迟轮换,新成员自己延迟更长
|
|
1646
|
+
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
1647
|
+
if (groupId) {
|
|
1648
|
+
const action = String(d.action ?? '');
|
|
1649
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
1650
|
+
const joinedAids = this._joinedMemberAidsFromPayload(d);
|
|
1651
|
+
const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
|
|
1652
|
+
if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
|
|
1653
|
+
// open/invite_code 群:所有在线成员都参与延迟轮换
|
|
1654
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
1520
1655
|
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
1521
|
-
|
|
1656
|
+
if (!isSelfJoining) {
|
|
1657
|
+
this._safeAsync(this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId));
|
|
1658
|
+
}
|
|
1659
|
+
if (expectedEpoch !== null) {
|
|
1660
|
+
const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
|
|
1661
|
+
this._safeAsync(this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay));
|
|
1662
|
+
}
|
|
1522
1663
|
}
|
|
1523
1664
|
else {
|
|
1524
|
-
|
|
1665
|
+
if (expectedEpoch === null) {
|
|
1666
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
1667
|
+
this._safeAsync(this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId));
|
|
1668
|
+
}
|
|
1669
|
+
else {
|
|
1670
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
|
|
1671
|
+
}
|
|
1525
1672
|
}
|
|
1526
1673
|
}
|
|
1527
1674
|
}
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1675
|
+
// 群组解散 → 清理本地 epoch key、seq_tracker、补洞去重缓存
|
|
1676
|
+
if (d.action === 'dissolved') {
|
|
1677
|
+
if (groupId) {
|
|
1678
|
+
this._cleanupDissolvedGroup(groupId);
|
|
1679
|
+
}
|
|
1533
1680
|
}
|
|
1534
1681
|
}
|
|
1682
|
+
else {
|
|
1683
|
+
// data 非对象也透传给用户(兼容旧版)
|
|
1684
|
+
await this._dispatcher.publish('group.changed', data);
|
|
1685
|
+
}
|
|
1686
|
+
this._clientLog.debug(`_onRawGroupChanged exit: elapsed=${Date.now() - tStart}ms group_id=${groupIdInit}`);
|
|
1535
1687
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1688
|
+
catch (err) {
|
|
1689
|
+
this._clientLog.debug(`_onRawGroupChanged exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1690
|
+
throw err;
|
|
1539
1691
|
}
|
|
1540
1692
|
}
|
|
1541
1693
|
/**
|
|
@@ -1543,18 +1695,35 @@ export class AUNClient {
|
|
|
1543
1695
|
* 当 prev_state_hash 与本地不连续时回源 group.get_state,并对回源数据做 hash 验证。
|
|
1544
1696
|
*/
|
|
1545
1697
|
async _onGroupStateCommitted(data) {
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1698
|
+
const tStart = Date.now();
|
|
1699
|
+
const groupIdInit = String(data?.group_id ?? '');
|
|
1700
|
+
this._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupIdInit} state_version=${String(data?.state_version ?? '-')}`);
|
|
1701
|
+
try {
|
|
1702
|
+
if (!isJsonObject(data)) {
|
|
1703
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=non_object`);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const d = data;
|
|
1707
|
+
const groupId = String(d.group_id ?? '').trim();
|
|
1708
|
+
if (!groupId) {
|
|
1709
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=no_group_id`);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
await this._onGroupStateCommittedImpl(d, groupId);
|
|
1713
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
1714
|
+
}
|
|
1715
|
+
catch (err) {
|
|
1716
|
+
this._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1717
|
+
throw err;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
async _onGroupStateCommittedImpl(d, groupId) {
|
|
1552
1721
|
// 提交者签名验证
|
|
1553
1722
|
const cs = d.client_signature;
|
|
1554
1723
|
if (cs && isJsonObject(cs)) {
|
|
1555
1724
|
const verified = await this._verifyEventSignature(d, cs);
|
|
1556
1725
|
if (verified === false) {
|
|
1557
|
-
|
|
1726
|
+
this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
|
|
1558
1727
|
return;
|
|
1559
1728
|
}
|
|
1560
1729
|
d._verified = verified;
|
|
@@ -1571,7 +1740,7 @@ export class AUNClient {
|
|
|
1571
1740
|
? await loadFn.call(this._keystore, groupId)
|
|
1572
1741
|
: null;
|
|
1573
1742
|
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
1574
|
-
|
|
1743
|
+
this._clientLog.warn('[aun_core] state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
|
|
1575
1744
|
// 回源同步
|
|
1576
1745
|
try {
|
|
1577
1746
|
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
@@ -1591,7 +1760,7 @@ export class AUNClient {
|
|
|
1591
1760
|
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
1592
1761
|
});
|
|
1593
1762
|
if (computed !== sHash) {
|
|
1594
|
-
|
|
1763
|
+
this._clientLog.warn('[aun_core] 回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
|
|
1595
1764
|
return;
|
|
1596
1765
|
}
|
|
1597
1766
|
}
|
|
@@ -1610,7 +1779,7 @@ export class AUNClient {
|
|
|
1610
1779
|
}
|
|
1611
1780
|
}
|
|
1612
1781
|
catch (exc) {
|
|
1613
|
-
|
|
1782
|
+
this._clientLog.warn(`state pull-back failed group=%s:${groupId} ${exc}`);
|
|
1614
1783
|
}
|
|
1615
1784
|
return;
|
|
1616
1785
|
}
|
|
@@ -1622,7 +1791,7 @@ export class AUNClient {
|
|
|
1622
1791
|
members, policy, prevStateHash,
|
|
1623
1792
|
});
|
|
1624
1793
|
if (computed !== stateHash) {
|
|
1625
|
-
|
|
1794
|
+
this._clientLog.warn('[aun_core] state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
|
|
1626
1795
|
return;
|
|
1627
1796
|
}
|
|
1628
1797
|
// 3. 更新本地存储
|
|
@@ -1649,7 +1818,7 @@ export class AUNClient {
|
|
|
1649
1818
|
_cleanupDissolvedGroup(groupId) {
|
|
1650
1819
|
// 1. 清理 GroupE2EEManager / keystore 中的 epoch 密钥
|
|
1651
1820
|
this._safeAsync(this._groupE2ee.removeGroup(groupId).catch((exc) => {
|
|
1652
|
-
|
|
1821
|
+
this._clientLog.warn(`cleanup dissolved group ${groupId} epoch key failed: ${String(exc)}`);
|
|
1653
1822
|
}));
|
|
1654
1823
|
// 2. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
1655
1824
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -1666,7 +1835,7 @@ export class AUNClient {
|
|
|
1666
1835
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
1667
1836
|
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
1668
1837
|
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
1669
|
-
|
|
1838
|
+
this._clientLog.info(`cleanup dissolved group ${groupId} local state`);
|
|
1670
1839
|
}
|
|
1671
1840
|
async _verifyEventSignature(_event, cs) {
|
|
1672
1841
|
const sigAid = String(cs.aid ?? '');
|
|
@@ -1683,7 +1852,7 @@ export class AUNClient {
|
|
|
1683
1852
|
if (expectedFP) {
|
|
1684
1853
|
const actualFP = await certificateSha256Fingerprint(cached.certPem);
|
|
1685
1854
|
if (actualFP !== expectedFP) {
|
|
1686
|
-
|
|
1855
|
+
this._clientLog.warn(`group event sig verify failed: cert fingerprint mismatch aid=%s${String(sigAid)}`);
|
|
1687
1856
|
return false;
|
|
1688
1857
|
}
|
|
1689
1858
|
}
|
|
@@ -1698,7 +1867,7 @@ export class AUNClient {
|
|
|
1698
1867
|
const sigBytes = base64ToUint8(sigB64);
|
|
1699
1868
|
const ok = await ecdsaVerifyDer(pubKey, sigBytes, signData);
|
|
1700
1869
|
if (!ok) {
|
|
1701
|
-
|
|
1870
|
+
this._clientLog.warn(`group event sig verify failed aid=%s method=%s${sigAid} ${method}`);
|
|
1702
1871
|
// P1-16: 签名失败统一发布事件
|
|
1703
1872
|
this._dispatcher.publish('signature.verification_failed', {
|
|
1704
1873
|
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
@@ -1707,7 +1876,7 @@ export class AUNClient {
|
|
|
1707
1876
|
return ok;
|
|
1708
1877
|
}
|
|
1709
1878
|
catch (exc) {
|
|
1710
|
-
|
|
1879
|
+
this._clientLog.warn(`group event sig verify exception:${String(exc)}`);
|
|
1711
1880
|
// P1-16: 签名失败统一发布事件
|
|
1712
1881
|
this._dispatcher.publish('signature.verification_failed', {
|
|
1713
1882
|
aid: sigAid, method, error: String(exc),
|
|
@@ -1729,74 +1898,88 @@ export class AUNClient {
|
|
|
1729
1898
|
}
|
|
1730
1899
|
/** 自动加密并发送 P2P 消息 */
|
|
1731
1900
|
async _sendEncrypted(params) {
|
|
1901
|
+
const tStart = Date.now();
|
|
1732
1902
|
const toAid = String(params.to ?? '');
|
|
1733
|
-
this.
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1903
|
+
this._clientLog.debug(`_sendEncrypted enter: to=${toAid} mid=${String(params.message_id ?? '<auto>')}`);
|
|
1904
|
+
try {
|
|
1905
|
+
this._validateMessageRecipient(toAid);
|
|
1906
|
+
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
1907
|
+
const messageId = String(params.message_id ?? '') || _uuidV4();
|
|
1908
|
+
const timestamp = params.timestamp ?? Date.now();
|
|
1909
|
+
if (payload === null) {
|
|
1910
|
+
throw new ValidationError('message.send payload must be an object when encrypt=true');
|
|
1911
|
+
}
|
|
1912
|
+
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
1913
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
1914
|
+
// Lazy P2P sync:首次发送前自动拉取历史,避免重连后 seq 空洞
|
|
1915
|
+
if (!this._p2pSynced) {
|
|
1916
|
+
await this._lazySyncP2p();
|
|
1917
|
+
}
|
|
1918
|
+
// 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
|
|
1919
|
+
const sendAttempt = async (refreshPeerMaterial = false) => {
|
|
1920
|
+
const recipientPrekeys = refreshPeerMaterial
|
|
1921
|
+
? await this._refreshPeerPrekeys(toAid)
|
|
1922
|
+
: await this._fetchPeerPrekeys(toAid);
|
|
1923
|
+
const selfSyncCopies = await this._buildSelfSyncCopies({
|
|
1924
|
+
logicalToAid: toAid, payload, messageId, timestamp, protectedHeaders,
|
|
1925
|
+
});
|
|
1926
|
+
// 多设备过滤:只保留有有效 device_id 的可路由 prekey,
|
|
1927
|
+
// 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
|
|
1928
|
+
const routablePrekeys = recipientPrekeys.filter(pk => {
|
|
1929
|
+
const did = String(pk.device_id ?? '').trim();
|
|
1930
|
+
return did && did !== PREKEY_FALLBACK_DEVICE_ID;
|
|
1931
|
+
});
|
|
1932
|
+
// 只要有 routable prekey 就走 multi_device 路径(即使只有 1 个 recipient device + 0 self copies)。
|
|
1933
|
+
// 这确保服务端为每个已注册设备存储副本,离线设备重连后能 pull 到。
|
|
1934
|
+
// single 路径仅在完全没有 routable prekey 时使用(legacy 兼容)。
|
|
1935
|
+
const canUseMultiDevice = routablePrekeys.length > 0;
|
|
1936
|
+
if (!canUseMultiDevice) {
|
|
1937
|
+
return await this._sendEncryptedSingle({
|
|
1938
|
+
toAid, payload, messageId, timestamp,
|
|
1939
|
+
prekey: routablePrekeys[0] ?? recipientPrekeys[0],
|
|
1940
|
+
persistRequired, protectedHeaders,
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
1764
1944
|
toAid, payload, messageId, timestamp,
|
|
1765
|
-
|
|
1766
|
-
persistRequired, protectedHeaders,
|
|
1945
|
+
prekeys: routablePrekeys, protectedHeaders,
|
|
1767
1946
|
});
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1947
|
+
const sendParams = {
|
|
1948
|
+
to: toAid,
|
|
1949
|
+
payload: {
|
|
1950
|
+
type: 'e2ee.multi_device',
|
|
1951
|
+
logical_message_id: messageId,
|
|
1952
|
+
recipient_copies: recipientCopies,
|
|
1953
|
+
self_copies: selfSyncCopies,
|
|
1954
|
+
},
|
|
1776
1955
|
type: 'e2ee.multi_device',
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
timestamp,
|
|
1956
|
+
encrypted: true,
|
|
1957
|
+
message_id: messageId,
|
|
1958
|
+
timestamp,
|
|
1959
|
+
};
|
|
1960
|
+
if (persistRequired)
|
|
1961
|
+
sendParams.persist_required = true;
|
|
1962
|
+
return this._transport.call('message.send', sendParams);
|
|
1785
1963
|
};
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1964
|
+
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
1965
|
+
try {
|
|
1966
|
+
const result = await sendAttempt(false);
|
|
1967
|
+
this._clientLog.debug(`_sendEncrypted exit: elapsed=${Date.now() - tStart}ms to=${toAid} retry=false`);
|
|
1968
|
+
return result;
|
|
1969
|
+
}
|
|
1970
|
+
catch (exc) {
|
|
1971
|
+
if (!isRetryablePeerMaterialError(exc))
|
|
1972
|
+
throw exc;
|
|
1973
|
+
this._clientLog.warn(`peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
1974
|
+
}
|
|
1975
|
+
const result = await sendAttempt(true);
|
|
1976
|
+
this._clientLog.debug(`_sendEncrypted exit: elapsed=${Date.now() - tStart}ms to=${toAid} retry=true`);
|
|
1977
|
+
return result;
|
|
1793
1978
|
}
|
|
1794
|
-
catch (
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
console.warn(`[aun_core] peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
1979
|
+
catch (err) {
|
|
1980
|
+
this._clientLog.debug(`_sendEncrypted exit (error): elapsed=${Date.now() - tStart}ms to=${toAid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1981
|
+
throw err;
|
|
1798
1982
|
}
|
|
1799
|
-
return await sendAttempt(true);
|
|
1800
1983
|
}
|
|
1801
1984
|
/**
|
|
1802
1985
|
* 首次发送 P2P 消息前懒拉取历史消息,同步 seqTracker 避免空洞。
|
|
@@ -1822,7 +2005,7 @@ export class AUNClient {
|
|
|
1822
2005
|
}
|
|
1823
2006
|
}
|
|
1824
2007
|
catch (exc) {
|
|
1825
|
-
|
|
2008
|
+
this._clientLog.warn(`lazySyncP2p failed:${String(exc)}`);
|
|
1826
2009
|
}
|
|
1827
2010
|
}
|
|
1828
2011
|
async _sendEncryptedSingle(opts) {
|
|
@@ -1933,7 +2116,7 @@ export class AUNClient {
|
|
|
1933
2116
|
}
|
|
1934
2117
|
catch (e) {
|
|
1935
2118
|
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
1936
|
-
|
|
2119
|
+
this._clientLog.warn(`self-sync skip device ${deviceId}: cert parse failed (${e}), may be old prekey`);
|
|
1937
2120
|
continue;
|
|
1938
2121
|
}
|
|
1939
2122
|
const [envelope, encryptResult] = await this._encryptCopyPayload({
|
|
@@ -1980,7 +2163,7 @@ export class AUNClient {
|
|
|
1980
2163
|
});
|
|
1981
2164
|
}
|
|
1982
2165
|
catch (exc) {
|
|
1983
|
-
|
|
2166
|
+
this._clientLog.warn(`publish e2ee.degraded eventfailed:${String(exc)}`);
|
|
1984
2167
|
}
|
|
1985
2168
|
}
|
|
1986
2169
|
}
|
|
@@ -2067,10 +2250,21 @@ export class AUNClient {
|
|
|
2067
2250
|
}
|
|
2068
2251
|
/** 自动加密并发送群组消息 */
|
|
2069
2252
|
async _sendGroupEncrypted(params) {
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2253
|
+
const tStart = Date.now();
|
|
2254
|
+
const groupId = String(params.group_id ?? '');
|
|
2255
|
+
this._clientLog.debug(`_sendGroupEncrypted enter: group_id=${groupId}`);
|
|
2256
|
+
try {
|
|
2257
|
+
const result = await this._callGroupEncryptedRpc('group.send', params, {
|
|
2258
|
+
idField: 'message_id',
|
|
2259
|
+
idPrefix: 'gm',
|
|
2260
|
+
});
|
|
2261
|
+
this._clientLog.debug(`_sendGroupEncrypted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
2262
|
+
return result;
|
|
2263
|
+
}
|
|
2264
|
+
catch (err) {
|
|
2265
|
+
this._clientLog.debug(`_sendGroupEncrypted exit (error): elapsed=${Date.now() - tStart}ms group_id=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2266
|
+
throw err;
|
|
2267
|
+
}
|
|
2074
2268
|
}
|
|
2075
2269
|
async _putGroupThoughtEncrypted(params) {
|
|
2076
2270
|
return this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
@@ -2126,7 +2320,7 @@ export class AUNClient {
|
|
|
2126
2320
|
}
|
|
2127
2321
|
catch (exc) {
|
|
2128
2322
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
2129
|
-
|
|
2323
|
+
this._clientLog.warn(`group ${prepared.groupId} call ${method} epoch too old, retry encrypt after key recovery : ${formatCaughtError(exc)}`);
|
|
2130
2324
|
prepared = await this._prepareGroupEncryptedRpcParams(method, params, options, true);
|
|
2131
2325
|
continue;
|
|
2132
2326
|
}
|
|
@@ -2217,7 +2411,7 @@ export class AUNClient {
|
|
|
2217
2411
|
}
|
|
2218
2412
|
}
|
|
2219
2413
|
catch (exc) {
|
|
2220
|
-
|
|
2414
|
+
this._clientLog.warn(`lazySyncGroup(${groupId}) failed: ${String(exc)}`);
|
|
2221
2415
|
}
|
|
2222
2416
|
}
|
|
2223
2417
|
_isGroupEpochTooOldError(exc) {
|
|
@@ -2275,7 +2469,7 @@ export class AUNClient {
|
|
|
2275
2469
|
const secretData = await this._groupE2ee.loadSecret(groupId, 1);
|
|
2276
2470
|
if (!secretData || secretData.pending_rotation_id)
|
|
2277
2471
|
return epochResult;
|
|
2278
|
-
|
|
2472
|
+
this._clientLog.warn(`group ${groupId} local epoch=1 but server epoch=0, try sync initial epoch`);
|
|
2279
2473
|
await this._syncEpochToServer(groupId);
|
|
2280
2474
|
try {
|
|
2281
2475
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -2283,7 +2477,7 @@ export class AUNClient {
|
|
|
2283
2477
|
return refreshed;
|
|
2284
2478
|
}
|
|
2285
2479
|
catch (exc) {
|
|
2286
|
-
|
|
2480
|
+
this._clientLog.warn(`group ${groupId} initial epoch sync then refresh server epoch failed: ${formatCaughtError(exc)}`);
|
|
2287
2481
|
}
|
|
2288
2482
|
return epochResult;
|
|
2289
2483
|
}
|
|
@@ -2300,7 +2494,7 @@ export class AUNClient {
|
|
|
2300
2494
|
catch (exc) {
|
|
2301
2495
|
if (strict)
|
|
2302
2496
|
throw new StateError(`group ${groupId} failed to query server epoch before retry: ${formatCaughtError(exc)}`);
|
|
2303
|
-
|
|
2497
|
+
this._clientLog.warn(`group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
|
|
2304
2498
|
return;
|
|
2305
2499
|
}
|
|
2306
2500
|
let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
@@ -2348,7 +2542,7 @@ export class AUNClient {
|
|
|
2348
2542
|
throw new StateError(`group ${groupId} epoch rotation has not completed`);
|
|
2349
2543
|
}
|
|
2350
2544
|
}
|
|
2351
|
-
|
|
2545
|
+
this._clientLog.warn(`group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
|
|
2352
2546
|
await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
|
|
2353
2547
|
const deadline = Date.now() + 5000;
|
|
2354
2548
|
while (Date.now() < deadline) {
|
|
@@ -2372,7 +2566,7 @@ export class AUNClient {
|
|
|
2372
2566
|
members = isJsonObject(membersResult) ? membersResult.members : null;
|
|
2373
2567
|
}
|
|
2374
2568
|
catch (exc) {
|
|
2375
|
-
|
|
2569
|
+
this._clientLog.warn(`group ${groupId} member epoch floor pre-check skip: ${formatCaughtError(exc)}`);
|
|
2376
2570
|
return;
|
|
2377
2571
|
}
|
|
2378
2572
|
let maxMinReadEpoch = 0;
|
|
@@ -2387,7 +2581,7 @@ export class AUNClient {
|
|
|
2387
2581
|
}
|
|
2388
2582
|
if (maxMinReadEpoch <= committedEpoch)
|
|
2389
2583
|
return;
|
|
2390
|
-
|
|
2584
|
+
this._clientLog.warn(`group ${groupId} min_read_epoch above committed epoch, send with committed epoch: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
|
|
2391
2585
|
return;
|
|
2392
2586
|
}
|
|
2393
2587
|
}
|
|
@@ -2398,7 +2592,7 @@ export class AUNClient {
|
|
|
2398
2592
|
return epochResult;
|
|
2399
2593
|
}
|
|
2400
2594
|
catch (exc) {
|
|
2401
|
-
|
|
2595
|
+
this._clientLog.warn(`group ${groupId} query committed epoch state failed, rollback local epoch: ${formatCaughtError(exc)}`);
|
|
2402
2596
|
}
|
|
2403
2597
|
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
2404
2598
|
return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
|
|
@@ -2426,7 +2620,7 @@ export class AUNClient {
|
|
|
2426
2620
|
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
2427
2621
|
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
2428
2622
|
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
2429
|
-
|
|
2623
|
+
this._clientLog.warn(`group ${groupId} committed epoch ${committedEpoch} member snapshot mismatches current members, trigger rotation fix`);
|
|
2430
2624
|
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
2431
2625
|
const refreshed = await this._committedGroupEpochState(groupId);
|
|
2432
2626
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -2443,7 +2637,7 @@ export class AUNClient {
|
|
|
2443
2637
|
return committedEpoch;
|
|
2444
2638
|
}
|
|
2445
2639
|
const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
|
|
2446
|
-
|
|
2640
|
+
this._clientLog.warn(`group ${groupId} epoch ${committedEpoch} local pending key mismatches server committed rotation, recover key first: local_rotation=${pendingRotationId || '-'}`);
|
|
2447
2641
|
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
2448
2642
|
let refreshed = await this._committedGroupEpochState(groupId);
|
|
2449
2643
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -2488,13 +2682,13 @@ export class AUNClient {
|
|
|
2488
2682
|
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
2489
2683
|
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
2490
2684
|
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
2491
|
-
|
|
2685
|
+
this._clientLog.info(`group ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
|
|
2492
2686
|
return true;
|
|
2493
2687
|
}
|
|
2494
2688
|
return false;
|
|
2495
2689
|
}
|
|
2496
2690
|
catch (exc) {
|
|
2497
|
-
|
|
2691
|
+
this._clientLog.debug(`query current members failed, cannot determine committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
2498
2692
|
return false;
|
|
2499
2693
|
}
|
|
2500
2694
|
}
|
|
@@ -2514,7 +2708,7 @@ export class AUNClient {
|
|
|
2514
2708
|
if (fromAid) {
|
|
2515
2709
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2516
2710
|
if (!certReady) {
|
|
2517
|
-
|
|
2711
|
+
this._clientLog.warn(`cannot fetch sender ${fromAid} cert, skip decrypt`);
|
|
2518
2712
|
throw new Error(`发送方证书不可用: from=${fromAid}, mid=${message.message_id}`);
|
|
2519
2713
|
}
|
|
2520
2714
|
}
|
|
@@ -2528,47 +2722,56 @@ export class AUNClient {
|
|
|
2528
2722
|
}
|
|
2529
2723
|
/** 批量解密 P2P 消息(用于 message.pull) */
|
|
2530
2724
|
async _decryptMessages(messages) {
|
|
2531
|
-
const
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
const
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
seenInBatch.
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
const
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2725
|
+
const tStart = Date.now();
|
|
2726
|
+
this._clientLog.debug(`_decryptMessages enter: count=${messages.length}`);
|
|
2727
|
+
try {
|
|
2728
|
+
const seenInBatch = new Set();
|
|
2729
|
+
const result = [];
|
|
2730
|
+
for (const msg of messages) {
|
|
2731
|
+
const mid = (msg.message_id ?? '');
|
|
2732
|
+
if (mid && seenInBatch.has(mid))
|
|
2733
|
+
continue;
|
|
2734
|
+
if (mid)
|
|
2735
|
+
seenInBatch.add(mid);
|
|
2736
|
+
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2737
|
+
if (payload !== null && await this._tryHandleGroupKeyMessage(msg)) {
|
|
2738
|
+
continue;
|
|
2739
|
+
}
|
|
2740
|
+
if (payload !== null
|
|
2741
|
+
&& payload.type === 'e2ee.encrypted'
|
|
2742
|
+
&& (msg.encrypted === true || !('encrypted' in msg))) {
|
|
2743
|
+
try {
|
|
2744
|
+
const fromAid = (msg.from ?? '');
|
|
2745
|
+
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2746
|
+
if (fromAid) {
|
|
2747
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2748
|
+
if (!certReady) {
|
|
2749
|
+
this._clientLog.warn(`cannot fetch sender %s cert, skip decrypt${String(fromAid)}`);
|
|
2750
|
+
continue;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
// Pull 场景:跳过防重放和 timestamp 窗口检查(push 已处理过的消息仍需要能解密)
|
|
2754
|
+
const decrypted = await this._e2ee.decryptMessage(msg, { skipReplay: true });
|
|
2755
|
+
if (decrypted !== null) {
|
|
2756
|
+
result.push(decrypted);
|
|
2554
2757
|
}
|
|
2555
2758
|
}
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
result.push(decrypted);
|
|
2759
|
+
catch (decryptExc) {
|
|
2760
|
+
this._clientLog.warn(`pull messagedecryptfailed, skip: from=${String(msg.from ?? '')} mid=${mid} err=${decryptExc instanceof Error ? decryptExc.message : String(decryptExc)}`);
|
|
2761
|
+
continue;
|
|
2560
2762
|
}
|
|
2561
2763
|
}
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
continue;
|
|
2764
|
+
else {
|
|
2765
|
+
result.push(msg);
|
|
2565
2766
|
}
|
|
2566
2767
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2768
|
+
this._clientLog.debug(`_decryptMessages exit: elapsed=${Date.now() - tStart}ms in=${messages.length} out=${result.length}`);
|
|
2769
|
+
return result;
|
|
2770
|
+
}
|
|
2771
|
+
catch (err) {
|
|
2772
|
+
this._clientLog.debug(`_decryptMessages exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
2773
|
+
throw err;
|
|
2570
2774
|
}
|
|
2571
|
-
return result;
|
|
2572
2775
|
}
|
|
2573
2776
|
/** 解密单条群组消息。opts.skipReplay 用于 pull 场景跳过防重放。 */
|
|
2574
2777
|
_enqueuePendingDecrypt(groupId, msg) {
|
|
@@ -2577,23 +2780,47 @@ export class AUNClient {
|
|
|
2577
2780
|
queue.push(msg);
|
|
2578
2781
|
this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
|
|
2579
2782
|
}
|
|
2580
|
-
async _retryPendingDecryptMsgs(groupId) {
|
|
2783
|
+
async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
|
|
2581
2784
|
const ns = `group:${groupId}`;
|
|
2582
2785
|
const queue = this._pendingDecryptMsgs.get(ns);
|
|
2583
2786
|
if (!queue || queue.length === 0)
|
|
2584
2787
|
return;
|
|
2585
2788
|
this._pendingDecryptMsgs.set(ns, []);
|
|
2586
2789
|
const stillPending = [];
|
|
2790
|
+
let forceAdvancedAny = false;
|
|
2587
2791
|
for (const msg of queue) {
|
|
2588
2792
|
try {
|
|
2589
2793
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
2590
2794
|
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2591
2795
|
if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
2592
|
-
|
|
2796
|
+
if (forceAdvanceOnFail) {
|
|
2797
|
+
// recovery 真的失败:强制推进 + 发 undecryptable
|
|
2798
|
+
this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
|
|
2799
|
+
const seq = msg.seq;
|
|
2800
|
+
if (seq !== undefined && seq !== null) {
|
|
2801
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2802
|
+
this._saveSeqTrackerState();
|
|
2803
|
+
forceAdvancedAny = true;
|
|
2804
|
+
}
|
|
2805
|
+
await this._publishAppEvent('group.message_undecryptable', {
|
|
2806
|
+
message_id: msg.message_id,
|
|
2807
|
+
group_id: groupId,
|
|
2808
|
+
from: msg.from,
|
|
2809
|
+
seq,
|
|
2810
|
+
timestamp: msg.timestamp,
|
|
2811
|
+
_decrypt_error: 'epoch recovery failed',
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
else {
|
|
2815
|
+
stillPending.push(msg);
|
|
2816
|
+
}
|
|
2593
2817
|
continue;
|
|
2594
2818
|
}
|
|
2595
2819
|
const seq = msg.seq;
|
|
2596
2820
|
if (seq !== undefined && seq !== null) {
|
|
2821
|
+
// 推进 seq tracker(之前 push/pull 失败时没推进)
|
|
2822
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2823
|
+
this._saveSeqTrackerState();
|
|
2597
2824
|
await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
|
|
2598
2825
|
}
|
|
2599
2826
|
else {
|
|
@@ -2604,6 +2831,17 @@ export class AUNClient {
|
|
|
2604
2831
|
stillPending.push(msg);
|
|
2605
2832
|
}
|
|
2606
2833
|
}
|
|
2834
|
+
if (forceAdvancedAny) {
|
|
2835
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2836
|
+
if (contig > 0) {
|
|
2837
|
+
this._transport.call('group.ack_messages', {
|
|
2838
|
+
group_id: groupId,
|
|
2839
|
+
msg_seq: contig,
|
|
2840
|
+
device_id: this._deviceId,
|
|
2841
|
+
slot_id: this._slotId,
|
|
2842
|
+
}).catch((e) => { this._clientLog.warn('group recovery force-advance ack failed: group=' + groupId, e); });
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2607
2845
|
const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2608
2846
|
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
|
|
2609
2847
|
if (mergedPending.length)
|
|
@@ -2611,26 +2849,59 @@ export class AUNClient {
|
|
|
2611
2849
|
else
|
|
2612
2850
|
this._pendingDecryptMsgs.delete(ns);
|
|
2613
2851
|
}
|
|
2852
|
+
// recovery 兜底定时去重:每个 group 在 30s 内最多调度一次"超时强制推进"任务
|
|
2853
|
+
_recoveryTimeoutScheduled = new Map();
|
|
2854
|
+
_scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
|
|
2855
|
+
if (!groupId)
|
|
2856
|
+
return;
|
|
2857
|
+
const now = Date.now();
|
|
2858
|
+
const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
|
|
2859
|
+
if (last && (last + timeoutMs) > now)
|
|
2860
|
+
return;
|
|
2861
|
+
this._recoveryTimeoutScheduled.set(groupId, now);
|
|
2862
|
+
setTimeout(() => {
|
|
2863
|
+
const ns = `group:${groupId}`;
|
|
2864
|
+
const queue = this._pendingDecryptMsgs.get(ns);
|
|
2865
|
+
if (!queue || queue.length === 0)
|
|
2866
|
+
return;
|
|
2867
|
+
this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
|
|
2868
|
+
this._safeAsync(this._retryPendingDecryptMsgs(groupId, true));
|
|
2869
|
+
}, timeoutMs);
|
|
2870
|
+
}
|
|
2614
2871
|
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
2615
2872
|
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
2616
2873
|
return;
|
|
2617
2874
|
this._safeAsync(this._retryPendingDecryptMsgs(groupId));
|
|
2618
2875
|
}
|
|
2619
2876
|
async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
|
|
2620
|
-
const
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2877
|
+
const tStart = Date.now();
|
|
2878
|
+
this._clientLog.debug(`_recoverGroupEpochKey enter: group_id=${groupId} epoch=${epoch} sender=${senderAid} timeout=${timeoutMs}`);
|
|
2879
|
+
try {
|
|
2880
|
+
const existing = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
2881
|
+
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, existing)) {
|
|
2882
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2883
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group_id=${groupId} epoch=${epoch} result=already_ready`);
|
|
2884
|
+
return true;
|
|
2885
|
+
}
|
|
2886
|
+
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
2887
|
+
const key = `${groupId}:${epoch}`;
|
|
2888
|
+
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
2889
|
+
if (inflight) {
|
|
2890
|
+
const r = await inflight;
|
|
2891
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group_id=${groupId} epoch=${epoch} result=${r ? 'ok' : 'failed'} source=inflight`);
|
|
2892
|
+
return r;
|
|
2893
|
+
}
|
|
2894
|
+
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
2895
|
+
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
2896
|
+
this._groupEpochRecoveryInflight.set(key, promise);
|
|
2897
|
+
const r = await promise;
|
|
2898
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group_id=${groupId} epoch=${epoch} result=${r ? 'ok' : 'failed'}`);
|
|
2899
|
+
return r;
|
|
2900
|
+
}
|
|
2901
|
+
catch (err) {
|
|
2902
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit (error): elapsed=${Date.now() - tStart}ms group_id=${groupId} epoch=${epoch} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2903
|
+
throw err;
|
|
2624
2904
|
}
|
|
2625
|
-
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
2626
|
-
const key = `${groupId}:${epoch}`;
|
|
2627
|
-
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
2628
|
-
if (inflight)
|
|
2629
|
-
return inflight;
|
|
2630
|
-
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
2631
|
-
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
2632
|
-
this._groupEpochRecoveryInflight.set(key, promise);
|
|
2633
|
-
return promise;
|
|
2634
2905
|
}
|
|
2635
2906
|
static _extractGroupJoinMode(payload) {
|
|
2636
2907
|
if (!isJsonObject(payload))
|
|
@@ -2976,46 +3247,66 @@ export class AUNClient {
|
|
|
2976
3247
|
}
|
|
2977
3248
|
}
|
|
2978
3249
|
async _decryptGroupMessage(message, opts) {
|
|
2979
|
-
const
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
}
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
// 不是解密失败,不应触发 recover
|
|
2999
|
-
if (result !== null) {
|
|
3000
|
-
return result;
|
|
3001
|
-
}
|
|
3002
|
-
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
3003
|
-
const groupId = String(message.group_id ?? '');
|
|
3004
|
-
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
3005
|
-
const epoch = Number(payload.epoch ?? 0);
|
|
3006
|
-
if (epoch > 0 && groupId) {
|
|
3007
|
-
try {
|
|
3008
|
-
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
3009
|
-
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
3010
|
-
if (retry !== null && retry.e2ee)
|
|
3011
|
-
return this._attachGroupDispatchModeToPayload(retry);
|
|
3250
|
+
const tStart = Date.now();
|
|
3251
|
+
const groupIdInit = String(message.group_id ?? '');
|
|
3252
|
+
const midInit = String(message.message_id ?? '');
|
|
3253
|
+
this._clientLog.debug(`_decryptGroupMessage enter: group_id=${groupIdInit} mid=${midInit} skip_replay=${!!opts?.skipReplay}`);
|
|
3254
|
+
try {
|
|
3255
|
+
const payload = isJsonObject(message.payload) ? message.payload : null;
|
|
3256
|
+
if (payload === null || payload.type !== 'e2ee.group_encrypted') {
|
|
3257
|
+
const r = this._attachGroupDispatchModeToPayload(message);
|
|
3258
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms result=passthrough_not_encrypted`);
|
|
3259
|
+
return r;
|
|
3260
|
+
}
|
|
3261
|
+
// 确保发送方证书已缓存(签名验证需要)
|
|
3262
|
+
const senderAid = String(message.from ?? message.sender_aid ?? '');
|
|
3263
|
+
if (senderAid) {
|
|
3264
|
+
const certOk = await this._ensureSenderCertCached(senderAid);
|
|
3265
|
+
if (!certOk) {
|
|
3266
|
+
this._clientLog.warn(`group message decrypt skip: sender ${senderAid} cert unavailable`);
|
|
3267
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms result=skip_no_sender_cert`);
|
|
3268
|
+
return message;
|
|
3012
3269
|
}
|
|
3013
3270
|
}
|
|
3014
|
-
|
|
3015
|
-
|
|
3271
|
+
// 先尝试直接解密
|
|
3272
|
+
const result = await this._groupE2ee.decrypt(message, opts);
|
|
3273
|
+
if (result !== null && isJsonObject(result.e2ee)) {
|
|
3274
|
+
const r = this._attachGroupDispatchModeToPayload(result);
|
|
3275
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms result=ok group_id=${groupIdInit}`);
|
|
3276
|
+
return r;
|
|
3277
|
+
}
|
|
3278
|
+
// replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
|
|
3279
|
+
// 不是解密失败,不应触发 recover
|
|
3280
|
+
if (result !== null) {
|
|
3281
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms result=replay_skipped`);
|
|
3282
|
+
return result;
|
|
3283
|
+
}
|
|
3284
|
+
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
3285
|
+
const groupId = String(message.group_id ?? '');
|
|
3286
|
+
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
3287
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
3288
|
+
if (epoch > 0 && groupId) {
|
|
3289
|
+
try {
|
|
3290
|
+
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
3291
|
+
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
3292
|
+
if (retry !== null && retry.e2ee) {
|
|
3293
|
+
const r = this._attachGroupDispatchModeToPayload(retry);
|
|
3294
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms result=ok_after_recover`);
|
|
3295
|
+
return r;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
catch (exc) {
|
|
3300
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} sync recover failed: ${formatCaughtError(exc)}`);
|
|
3301
|
+
}
|
|
3016
3302
|
}
|
|
3303
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms result=undecryptable group_id=${groupIdInit}`);
|
|
3304
|
+
return message;
|
|
3305
|
+
}
|
|
3306
|
+
catch (err) {
|
|
3307
|
+
this._clientLog.debug(`_decryptGroupMessage exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3308
|
+
throw err;
|
|
3017
3309
|
}
|
|
3018
|
-
return message;
|
|
3019
3310
|
}
|
|
3020
3311
|
_attachGroupDispatchModeToPayload(message) {
|
|
3021
3312
|
const payload = message.payload;
|
|
@@ -3132,14 +3423,14 @@ export class AUNClient {
|
|
|
3132
3423
|
if (fromAid) {
|
|
3133
3424
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
3134
3425
|
if (!certReady) {
|
|
3135
|
-
|
|
3426
|
+
this._clientLog.warn('p2p.thought.decrypt failed: cannot fetch sendercert thought_id=' + thoughtId + ' from=' + fromAid);
|
|
3136
3427
|
decryptFailed = true;
|
|
3137
3428
|
}
|
|
3138
3429
|
}
|
|
3139
3430
|
if (!decryptFailed) {
|
|
3140
3431
|
decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
|
|
3141
3432
|
if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
|
|
3142
|
-
|
|
3433
|
+
this._clientLog.warn('p2p.thought.decrypt failed thought_id=' + thoughtId);
|
|
3143
3434
|
decryptFailed = true;
|
|
3144
3435
|
decrypted = message;
|
|
3145
3436
|
}
|
|
@@ -3188,7 +3479,7 @@ export class AUNClient {
|
|
|
3188
3479
|
decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
|
|
3189
3480
|
}
|
|
3190
3481
|
catch (exc) {
|
|
3191
|
-
|
|
3482
|
+
this._clientLog.warn(`e2ee.encrypted outer decrypt exception, fallback to normal path:${String(exc)}`);
|
|
3192
3483
|
return false;
|
|
3193
3484
|
}
|
|
3194
3485
|
if (!decrypted) {
|
|
@@ -3207,11 +3498,31 @@ export class AUNClient {
|
|
|
3207
3498
|
let result = null;
|
|
3208
3499
|
try {
|
|
3209
3500
|
if (actualPayload.type === 'e2ee.group_key_distribution') {
|
|
3501
|
+
// 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
|
|
3502
|
+
// 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
|
|
3503
|
+
const distGroupId = String(actualPayload.group_id ?? '');
|
|
3504
|
+
const distEpoch = Number(actualPayload.epoch ?? 0);
|
|
3505
|
+
if (distGroupId && distEpoch > 0) {
|
|
3506
|
+
const localEpoch = await this._groupE2ee.currentEpoch(distGroupId) ?? 0;
|
|
3507
|
+
if (localEpoch >= distEpoch) {
|
|
3508
|
+
this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
|
|
3509
|
+
return true;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3210
3512
|
if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
|
|
3211
3513
|
return true;
|
|
3212
3514
|
}
|
|
3213
3515
|
}
|
|
3214
3516
|
else if (actualPayload.type === 'e2ee.group_key_response') {
|
|
3517
|
+
const respGroupId = String(actualPayload.group_id ?? '');
|
|
3518
|
+
const respEpoch = Number(actualPayload.epoch ?? 0);
|
|
3519
|
+
if (respGroupId && respEpoch > 0) {
|
|
3520
|
+
const localEpoch = await this._groupE2ee.currentEpoch(respGroupId) ?? 0;
|
|
3521
|
+
if (localEpoch >= respEpoch) {
|
|
3522
|
+
this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
|
|
3523
|
+
return true;
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3215
3526
|
if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
|
|
3216
3527
|
return true;
|
|
3217
3528
|
}
|
|
@@ -3227,7 +3538,7 @@ export class AUNClient {
|
|
|
3227
3538
|
}
|
|
3228
3539
|
}
|
|
3229
3540
|
catch (exc) {
|
|
3230
|
-
|
|
3541
|
+
this._clientLog.warn(`group key message handle exception:${String(exc)}`);
|
|
3231
3542
|
// S14: 控制面消息处理异常也要抑制业务分发
|
|
3232
3543
|
if (isGroupKeyCtrl)
|
|
3233
3544
|
return true;
|
|
@@ -3255,7 +3566,7 @@ export class AUNClient {
|
|
|
3255
3566
|
members = memberList.map(m => String(m.aid ?? ''));
|
|
3256
3567
|
}
|
|
3257
3568
|
catch (exc) {
|
|
3258
|
-
|
|
3569
|
+
this._clientLog.warn(`group ${groupId} member list pull-back failed: ${String(exc)}`);
|
|
3259
3570
|
}
|
|
3260
3571
|
}
|
|
3261
3572
|
const response = await this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
|
|
@@ -3269,7 +3580,7 @@ export class AUNClient {
|
|
|
3269
3580
|
});
|
|
3270
3581
|
}
|
|
3271
3582
|
catch (exc) {
|
|
3272
|
-
|
|
3583
|
+
this._clientLog.warn(`reply group key to ${requester} failed: ${String(exc)}`);
|
|
3273
3584
|
}
|
|
3274
3585
|
}
|
|
3275
3586
|
}
|
|
@@ -3291,144 +3602,170 @@ export class AUNClient {
|
|
|
3291
3602
|
* 跨域时自动将请求路由到 peer 所在域的 Gateway。
|
|
3292
3603
|
*/
|
|
3293
3604
|
async _fetchPeerCert(aid, certFingerprint) {
|
|
3294
|
-
const
|
|
3295
|
-
|
|
3296
|
-
const now = Date.now() / 1000;
|
|
3297
|
-
if (cached && now < cached.refreshAfter) {
|
|
3298
|
-
return cached.certPem;
|
|
3299
|
-
}
|
|
3300
|
-
const gatewayUrl = this._gatewayUrl;
|
|
3301
|
-
if (!gatewayUrl) {
|
|
3302
|
-
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
3303
|
-
}
|
|
3304
|
-
// 跨域时用 peer 所在域的 Gateway URL
|
|
3305
|
-
const peerGatewayUrl = resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
3306
|
-
let certPem;
|
|
3605
|
+
const tStart = Date.now();
|
|
3606
|
+
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
|
|
3307
3607
|
try {
|
|
3308
|
-
const
|
|
3309
|
-
|
|
3310
|
-
const
|
|
3311
|
-
|
|
3608
|
+
const cacheKey = certCacheKey(aid, certFingerprint);
|
|
3609
|
+
const cached = this._certCache.get(cacheKey);
|
|
3610
|
+
const now = Date.now() / 1000;
|
|
3611
|
+
if (cached && now < cached.refreshAfter) {
|
|
3612
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} source=cache`);
|
|
3613
|
+
return cached.certPem;
|
|
3614
|
+
}
|
|
3615
|
+
const gatewayUrl = this._gatewayUrl;
|
|
3616
|
+
if (!gatewayUrl) {
|
|
3617
|
+
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
3618
|
+
}
|
|
3619
|
+
// 跨域时用 peer 所在域的 Gateway URL
|
|
3620
|
+
const peerGatewayUrl = resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
3621
|
+
let certPem;
|
|
3312
3622
|
try {
|
|
3313
|
-
const
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3623
|
+
const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
3624
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
3625
|
+
const controller = new AbortController();
|
|
3626
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
3627
|
+
try {
|
|
3628
|
+
const resp = await fetch(certUrl, { signal: controller.signal });
|
|
3629
|
+
if (!resp.ok)
|
|
3630
|
+
throw new ValidationError(`failed to fetch peer cert for ${aid}: HTTP ${resp.status}`);
|
|
3631
|
+
certPem = await resp.text();
|
|
3632
|
+
}
|
|
3633
|
+
finally {
|
|
3634
|
+
clearTimeout(timeoutId);
|
|
3635
|
+
}
|
|
3317
3636
|
}
|
|
3318
|
-
|
|
3319
|
-
|
|
3637
|
+
catch (exc) {
|
|
3638
|
+
if (!certFingerprint) {
|
|
3639
|
+
throw exc;
|
|
3640
|
+
}
|
|
3641
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
3642
|
+
const fallbackController = new AbortController();
|
|
3643
|
+
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 5000);
|
|
3644
|
+
try {
|
|
3645
|
+
const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
|
|
3646
|
+
if (!fallbackResp.ok) {
|
|
3647
|
+
throw exc;
|
|
3648
|
+
}
|
|
3649
|
+
certPem = await fallbackResp.text();
|
|
3650
|
+
}
|
|
3651
|
+
finally {
|
|
3652
|
+
clearTimeout(fallbackTimeoutId);
|
|
3653
|
+
}
|
|
3320
3654
|
}
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3655
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
3656
|
+
if (certFingerprint) {
|
|
3657
|
+
const expectedFP = String(certFingerprint).trim().toLowerCase();
|
|
3658
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
3659
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
3660
|
+
}
|
|
3661
|
+
const derFP = await this._certFingerprint(certPem);
|
|
3662
|
+
if (derFP !== expectedFP) {
|
|
3663
|
+
const spkiFP = await this._spkiFingerprint(certPem);
|
|
3664
|
+
if (!spkiFP || spkiFP !== expectedFP) {
|
|
3665
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3325
3668
|
}
|
|
3326
|
-
//
|
|
3327
|
-
const fallbackController = new AbortController();
|
|
3328
|
-
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 5000);
|
|
3669
|
+
// 完整 PKI 验证:链 + CRL + OCSP + AID 绑定
|
|
3329
3670
|
try {
|
|
3330
|
-
|
|
3331
|
-
if (!fallbackResp.ok) {
|
|
3332
|
-
throw exc;
|
|
3333
|
-
}
|
|
3334
|
-
certPem = await fallbackResp.text();
|
|
3671
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
3335
3672
|
}
|
|
3336
|
-
|
|
3337
|
-
|
|
3673
|
+
catch (exc) {
|
|
3674
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc}`);
|
|
3338
3675
|
}
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3676
|
+
this._certCache.set(cacheKey, {
|
|
3677
|
+
certPem,
|
|
3678
|
+
validatedAt: now,
|
|
3679
|
+
refreshAfter: now + PEER_CERT_CACHE_TTL,
|
|
3680
|
+
});
|
|
3681
|
+
try {
|
|
3682
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
3683
|
+
await this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
3345
3684
|
}
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
const spkiFP = await this._spkiFingerprint(certPem);
|
|
3349
|
-
if (!spkiFP || spkiFP !== expectedFP) {
|
|
3350
|
-
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3351
|
-
}
|
|
3685
|
+
catch (exc) {
|
|
3686
|
+
this._clientLog.error(`write cert to keystore failed (aid=${aid}): ${String(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3352
3687
|
}
|
|
3688
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} source=fetched`);
|
|
3689
|
+
return certPem;
|
|
3353
3690
|
}
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
}
|
|
3358
|
-
catch (exc) {
|
|
3359
|
-
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc}`);
|
|
3691
|
+
catch (err) {
|
|
3692
|
+
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3693
|
+
throw err;
|
|
3360
3694
|
}
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
});
|
|
3695
|
+
}
|
|
3696
|
+
/** 获取对方所有设备的 prekey(带缓存)。 */
|
|
3697
|
+
async _fetchPeerPrekeys(peerAid) {
|
|
3698
|
+
const tStart = Date.now();
|
|
3699
|
+
this._clientLog.debug(`_fetchPeerPrekeys enter: peer_aid=${peerAid}`);
|
|
3366
3700
|
try {
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
}
|
|
3375
|
-
/** 获取对方所有设备的 prekey(带缓存)。 */
|
|
3376
|
-
async _fetchPeerPrekeys(peerAid) {
|
|
3377
|
-
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
3378
|
-
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
3379
|
-
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
3380
|
-
if (normalized.length > 0)
|
|
3381
|
-
return normalized.map((item) => ({ ...item }));
|
|
3382
|
-
}
|
|
3383
|
-
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
3384
|
-
if (cached !== null) {
|
|
3385
|
-
const normalized = normalizePeerPrekeys([cached]);
|
|
3386
|
-
if (normalized.length > 0)
|
|
3387
|
-
return normalized.map((item) => ({ ...item }));
|
|
3388
|
-
}
|
|
3389
|
-
let result;
|
|
3390
|
-
try {
|
|
3391
|
-
result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
3392
|
-
}
|
|
3393
|
-
catch (exc) {
|
|
3394
|
-
throw new ValidationError(`failed to fetch peer prekey for ${peerAid}: ${String(exc)}`);
|
|
3395
|
-
}
|
|
3396
|
-
if (!isJsonObject(result)) {
|
|
3397
|
-
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3398
|
-
}
|
|
3399
|
-
if (result.found === false) {
|
|
3400
|
-
return [];
|
|
3401
|
-
}
|
|
3402
|
-
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
3403
|
-
if (devicePrekeys) {
|
|
3404
|
-
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
3405
|
-
if (normalized.length > 0) {
|
|
3406
|
-
this._peerPrekeysCache.set(peerAid, {
|
|
3407
|
-
items: normalized.map((item) => ({ ...item })),
|
|
3408
|
-
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
3409
|
-
});
|
|
3410
|
-
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3411
|
-
return normalized;
|
|
3701
|
+
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
3702
|
+
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
3703
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
3704
|
+
if (normalized.length > 0) {
|
|
3705
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} count=${normalized.length} source=list_cache`);
|
|
3706
|
+
return normalized.map((item) => ({ ...item }));
|
|
3707
|
+
}
|
|
3412
3708
|
}
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
});
|
|
3424
|
-
|
|
3425
|
-
|
|
3709
|
+
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
3710
|
+
if (cached !== null) {
|
|
3711
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
3712
|
+
if (normalized.length > 0) {
|
|
3713
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} count=${normalized.length} source=e2ee_cache`);
|
|
3714
|
+
return normalized.map((item) => ({ ...item }));
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
let result;
|
|
3718
|
+
try {
|
|
3719
|
+
result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
3720
|
+
}
|
|
3721
|
+
catch (exc) {
|
|
3722
|
+
throw new ValidationError(`failed to fetch peer prekey for ${peerAid}: ${String(exc)}`);
|
|
3723
|
+
}
|
|
3724
|
+
if (!isJsonObject(result)) {
|
|
3725
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3726
|
+
}
|
|
3727
|
+
if (result.found === false) {
|
|
3728
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} count=0 reason=not_found`);
|
|
3729
|
+
return [];
|
|
3730
|
+
}
|
|
3731
|
+
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
3732
|
+
if (devicePrekeys) {
|
|
3733
|
+
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
3734
|
+
if (normalized.length > 0) {
|
|
3735
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
3736
|
+
items: normalized.map((item) => ({ ...item })),
|
|
3737
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
3738
|
+
});
|
|
3739
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3740
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} count=${normalized.length} source=device_prekeys`);
|
|
3741
|
+
return normalized;
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
if (!isPeerPrekeyResponse(result)) {
|
|
3745
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3746
|
+
}
|
|
3747
|
+
if (result.prekey) {
|
|
3748
|
+
const normalized = normalizePeerPrekeys([result.prekey]);
|
|
3749
|
+
if (normalized.length > 0) {
|
|
3750
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
3751
|
+
items: normalized.map((item) => ({ ...item })),
|
|
3752
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
3753
|
+
});
|
|
3754
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3755
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} count=${normalized.length} source=single_prekey`);
|
|
3756
|
+
return normalized.map((item) => ({ ...item }));
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
if (result.found) {
|
|
3760
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3426
3761
|
}
|
|
3762
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} count=0`);
|
|
3763
|
+
return [];
|
|
3427
3764
|
}
|
|
3428
|
-
|
|
3429
|
-
|
|
3765
|
+
catch (err) {
|
|
3766
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit (error): elapsed=${Date.now() - tStart}ms peer_aid=${peerAid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3767
|
+
throw err;
|
|
3430
3768
|
}
|
|
3431
|
-
return [];
|
|
3432
3769
|
}
|
|
3433
3770
|
/** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
|
|
3434
3771
|
_invalidatePeerPrekeyCache(peerAid) {
|
|
@@ -3467,6 +3804,8 @@ export class AUNClient {
|
|
|
3467
3804
|
async _uploadPrekey() {
|
|
3468
3805
|
const prekeyMaterial = await this._e2ee.generatePrekey();
|
|
3469
3806
|
const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
|
|
3807
|
+
// 上传成功后记录为活跃 prekey
|
|
3808
|
+
this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
|
|
3470
3809
|
return isJsonObject(result) ? { ...result } : { ok: true };
|
|
3471
3810
|
}
|
|
3472
3811
|
/** 确保发送方证书在本地可用且未过期 */
|
|
@@ -3507,10 +3846,10 @@ export class AUNClient {
|
|
|
3507
3846
|
catch (exc) {
|
|
3508
3847
|
// 刷新失败时:若缓存有 PKI 验证过的证书(2 倍 TTL 内)则继续用
|
|
3509
3848
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
3510
|
-
|
|
3849
|
+
this._clientLog.warn(`refresh sender ${aid} cert failed, continue using verified memory cache: ${String(exc)}`);
|
|
3511
3850
|
return true;
|
|
3512
3851
|
}
|
|
3513
|
-
|
|
3852
|
+
this._clientLog.warn(`fetch sender ${aid} cert failed and no verify cache, reject trust: ${String(exc)}`);
|
|
3514
3853
|
return false;
|
|
3515
3854
|
}
|
|
3516
3855
|
}
|
|
@@ -3638,7 +3977,7 @@ export class AUNClient {
|
|
|
3638
3977
|
}
|
|
3639
3978
|
else {
|
|
3640
3979
|
failed.push(String(dist.to));
|
|
3641
|
-
|
|
3980
|
+
this._clientLog.warn(`epoch keydistributefailed (to=%s):${dist.to} ${exc}`);
|
|
3642
3981
|
}
|
|
3643
3982
|
}
|
|
3644
3983
|
}
|
|
@@ -3656,7 +3995,7 @@ export class AUNClient {
|
|
|
3656
3995
|
return isJsonObject(result) && result.success === true;
|
|
3657
3996
|
}
|
|
3658
3997
|
catch (exc) {
|
|
3659
|
-
|
|
3998
|
+
this._clientLog.warn(`refresh epoch rotation lease failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3660
3999
|
return false;
|
|
3661
4000
|
}
|
|
3662
4001
|
}
|
|
@@ -3672,7 +4011,7 @@ export class AUNClient {
|
|
|
3672
4011
|
return isJsonObject(result) && result.success === true;
|
|
3673
4012
|
}
|
|
3674
4013
|
catch (exc) {
|
|
3675
|
-
|
|
4014
|
+
this._clientLog.warn(`commit epoch key ack failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3676
4015
|
return false;
|
|
3677
4016
|
}
|
|
3678
4017
|
}
|
|
@@ -3681,6 +4020,11 @@ export class AUNClient {
|
|
|
3681
4020
|
const groupId = String(payload.group_id ?? '').trim();
|
|
3682
4021
|
if (!groupId)
|
|
3683
4022
|
return false;
|
|
4023
|
+
// 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
|
|
4024
|
+
if (!this._groupSynced.has(groupId)) {
|
|
4025
|
+
this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
|
|
4026
|
+
return true;
|
|
4027
|
+
}
|
|
3684
4028
|
const epoch = Number(payload.epoch ?? 0);
|
|
3685
4029
|
if (!Number.isFinite(epoch) || epoch <= 0)
|
|
3686
4030
|
return false;
|
|
@@ -3700,7 +4044,7 @@ export class AUNClient {
|
|
|
3700
4044
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3701
4045
|
: [];
|
|
3702
4046
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3703
|
-
|
|
4047
|
+
this._clientLog.debug(`allow group key distribute: new member recover commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3704
4048
|
}
|
|
3705
4049
|
else {
|
|
3706
4050
|
return false;
|
|
@@ -3709,7 +4053,7 @@ export class AUNClient {
|
|
|
3709
4053
|
}
|
|
3710
4054
|
return true;
|
|
3711
4055
|
}
|
|
3712
|
-
|
|
4056
|
+
this._clientLog.info(`reject missing rotation_id future epoch key distribute: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3713
4057
|
return false;
|
|
3714
4058
|
}
|
|
3715
4059
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -3730,10 +4074,10 @@ export class AUNClient {
|
|
|
3730
4074
|
}
|
|
3731
4075
|
}
|
|
3732
4076
|
catch (exc) {
|
|
3733
|
-
|
|
4077
|
+
this._clientLog.warn(`reject cannot check active rotation epoch key distribute: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3734
4078
|
return false;
|
|
3735
4079
|
}
|
|
3736
|
-
|
|
4080
|
+
this._clientLog.info(`reject non-pending/committed state epoch key distribute: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
|
|
3737
4081
|
return false;
|
|
3738
4082
|
}
|
|
3739
4083
|
async _discardGroupDistributionIfStale(payload) {
|
|
@@ -3748,10 +4092,10 @@ export class AUNClient {
|
|
|
3748
4092
|
return;
|
|
3749
4093
|
try {
|
|
3750
4094
|
await this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
|
|
3751
|
-
|
|
4095
|
+
this._clientLog.info('discard stale group epoch key after verify: group=%s epoch=%s rotation=%s', groupId, epoch, rotationId);
|
|
3752
4096
|
}
|
|
3753
4097
|
catch (exc) {
|
|
3754
|
-
|
|
4098
|
+
this._clientLog.debug('cleanup stale group epoch key failed: group=%s epoch=%s rotation=%s err=%s', groupId, epoch, rotationId, formatCaughtError(exc));
|
|
3755
4099
|
}
|
|
3756
4100
|
}
|
|
3757
4101
|
async _verifyGroupKeyResponseEpoch(payload) {
|
|
@@ -3768,7 +4112,7 @@ export class AUNClient {
|
|
|
3768
4112
|
return false;
|
|
3769
4113
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
3770
4114
|
if (epoch > committedEpoch) {
|
|
3771
|
-
|
|
4115
|
+
this._clientLog.info(`reject uncommitted epoch group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3772
4116
|
return false;
|
|
3773
4117
|
}
|
|
3774
4118
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
@@ -3779,7 +4123,7 @@ export class AUNClient {
|
|
|
3779
4123
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3780
4124
|
: [];
|
|
3781
4125
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3782
|
-
|
|
4126
|
+
this._clientLog.debug(`allow group key response: new member recover commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3783
4127
|
}
|
|
3784
4128
|
else {
|
|
3785
4129
|
return false;
|
|
@@ -3789,7 +4133,7 @@ export class AUNClient {
|
|
|
3789
4133
|
return true;
|
|
3790
4134
|
}
|
|
3791
4135
|
catch (exc) {
|
|
3792
|
-
|
|
4136
|
+
this._clientLog.warn(`reject cannot check committed epoch group key response: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3793
4137
|
return false;
|
|
3794
4138
|
}
|
|
3795
4139
|
}
|
|
@@ -3804,7 +4148,7 @@ export class AUNClient {
|
|
|
3804
4148
|
return isJsonObject(result) && result.success === true;
|
|
3805
4149
|
}
|
|
3806
4150
|
catch (exc) {
|
|
3807
|
-
|
|
4151
|
+
this._clientLog.warn(`abort epoch rotation failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3808
4152
|
return false;
|
|
3809
4153
|
}
|
|
3810
4154
|
}
|
|
@@ -3848,7 +4192,7 @@ export class AUNClient {
|
|
|
3848
4192
|
if (this._closing || this._state !== 'connected')
|
|
3849
4193
|
return;
|
|
3850
4194
|
if (Date.now() - started > 20000) {
|
|
3851
|
-
|
|
4195
|
+
this._clientLog.warn(`group epoch create sync still in-flight; skip duplicate sync (group=%s)${String(groupId)}`);
|
|
3852
4196
|
return;
|
|
3853
4197
|
}
|
|
3854
4198
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
@@ -3882,12 +4226,12 @@ export class AUNClient {
|
|
|
3882
4226
|
const beginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
|
|
3883
4227
|
const rotation = isJsonObject(beginResult) && isJsonObject(beginResult.rotation) ? beginResult.rotation : null;
|
|
3884
4228
|
if (!isJsonObject(beginResult) || beginResult.success !== true || !rotation) {
|
|
3885
|
-
|
|
4229
|
+
this._clientLog.warn('group epoch begin failed; stop key distribution (group=%s, returned=%s)', groupId, JSON.stringify(beginResult));
|
|
3886
4230
|
return;
|
|
3887
4231
|
}
|
|
3888
4232
|
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
3889
4233
|
if (!await this._ackGroupRotationKey(activeRotationId, secretData.commitment)) {
|
|
3890
|
-
|
|
4234
|
+
this._clientLog.warn(`group epoch self ack failed (group=%s, rotation=%s)${groupId} ${activeRotationId}`);
|
|
3891
4235
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3892
4236
|
return;
|
|
3893
4237
|
}
|
|
@@ -3904,17 +4248,17 @@ export class AUNClient {
|
|
|
3904
4248
|
await storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
|
|
3905
4249
|
return;
|
|
3906
4250
|
}
|
|
3907
|
-
|
|
4251
|
+
this._clientLog.warn('group epoch commit failed (group=%s, returned=%s)', groupId, JSON.stringify(commitResult));
|
|
3908
4252
|
return;
|
|
3909
4253
|
}
|
|
3910
4254
|
catch (exc) {
|
|
3911
4255
|
if (attempt < maxRetries) {
|
|
3912
4256
|
const delay = 500 * Math.pow(2, attempt - 1);
|
|
3913
|
-
|
|
4257
|
+
this._clientLog.warn(`sync epoch to server failed (group=${groupId}, #${attempt}/${maxRetries} ), ${delay}ms then retry: ${String(exc)}`);
|
|
3914
4258
|
await new Promise(r => setTimeout(r, delay));
|
|
3915
4259
|
}
|
|
3916
4260
|
else {
|
|
3917
|
-
|
|
4261
|
+
this._clientLog.error(`sync epoch to server final failed (group=${groupId}, retry${maxRetries} ): ${String(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3918
4262
|
}
|
|
3919
4263
|
}
|
|
3920
4264
|
}
|
|
@@ -3928,118 +4272,136 @@ export class AUNClient {
|
|
|
3928
4272
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
3929
4273
|
*/
|
|
3930
4274
|
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
3931
|
-
const
|
|
3932
|
-
|
|
3933
|
-
return;
|
|
3934
|
-
const started = Date.now();
|
|
3935
|
-
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
3936
|
-
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
3937
|
-
return;
|
|
3938
|
-
if (this._closing || this._state !== 'connected')
|
|
3939
|
-
return;
|
|
3940
|
-
if (Date.now() - started > 20000) {
|
|
3941
|
-
console.warn('group epoch rotation still in-flight; skip pending trigger (group=%s trigger=%s)', groupId, triggerId || '-');
|
|
3942
|
-
return;
|
|
3943
|
-
}
|
|
3944
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
3945
|
-
}
|
|
3946
|
-
if (this._closing || this._state !== 'connected')
|
|
3947
|
-
return;
|
|
3948
|
-
this._groupEpochRotationInflight.add(groupId);
|
|
4275
|
+
const tStart = Date.now();
|
|
4276
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch enter: group_id=${groupId} trigger=${triggerId || '-'} expected_epoch=${expectedEpoch ?? '-'} allow_member=${allowMember}`);
|
|
3949
4277
|
try {
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
if (!isJsonObject(membersResp))
|
|
3954
|
-
return;
|
|
3955
|
-
const rawList = membersResp.members ?? membersResp.items;
|
|
3956
|
-
if (!Array.isArray(rawList))
|
|
4278
|
+
const myAid = this._aid;
|
|
4279
|
+
if (!myAid || this._closing || this._state !== 'connected') {
|
|
4280
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms reason=not_ready`);
|
|
3957
4281
|
return;
|
|
3958
|
-
|
|
3959
|
-
const
|
|
3960
|
-
|
|
3961
|
-
if (
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
if (
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
admins.push(aid);
|
|
4282
|
+
}
|
|
4283
|
+
const started = Date.now();
|
|
4284
|
+
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
4285
|
+
if (triggerId && this._groupMembershipRotationDone.has(triggerId)) {
|
|
4286
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms reason=trigger_done`);
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
if (this._closing || this._state !== 'connected') {
|
|
4290
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms reason=disconnected_during_wait`);
|
|
4291
|
+
return;
|
|
3969
4292
|
}
|
|
3970
|
-
|
|
3971
|
-
|
|
4293
|
+
if (Date.now() - started > 20000) {
|
|
4294
|
+
this._clientLog.warn(`group epoch rotation still in-flight; skip pending trigger (group=%s trigger=%s)${groupId} ${triggerId || '-'}`);
|
|
4295
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms reason=inflight_timeout`);
|
|
4296
|
+
return;
|
|
3972
4297
|
}
|
|
4298
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
3973
4299
|
}
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
if (candidates.length === 0)
|
|
4300
|
+
if (this._closing || this._state !== 'connected') {
|
|
4301
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms reason=disconnected`);
|
|
3977
4302
|
return;
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
if (
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
4303
|
+
}
|
|
4304
|
+
this._groupEpochRotationInflight.add(groupId);
|
|
4305
|
+
try {
|
|
4306
|
+
if (this._closing || this._state !== 'connected')
|
|
4307
|
+
return;
|
|
4308
|
+
const membersResp = await this.call('group.get_members', { group_id: groupId });
|
|
4309
|
+
if (!isJsonObject(membersResp))
|
|
4310
|
+
return;
|
|
4311
|
+
const rawList = membersResp.members ?? membersResp.items;
|
|
4312
|
+
if (!Array.isArray(rawList))
|
|
4313
|
+
return;
|
|
4314
|
+
const admins = [];
|
|
4315
|
+
const members = [];
|
|
4316
|
+
for (const m of rawList) {
|
|
4317
|
+
if (!isJsonObject(m))
|
|
4318
|
+
continue;
|
|
4319
|
+
const role = String(m.role ?? '');
|
|
4320
|
+
const aid = String(m.aid ?? '');
|
|
4321
|
+
if (!aid)
|
|
4322
|
+
continue;
|
|
4323
|
+
if (role === 'admin' || role === 'owner') {
|
|
4324
|
+
admins.push(aid);
|
|
3985
4325
|
}
|
|
3986
|
-
else if (
|
|
3987
|
-
|
|
4326
|
+
else if (allowMember && role === 'member') {
|
|
4327
|
+
members.push(aid);
|
|
3988
4328
|
}
|
|
3989
4329
|
}
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
4330
|
+
// 候选列表:admin/owner 排序在前,member 排序在后
|
|
4331
|
+
let candidates = [...admins.sort(), ...members.sort()];
|
|
4332
|
+
if (candidates.length === 0)
|
|
4333
|
+
return;
|
|
4334
|
+
// 没有当前 epoch key 的成员不参与 leader 选举
|
|
4335
|
+
if (expectedEpoch !== null && expectedEpoch > 0) {
|
|
4336
|
+
const localSecret = await this._groupE2ee.loadSecret(groupId, expectedEpoch);
|
|
4337
|
+
if (!localSecret) {
|
|
4338
|
+
const filtered = candidates.filter(c => c !== myAid);
|
|
4339
|
+
if (filtered.length > 0) {
|
|
4340
|
+
candidates = filtered;
|
|
4341
|
+
}
|
|
4342
|
+
else if (!allowMember) {
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4347
|
+
const leader = candidates[0];
|
|
4348
|
+
if (leader === myAid) {
|
|
4349
|
+
// 我是 leader,直接发起
|
|
4350
|
+
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
4353
|
+
if (!candidates.includes(myAid))
|
|
4354
|
+
return;
|
|
4355
|
+
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
4356
|
+
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
4357
|
+
let beforeEpoch = 0;
|
|
4358
|
+
try {
|
|
4359
|
+
const resp = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
4360
|
+
if (isJsonObject(resp))
|
|
4361
|
+
beforeEpoch = Number(resp.epoch ?? 0);
|
|
4362
|
+
}
|
|
4363
|
+
catch {
|
|
4364
|
+
beforeEpoch = (await this._groupE2ee.currentEpoch(groupId)) ?? 0;
|
|
4365
|
+
}
|
|
4366
|
+
await new Promise((r) => setTimeout(r, jitterMs));
|
|
4367
|
+
if (this._closing || this._state !== 'connected')
|
|
4368
|
+
return;
|
|
4369
|
+
let afterEpoch = 0;
|
|
4370
|
+
let afterResp = {};
|
|
4371
|
+
try {
|
|
4372
|
+
afterResp = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
4373
|
+
if (isJsonObject(afterResp))
|
|
4374
|
+
afterEpoch = Number(afterResp.epoch ?? 0);
|
|
4375
|
+
}
|
|
4376
|
+
catch {
|
|
4377
|
+
afterEpoch = (await this._groupE2ee.currentEpoch(groupId)) ?? 0;
|
|
4378
|
+
}
|
|
4379
|
+
if (afterEpoch > beforeEpoch)
|
|
4380
|
+
return; // leader 已完成
|
|
4381
|
+
const pending = isJsonObject(afterResp) && isJsonObject(afterResp.pending_rotation) ? afterResp.pending_rotation : null;
|
|
4382
|
+
if (pending && !pending.expired) {
|
|
4383
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
4384
|
+
reason: 'membership_changed',
|
|
4385
|
+
triggerId,
|
|
4386
|
+
expectedEpoch,
|
|
4387
|
+
pending,
|
|
4388
|
+
});
|
|
4389
|
+
return;
|
|
4390
|
+
}
|
|
4391
|
+
this._clientLog.info(`[H21] leader did not complete epoch rotation, non-leader fallback: group=%s myAid=%s${groupId} ${myAid}`);
|
|
3994
4392
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
3995
|
-
return;
|
|
3996
|
-
}
|
|
3997
|
-
if (!candidates.includes(myAid))
|
|
3998
|
-
return;
|
|
3999
|
-
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
4000
|
-
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
4001
|
-
let beforeEpoch = 0;
|
|
4002
|
-
try {
|
|
4003
|
-
const resp = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
4004
|
-
if (isJsonObject(resp))
|
|
4005
|
-
beforeEpoch = Number(resp.epoch ?? 0);
|
|
4006
|
-
}
|
|
4007
|
-
catch {
|
|
4008
|
-
beforeEpoch = (await this._groupE2ee.currentEpoch(groupId)) ?? 0;
|
|
4009
|
-
}
|
|
4010
|
-
await new Promise((r) => setTimeout(r, jitterMs));
|
|
4011
|
-
if (this._closing || this._state !== 'connected')
|
|
4012
|
-
return;
|
|
4013
|
-
let afterEpoch = 0;
|
|
4014
|
-
let afterResp = {};
|
|
4015
|
-
try {
|
|
4016
|
-
afterResp = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
4017
|
-
if (isJsonObject(afterResp))
|
|
4018
|
-
afterEpoch = Number(afterResp.epoch ?? 0);
|
|
4019
4393
|
}
|
|
4020
|
-
catch {
|
|
4021
|
-
|
|
4394
|
+
catch (exc) {
|
|
4395
|
+
this._clientLog.warn(`_maybeLeadRotateGroupEpoch failed: %s${String(exc)}`);
|
|
4022
4396
|
}
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
const pending = isJsonObject(afterResp) && isJsonObject(afterResp.pending_rotation) ? afterResp.pending_rotation : null;
|
|
4026
|
-
if (pending && !pending.expired) {
|
|
4027
|
-
this._scheduleGroupRotationRetry(groupId, {
|
|
4028
|
-
reason: 'membership_changed',
|
|
4029
|
-
triggerId,
|
|
4030
|
-
expectedEpoch,
|
|
4031
|
-
pending,
|
|
4032
|
-
});
|
|
4033
|
-
return;
|
|
4397
|
+
finally {
|
|
4398
|
+
this._groupEpochRotationInflight.delete(groupId);
|
|
4034
4399
|
}
|
|
4035
|
-
|
|
4036
|
-
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
4037
|
-
}
|
|
4038
|
-
catch (exc) {
|
|
4039
|
-
console.warn('_maybeLeadRotateGroupEpoch 失败: %s', exc);
|
|
4400
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
4040
4401
|
}
|
|
4041
|
-
|
|
4042
|
-
this.
|
|
4402
|
+
catch (err) {
|
|
4403
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
4404
|
+
throw err;
|
|
4043
4405
|
}
|
|
4044
4406
|
}
|
|
4045
4407
|
/**
|
|
@@ -4047,196 +4409,205 @@ export class AUNClient {
|
|
|
4047
4409
|
* 使用服务端 CAS 保证只有一方成功。
|
|
4048
4410
|
*/
|
|
4049
4411
|
async _rotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
4412
|
+
const tStart = Date.now();
|
|
4413
|
+
this._clientLog.debug(`_rotateGroupEpoch enter: group_id=${groupId} trigger=${triggerId || '-'} expected_epoch=${expectedEpoch ?? '-'}`);
|
|
4050
4414
|
try {
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4415
|
+
try {
|
|
4416
|
+
if (!this._aid)
|
|
4417
|
+
return;
|
|
4418
|
+
const memberAids = await this._getGroupMemberAids(groupId);
|
|
4419
|
+
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
4420
|
+
return;
|
|
4421
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
4422
|
+
const serverEpoch = isJsonObject(epochResult) ? Number(epochResult.epoch ?? 0) : 0;
|
|
4423
|
+
const pendingRotation = isJsonObject(epochResult) && isJsonObject(epochResult.pending_rotation)
|
|
4424
|
+
? epochResult.pending_rotation
|
|
4425
|
+
: null;
|
|
4426
|
+
if (pendingRotation && !pendingRotation.expired) {
|
|
4427
|
+
const pendingRotationId = String(pendingRotation.rotation_id ?? '');
|
|
4428
|
+
const stalePending = (expectedEpoch !== null
|
|
4429
|
+
&& serverEpoch === expectedEpoch
|
|
4430
|
+
&& this._rotationExpectedMembersStale(pendingRotation, memberAids));
|
|
4431
|
+
if (stalePending && await this._abortGroupRotation(pendingRotationId, 'membership_changed_during_rotation')) {
|
|
4432
|
+
this._clientLog.info(`aborted stale pending group epoch rotation: group=%s rotation=%s${groupId} ${pendingRotationId || '-'}`);
|
|
4433
|
+
}
|
|
4434
|
+
else {
|
|
4435
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
4436
|
+
reason: 'membership_changed',
|
|
4437
|
+
triggerId,
|
|
4438
|
+
expectedEpoch,
|
|
4439
|
+
pending: pendingRotation,
|
|
4440
|
+
});
|
|
4441
|
+
return;
|
|
4442
|
+
}
|
|
4068
4443
|
}
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
expectedEpoch,
|
|
4074
|
-
pending: pendingRotation,
|
|
4075
|
-
});
|
|
4444
|
+
if (expectedEpoch !== null && serverEpoch !== expectedEpoch) {
|
|
4445
|
+
if (triggerId)
|
|
4446
|
+
this._groupMembershipRotationDone.add(triggerId);
|
|
4447
|
+
this._clientLog.info(`skip membership epoch rotation: group=%s expected_epoch=%d server_epoch=%d trigger=%s${groupId} ${expectedEpoch} ${serverEpoch} ${triggerId || '-'}`);
|
|
4076
4448
|
return;
|
|
4077
4449
|
}
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
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}`);
|
|
4450
|
+
const currentEpoch = expectedEpoch ?? serverEpoch;
|
|
4451
|
+
const targetEpoch = currentEpoch + 1;
|
|
4452
|
+
let prevChainHint = null;
|
|
4453
|
+
const localPrev = await this._groupE2ee.loadSecret(groupId, currentEpoch);
|
|
4454
|
+
const localPrevChain = String(localPrev?.epoch_chain ?? '').trim();
|
|
4455
|
+
if (!localPrevChain && isJsonObject(epochResult)) {
|
|
4456
|
+
const cr = epochResult.committed_rotation;
|
|
4457
|
+
if (isJsonObject(cr)) {
|
|
4458
|
+
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
4459
|
+
if (rawChain) {
|
|
4460
|
+
prevChainHint = rawChain;
|
|
4461
|
+
this._clientLog.info(`rotation supplement prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
|
|
4462
|
+
}
|
|
4097
4463
|
}
|
|
4098
4464
|
}
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4465
|
+
const rotationId = `rot-${_uuidV4().replace(/-/g, '')}`;
|
|
4466
|
+
const info = await this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
|
|
4467
|
+
this._attachRotationId(info, rotationId);
|
|
4468
|
+
const discardGeneratedPending = async () => {
|
|
4469
|
+
try {
|
|
4470
|
+
await this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
|
|
4471
|
+
}
|
|
4472
|
+
catch (cleanupExc) {
|
|
4473
|
+
this._clientLog.debug('cleanup local pending group key failed: group=%s epoch=%d rotation=%s err=%s', groupId, targetEpoch, rotationId, formatCaughtError(cleanupExc));
|
|
4474
|
+
}
|
|
4475
|
+
};
|
|
4476
|
+
const rotateParams = {
|
|
4477
|
+
group_id: groupId,
|
|
4478
|
+
base_epoch: currentEpoch,
|
|
4479
|
+
target_epoch: targetEpoch,
|
|
4480
|
+
rotation_id: rotationId,
|
|
4481
|
+
reason: triggerId || expectedEpoch !== null ? 'membership_changed' : 'manual',
|
|
4482
|
+
key_commitment: String(info.commitment ?? ''),
|
|
4483
|
+
expected_members: memberAids,
|
|
4484
|
+
required_acks: [this._aid],
|
|
4485
|
+
lease_ms: GROUP_ROTATION_LEASE_MS,
|
|
4486
|
+
};
|
|
4487
|
+
const sigParams = await this._buildRotationSignature(groupId, currentEpoch, targetEpoch, rotateParams);
|
|
4488
|
+
Object.assign(rotateParams, sigParams);
|
|
4489
|
+
let rawBeginResult;
|
|
4104
4490
|
try {
|
|
4105
|
-
await this.
|
|
4491
|
+
rawBeginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
|
|
4106
4492
|
}
|
|
4107
|
-
catch (
|
|
4108
|
-
|
|
4493
|
+
catch (exc) {
|
|
4494
|
+
await discardGeneratedPending();
|
|
4495
|
+
throw exc;
|
|
4109
4496
|
}
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
const beginResult = isJsonObject(rawBeginResult) ? rawBeginResult : null;
|
|
4133
|
-
const beginRotationRaw = beginResult ? beginResult.rotation : null;
|
|
4134
|
-
const rotation = isJsonObject(beginRotationRaw) ? beginRotationRaw : null;
|
|
4135
|
-
if (!beginResult || beginResult.success !== true || !rotation) {
|
|
4136
|
-
if (rotation && !rotation.expired) {
|
|
4137
|
-
if (this._rotationExpectedMembersStale(rotation, memberAids)
|
|
4138
|
-
&& await this._abortGroupRotation(String(rotation.rotation_id ?? ''), 'membership_changed_during_rotation')) {
|
|
4139
|
-
this._scheduleGroupRotationRetry(groupId, {
|
|
4140
|
-
reason: 'membership_changed',
|
|
4141
|
-
triggerId,
|
|
4142
|
-
expectedEpoch,
|
|
4143
|
-
pending: null,
|
|
4144
|
-
});
|
|
4497
|
+
const beginResult = isJsonObject(rawBeginResult) ? rawBeginResult : null;
|
|
4498
|
+
const beginRotationRaw = beginResult ? beginResult.rotation : null;
|
|
4499
|
+
const rotation = isJsonObject(beginRotationRaw) ? beginRotationRaw : null;
|
|
4500
|
+
if (!beginResult || beginResult.success !== true || !rotation) {
|
|
4501
|
+
if (rotation && !rotation.expired) {
|
|
4502
|
+
if (this._rotationExpectedMembersStale(rotation, memberAids)
|
|
4503
|
+
&& await this._abortGroupRotation(String(rotation.rotation_id ?? ''), 'membership_changed_during_rotation')) {
|
|
4504
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
4505
|
+
reason: 'membership_changed',
|
|
4506
|
+
triggerId,
|
|
4507
|
+
expectedEpoch,
|
|
4508
|
+
pending: null,
|
|
4509
|
+
});
|
|
4510
|
+
}
|
|
4511
|
+
else {
|
|
4512
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
4513
|
+
reason: 'membership_changed',
|
|
4514
|
+
triggerId,
|
|
4515
|
+
expectedEpoch,
|
|
4516
|
+
pending: rotation,
|
|
4517
|
+
});
|
|
4518
|
+
}
|
|
4145
4519
|
}
|
|
4146
|
-
else {
|
|
4520
|
+
else if (beginResult && beginResult.reason === 'expected_members_mismatch') {
|
|
4147
4521
|
this._scheduleGroupRotationRetry(groupId, {
|
|
4148
4522
|
reason: 'membership_changed',
|
|
4149
4523
|
triggerId,
|
|
4150
4524
|
expectedEpoch,
|
|
4151
|
-
pending:
|
|
4525
|
+
pending: null,
|
|
4152
4526
|
});
|
|
4153
4527
|
}
|
|
4528
|
+
this._clientLog.warn('group epoch begin failed; stop key distribution (group=%s, current_epoch=%d, returned=%s)', groupId, currentEpoch, JSON.stringify(beginResult));
|
|
4529
|
+
await discardGeneratedPending();
|
|
4530
|
+
return;
|
|
4154
4531
|
}
|
|
4155
|
-
|
|
4532
|
+
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
4533
|
+
const distributeResult = await this._distributeGroupEpochKey(info, activeRotationId);
|
|
4534
|
+
if (distributeResult.failed.length > 0) {
|
|
4535
|
+
this._clientLog.warn('group epoch key distribution incomplete; abort rotation before retry (group=%s rotation=%s failed=%s)', groupId, activeRotationId, distributeResult.failed.join(','));
|
|
4536
|
+
await this._abortGroupRotation(activeRotationId, 'distribution_failed');
|
|
4156
4537
|
this._scheduleGroupRotationRetry(groupId, {
|
|
4157
4538
|
reason: 'membership_changed',
|
|
4158
4539
|
triggerId,
|
|
4159
4540
|
expectedEpoch,
|
|
4160
4541
|
pending: null,
|
|
4161
4542
|
});
|
|
4543
|
+
await discardGeneratedPending();
|
|
4544
|
+
return;
|
|
4162
4545
|
}
|
|
4163
|
-
|
|
4164
|
-
await
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
reason: 'membership_changed',
|
|
4174
|
-
triggerId,
|
|
4175
|
-
expectedEpoch,
|
|
4176
|
-
pending: null,
|
|
4177
|
-
});
|
|
4178
|
-
await discardGeneratedPending();
|
|
4179
|
-
return;
|
|
4180
|
-
}
|
|
4181
|
-
await this._heartbeatGroupRotation(activeRotationId);
|
|
4182
|
-
if (!await this._ackGroupRotationKey(activeRotationId, String(info.commitment ?? ''))) {
|
|
4183
|
-
console.warn('group epoch self ack failed; abort rotation before retry (group=%s rotation=%s)', groupId, activeRotationId);
|
|
4184
|
-
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
4185
|
-
this._scheduleGroupRotationRetry(groupId, {
|
|
4186
|
-
reason: 'membership_changed',
|
|
4187
|
-
triggerId,
|
|
4188
|
-
expectedEpoch,
|
|
4189
|
-
pending: null,
|
|
4190
|
-
});
|
|
4191
|
-
await discardGeneratedPending();
|
|
4192
|
-
return;
|
|
4193
|
-
}
|
|
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);
|
|
4203
|
-
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
4204
|
-
console.warn('group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
|
|
4205
|
-
this._scheduleGroupRotationRetry(groupId, {
|
|
4206
|
-
reason: 'membership_changed',
|
|
4207
|
-
triggerId,
|
|
4208
|
-
expectedEpoch,
|
|
4209
|
-
pending: isJsonObject(commitResult) && isJsonObject(commitResult.rotation) ? commitResult.rotation : rotation,
|
|
4210
|
-
});
|
|
4211
|
-
const returnedRotation = isJsonObject(commitResult) && isJsonObject(commitResult.rotation) ? commitResult.rotation : null;
|
|
4212
|
-
if (!(returnedRotation
|
|
4213
|
-
&& String(returnedRotation.rotation_id ?? '') === activeRotationId
|
|
4214
|
-
&& String(returnedRotation.status ?? '') === 'distributing')) {
|
|
4546
|
+
await this._heartbeatGroupRotation(activeRotationId);
|
|
4547
|
+
if (!await this._ackGroupRotationKey(activeRotationId, String(info.commitment ?? ''))) {
|
|
4548
|
+
this._clientLog.warn(`group epoch self ack failed; abort rotation before retry (group=%s rotation=%s)${groupId} ${activeRotationId}`);
|
|
4549
|
+
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
4550
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
4551
|
+
reason: 'membership_changed',
|
|
4552
|
+
triggerId,
|
|
4553
|
+
expectedEpoch,
|
|
4554
|
+
pending: null,
|
|
4555
|
+
});
|
|
4215
4556
|
await discardGeneratedPending();
|
|
4557
|
+
return;
|
|
4216
4558
|
}
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
if (this._groupSecretMatchesCommittedRotation(committedSecret, committedRotation)) {
|
|
4225
|
-
await storeGroupSecret(this._keystore, this._aid, groupId, targetEpoch, committedSecret.secret, committedSecret.commitment, committedSecret.member_aids.length > 0 ? committedSecret.member_aids : memberAids, committedSecret.epoch_chain);
|
|
4559
|
+
const commitParams = { rotation_id: activeRotationId };
|
|
4560
|
+
// 构建 per-member ECIES 加密的 epoch key 上传到服务端
|
|
4561
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
4562
|
+
const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
|
|
4563
|
+
if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
|
|
4564
|
+
commitParams.encrypted_keys = encryptedKeys;
|
|
4565
|
+
}
|
|
4226
4566
|
}
|
|
4227
|
-
|
|
4228
|
-
|
|
4567
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
|
|
4568
|
+
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
4569
|
+
this._clientLog.warn('group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
|
|
4570
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
4571
|
+
reason: 'membership_changed',
|
|
4572
|
+
triggerId,
|
|
4573
|
+
expectedEpoch,
|
|
4574
|
+
pending: isJsonObject(commitResult) && isJsonObject(commitResult.rotation) ? commitResult.rotation : rotation,
|
|
4575
|
+
});
|
|
4576
|
+
const returnedRotation = isJsonObject(commitResult) && isJsonObject(commitResult.rotation) ? commitResult.rotation : null;
|
|
4577
|
+
if (!(returnedRotation
|
|
4578
|
+
&& String(returnedRotation.rotation_id ?? '') === activeRotationId
|
|
4579
|
+
&& String(returnedRotation.status ?? '') === 'distributing')) {
|
|
4580
|
+
await discardGeneratedPending();
|
|
4581
|
+
}
|
|
4582
|
+
return;
|
|
4229
4583
|
}
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4584
|
+
const committedSecret = await this._groupE2ee.loadSecret(groupId, targetEpoch);
|
|
4585
|
+
if (committedSecret && this._aid) {
|
|
4586
|
+
const committedRotation = isJsonObject(commitResult.rotation)
|
|
4587
|
+
? commitResult.rotation
|
|
4588
|
+
: { rotation_id: activeRotationId, key_commitment: String(info.commitment ?? '') };
|
|
4589
|
+
if (this._groupSecretMatchesCommittedRotation(committedSecret, committedRotation)) {
|
|
4590
|
+
await storeGroupSecret(this._keystore, this._aid, groupId, targetEpoch, committedSecret.secret, committedSecret.commitment, committedSecret.member_aids.length > 0 ? committedSecret.member_aids : memberAids, committedSecret.epoch_chain);
|
|
4591
|
+
}
|
|
4592
|
+
else {
|
|
4593
|
+
this._clientLog.warn(`group epoch commit succeeded but local target key does not match committed rotation; keep pending blocked (group=%s rotation=%s epoch=%d)${groupId} ${activeRotationId} ${targetEpoch}`);
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
if (triggerId) {
|
|
4597
|
+
this._groupMembershipRotationDone.add(triggerId);
|
|
4598
|
+
if (this._groupMembershipRotationDone.size > 2000) {
|
|
4599
|
+
this._groupMembershipRotationDone = new Set(Array.from(this._groupMembershipRotationDone).slice(-1000));
|
|
4600
|
+
}
|
|
4235
4601
|
}
|
|
4236
4602
|
}
|
|
4603
|
+
catch (exc) {
|
|
4604
|
+
this._logE2eeError('rotate_epoch', groupId, '', exc);
|
|
4605
|
+
}
|
|
4606
|
+
this._clientLog.debug(`_rotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
4237
4607
|
}
|
|
4238
|
-
catch (
|
|
4239
|
-
this.
|
|
4608
|
+
catch (err) {
|
|
4609
|
+
this._clientLog.debug(`_rotateGroupEpoch exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
4610
|
+
throw err;
|
|
4240
4611
|
}
|
|
4241
4612
|
}
|
|
4242
4613
|
/** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
|
|
@@ -4416,73 +4787,91 @@ export class AUNClient {
|
|
|
4416
4787
|
}
|
|
4417
4788
|
// ── 内部:连接 ────────────────────────────────────
|
|
4418
4789
|
async _connectOnce(params, allowReauth) {
|
|
4419
|
-
const
|
|
4420
|
-
this.
|
|
4421
|
-
this._slotId = String(params.slot_id ?? '');
|
|
4422
|
-
this._connectDeliveryMode = { ...(params.delivery_mode ?? this._connectDeliveryMode) };
|
|
4423
|
-
this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
|
|
4424
|
-
this._state = 'connecting';
|
|
4425
|
-
// 前置 restore:在 _transport.connect 启动 reader 之前完成,
|
|
4426
|
-
// 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
|
|
4427
|
-
this._refreshSeqTrackerContext();
|
|
4428
|
-
await this._restoreSeqTrackerState();
|
|
4790
|
+
const tStart = Date.now();
|
|
4791
|
+
this._clientLog.debug(`_connectOnce enter: allow_reauth=${allowReauth}`);
|
|
4429
4792
|
try {
|
|
4430
|
-
const
|
|
4431
|
-
this.
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4793
|
+
const gatewayUrl = this._resolveGateway(params);
|
|
4794
|
+
this._gatewayUrl = gatewayUrl;
|
|
4795
|
+
this._slotId = String(params.slot_id ?? '');
|
|
4796
|
+
this._connectDeliveryMode = { ...(params.delivery_mode ?? this._connectDeliveryMode) };
|
|
4797
|
+
this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
|
|
4798
|
+
this._state = 'connecting';
|
|
4799
|
+
// 前置 restore:在 _transport.connect 启动 reader 之前完成,
|
|
4800
|
+
// 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
|
|
4801
|
+
this._refreshSeqTrackerContext();
|
|
4802
|
+
await this._restoreSeqTrackerState();
|
|
4803
|
+
try {
|
|
4804
|
+
const challenge = await this._transport.connect(gatewayUrl);
|
|
4805
|
+
this._state = 'authenticating';
|
|
4806
|
+
if (allowReauth) {
|
|
4807
|
+
const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
|
|
4808
|
+
accessToken: params.access_token,
|
|
4809
|
+
deviceId: this._deviceId,
|
|
4810
|
+
slotId: this._slotId,
|
|
4811
|
+
deliveryMode: this._connectDeliveryMode,
|
|
4812
|
+
connectionKind: String(params.connection_kind ?? 'long'),
|
|
4813
|
+
shortTtlMs: Number(params.short_ttl_ms ?? 0),
|
|
4814
|
+
extraInfo: params.extra_info,
|
|
4815
|
+
});
|
|
4816
|
+
if (isJsonObject(authContext)) {
|
|
4817
|
+
const auth = authContext;
|
|
4818
|
+
const identity = auth.identity;
|
|
4819
|
+
if (identity && isJsonObject(identity)) {
|
|
4820
|
+
this._identity = identity;
|
|
4821
|
+
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
4822
|
+
if (this._sessionParams) {
|
|
4823
|
+
this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
|
|
4824
|
+
}
|
|
4447
4825
|
}
|
|
4448
4826
|
}
|
|
4449
4827
|
}
|
|
4828
|
+
else {
|
|
4829
|
+
await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
|
|
4830
|
+
deviceId: this._deviceId,
|
|
4831
|
+
slotId: this._slotId,
|
|
4832
|
+
deliveryMode: this._connectDeliveryMode,
|
|
4833
|
+
connectionKind: String(params.connection_kind ?? 'long'),
|
|
4834
|
+
shortTtlMs: Number(params.short_ttl_ms ?? 0),
|
|
4835
|
+
extraInfo: params.extra_info,
|
|
4836
|
+
});
|
|
4837
|
+
await this._syncIdentityAfterConnect(String(params.access_token));
|
|
4838
|
+
}
|
|
4450
4839
|
}
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
}
|
|
4457
|
-
|
|
4840
|
+
catch (err) {
|
|
4841
|
+
// P1-19: 首连失败时重置状态,避免半连接残留
|
|
4842
|
+
this._state = 'disconnected';
|
|
4843
|
+
try {
|
|
4844
|
+
await this._transport.close();
|
|
4845
|
+
}
|
|
4846
|
+
catch { /* 忽略关闭错误 */ }
|
|
4847
|
+
throw err;
|
|
4458
4848
|
}
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4849
|
+
this._state = 'connected';
|
|
4850
|
+
await this._dispatcher.publish('connection.state', {
|
|
4851
|
+
state: this._state,
|
|
4852
|
+
gateway: gatewayUrl,
|
|
4853
|
+
});
|
|
4854
|
+
// auth 阶段 aid 可能被 identity 覆盖;若 context 发生变化,重新 refresh + restore。
|
|
4855
|
+
if (this._seqTrackerContext !== this._currentSeqTrackerContext()) {
|
|
4856
|
+
this._refreshSeqTrackerContext();
|
|
4857
|
+
await this._restoreSeqTrackerState();
|
|
4858
|
+
}
|
|
4859
|
+
this._startBackgroundTasks();
|
|
4860
|
+
// 上线后自动上传 prekey
|
|
4463
4861
|
try {
|
|
4464
|
-
await this.
|
|
4862
|
+
await this._uploadPrekey();
|
|
4465
4863
|
}
|
|
4466
|
-
catch {
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
});
|
|
4474
|
-
// auth 阶段 aid 可能被 identity 覆盖;若 context 发生变化,重新 refresh + restore。
|
|
4475
|
-
if (this._seqTrackerContext !== this._currentSeqTrackerContext()) {
|
|
4476
|
-
this._refreshSeqTrackerContext();
|
|
4477
|
-
await this._restoreSeqTrackerState();
|
|
4478
|
-
}
|
|
4479
|
-
this._startBackgroundTasks();
|
|
4480
|
-
// 上线后自动上传 prekey
|
|
4481
|
-
try {
|
|
4482
|
-
await this._uploadPrekey();
|
|
4864
|
+
catch (exc) {
|
|
4865
|
+
this._clientLog.warn(`prekey upload failed:${String(exc)}`);
|
|
4866
|
+
}
|
|
4867
|
+
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
4868
|
+
// 群消息按惰性触发,不在此处主动 pull
|
|
4869
|
+
this._safeAsync(this._fillP2pGap());
|
|
4870
|
+
this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? '-'}`);
|
|
4483
4871
|
}
|
|
4484
|
-
catch (
|
|
4485
|
-
|
|
4872
|
+
catch (err) {
|
|
4873
|
+
this._clientLog.debug(`_connectOnce exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
4874
|
+
throw err;
|
|
4486
4875
|
}
|
|
4487
4876
|
}
|
|
4488
4877
|
_resolveGateway(params) {
|
|
@@ -4563,15 +4952,42 @@ export class AUNClient {
|
|
|
4563
4952
|
if (request.timeouts !== undefined && !isJsonObject(request.timeouts)) {
|
|
4564
4953
|
throw new ValidationError('timeouts must be an object');
|
|
4565
4954
|
}
|
|
4955
|
+
// 长短连接选项:默认 long,向后兼容
|
|
4956
|
+
const kindRaw = request.connection_kind;
|
|
4957
|
+
if (kindRaw == null) {
|
|
4958
|
+
request.connection_kind = 'long';
|
|
4959
|
+
}
|
|
4960
|
+
else {
|
|
4961
|
+
request.connection_kind = String(kindRaw).trim().toLowerCase();
|
|
4962
|
+
}
|
|
4963
|
+
if (request.connection_kind !== 'long' && request.connection_kind !== 'short') {
|
|
4964
|
+
throw new ValidationError("connection_kind must be 'long' or 'short'");
|
|
4965
|
+
}
|
|
4966
|
+
try {
|
|
4967
|
+
request.short_ttl_ms = Math.max(0, Math.floor(Number(request.short_ttl_ms) || 0));
|
|
4968
|
+
}
|
|
4969
|
+
catch {
|
|
4970
|
+
throw new ValidationError('short_ttl_ms must be a non-negative integer');
|
|
4971
|
+
}
|
|
4972
|
+
if (request.connection_kind !== 'short') {
|
|
4973
|
+
request.short_ttl_ms = 0;
|
|
4974
|
+
}
|
|
4566
4975
|
return request;
|
|
4567
4976
|
}
|
|
4568
4977
|
_buildSessionOptions(params) {
|
|
4978
|
+
const connectionKind = String(params.connection_kind ?? 'long');
|
|
4979
|
+
// 短连接默认禁用 auto_reconnect:短连接生命周期短,自动重连无意义
|
|
4980
|
+
const defaultAutoReconnect = connectionKind === 'short'
|
|
4981
|
+
? false
|
|
4982
|
+
: DEFAULT_SESSION_OPTIONS.auto_reconnect;
|
|
4569
4983
|
const options = {
|
|
4570
|
-
auto_reconnect:
|
|
4984
|
+
auto_reconnect: defaultAutoReconnect,
|
|
4571
4985
|
heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
|
|
4572
4986
|
token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
|
|
4573
4987
|
retry: { ...DEFAULT_SESSION_OPTIONS.retry },
|
|
4574
4988
|
timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
|
|
4989
|
+
connection_kind: connectionKind,
|
|
4990
|
+
short_ttl_ms: Number(params.short_ttl_ms ?? 0),
|
|
4575
4991
|
};
|
|
4576
4992
|
if ('auto_reconnect' in params) {
|
|
4577
4993
|
options.auto_reconnect = Boolean(params.auto_reconnect);
|
|
@@ -4592,6 +5008,10 @@ export class AUNClient {
|
|
|
4592
5008
|
}
|
|
4593
5009
|
// ── 内部:后台任务 ────────────────────────────────
|
|
4594
5010
|
_startBackgroundTasks() {
|
|
5011
|
+
// 短连接生命周期短,禁用心跳与 token 刷新(不接收推送、不需要长期会话维护)
|
|
5012
|
+
if (this._sessionOptions?.connection_kind === 'short') {
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
4595
5015
|
this._startHeartbeat();
|
|
4596
5016
|
this._startTokenRefresh();
|
|
4597
5017
|
this._startPrekeyRefresh();
|
|
@@ -4650,10 +5070,10 @@ export class AUNClient {
|
|
|
4650
5070
|
}
|
|
4651
5071
|
catch (exc) {
|
|
4652
5072
|
consecutiveFailures++;
|
|
4653
|
-
|
|
5073
|
+
this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${String(exc)}`);
|
|
4654
5074
|
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
4655
5075
|
if (consecutiveFailures >= maxFailures) {
|
|
4656
|
-
|
|
5076
|
+
this._clientLog.warn(`consecutive ${maxFailures} heartbeat failed, trigger disconnect reconnect`);
|
|
4657
5077
|
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
4658
5078
|
}
|
|
4659
5079
|
}
|
|
@@ -4712,7 +5132,7 @@ export class AUNClient {
|
|
|
4712
5132
|
if (exc instanceof AuthError) {
|
|
4713
5133
|
this._tokenRefreshFailures++;
|
|
4714
5134
|
if (this._tokenRefreshFailures >= 3) {
|
|
4715
|
-
|
|
5135
|
+
this._clientLog.warn(`token refreshconsecutivefailed ${this._tokenRefreshFailures} , stop refresh loop and trigger reconnect`);
|
|
4716
5136
|
this._dispatcher.publish('token.refresh_exhausted', {
|
|
4717
5137
|
aid: this._identity?.aid ?? null,
|
|
4718
5138
|
consecutive_failures: this._tokenRefreshFailures,
|
|
@@ -4722,7 +5142,7 @@ export class AUNClient {
|
|
|
4722
5142
|
this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
|
|
4723
5143
|
return;
|
|
4724
5144
|
}
|
|
4725
|
-
|
|
5145
|
+
this._clientLog.warn(`token refresh failed (${this._tokenRefreshFailures}/3), next retry: ${String(exc)}`);
|
|
4726
5146
|
}
|
|
4727
5147
|
else {
|
|
4728
5148
|
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
@@ -4770,7 +5190,7 @@ export class AUNClient {
|
|
|
4770
5190
|
}
|
|
4771
5191
|
}
|
|
4772
5192
|
catch (exc) {
|
|
4773
|
-
|
|
5193
|
+
this._clientLog.warn(`prekey scheduled refresh failed:${String(exc)}`);
|
|
4774
5194
|
}
|
|
4775
5195
|
// 仍处于连接状态时安排下一次检查
|
|
4776
5196
|
if (this._state === 'connected') {
|
|
@@ -4857,22 +5277,17 @@ export class AUNClient {
|
|
|
4857
5277
|
const prekeyId = this._extractConsumedPrekeyId(message);
|
|
4858
5278
|
if (!prekeyId || this._state !== 'connected')
|
|
4859
5279
|
return;
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
// 同一时刻只允许一个 put_prekey inflight
|
|
4863
|
-
if (this._prekeyReplenishInflight.size > 0)
|
|
5280
|
+
// 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
|
|
5281
|
+
if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
|
|
4864
5282
|
return;
|
|
4865
|
-
|
|
5283
|
+
// 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
|
|
5284
|
+
this._activePrekeyId = '';
|
|
4866
5285
|
this._safeAsync((async () => {
|
|
4867
5286
|
try {
|
|
4868
5287
|
await this._uploadPrekey();
|
|
4869
|
-
this._prekeyReplenished.add(prekeyId);
|
|
4870
5288
|
}
|
|
4871
5289
|
catch (exc) {
|
|
4872
|
-
|
|
4873
|
-
}
|
|
4874
|
-
finally {
|
|
4875
|
-
this._prekeyReplenishInflight.delete(prekeyId);
|
|
5290
|
+
this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${String(exc)}`);
|
|
4876
5291
|
}
|
|
4877
5292
|
})());
|
|
4878
5293
|
}
|
|
@@ -4894,7 +5309,7 @@ export class AUNClient {
|
|
|
4894
5309
|
}
|
|
4895
5310
|
}
|
|
4896
5311
|
catch (exc) {
|
|
4897
|
-
|
|
5312
|
+
this._clientLog.warn(`epoch cleanup failed:${String(exc)}`);
|
|
4898
5313
|
}
|
|
4899
5314
|
}, 3600 * 1000);
|
|
4900
5315
|
}
|
|
@@ -4913,7 +5328,7 @@ export class AUNClient {
|
|
|
4913
5328
|
}
|
|
4914
5329
|
}
|
|
4915
5330
|
catch (exc) {
|
|
4916
|
-
|
|
5331
|
+
this._clientLog.warn(`epoch scheduled rotation failed:${String(exc)}`);
|
|
4917
5332
|
}
|
|
4918
5333
|
}, rotateInterval * 1000);
|
|
4919
5334
|
}
|
|
@@ -4941,13 +5356,28 @@ export class AUNClient {
|
|
|
4941
5356
|
}
|
|
4942
5357
|
// ── 内部:断线重连 ────────────────────────────────
|
|
4943
5358
|
/** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
|
|
4944
|
-
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011]);
|
|
4945
|
-
/** 处理服务端主动断开通知 event/gateway.disconnect
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
5359
|
+
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
|
|
5360
|
+
/** 处理服务端主动断开通知 event/gateway.disconnect
|
|
5361
|
+
*
|
|
5362
|
+
* 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
|
|
5363
|
+
* 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
|
|
5364
|
+
*/
|
|
5365
|
+
async _onGatewayDisconnect(data) {
|
|
5366
|
+
const obj = (data && typeof data === 'object') ? data : {};
|
|
5367
|
+
const code = obj.code;
|
|
5368
|
+
const reason = obj.reason ?? '';
|
|
5369
|
+
const detail = (obj.detail && typeof obj.detail === 'object') ? obj.detail : {};
|
|
5370
|
+
this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
|
|
4950
5371
|
this._serverKicked = true;
|
|
5372
|
+
// 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
|
|
5373
|
+
this._lastDisconnectInfo = { code, reason, detail };
|
|
5374
|
+
// 透传给应用层订阅者
|
|
5375
|
+
try {
|
|
5376
|
+
await this._dispatcher.publish('gateway.disconnect', { code, reason, detail });
|
|
5377
|
+
}
|
|
5378
|
+
catch (exc) {
|
|
5379
|
+
this._clientLog.debug(`publish gateway.disconnect failed: ${exc?.message ?? exc}`);
|
|
5380
|
+
}
|
|
4951
5381
|
}
|
|
4952
5382
|
async _handleTransportDisconnect(error, closeCode) {
|
|
4953
5383
|
if (this._closing || this._state === 'closed')
|
|
@@ -4967,10 +5397,20 @@ export class AUNClient {
|
|
|
4967
5397
|
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
4968
5398
|
this._state = 'terminal_failed';
|
|
4969
5399
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
4970
|
-
|
|
4971
|
-
|
|
5400
|
+
this._clientLog.warn(`suppress auto-reconnect: ${reason}`);
|
|
5401
|
+
const disconnectInfo = this._lastDisconnectInfo ?? {};
|
|
5402
|
+
const eventPayload = {
|
|
4972
5403
|
state: this._state, error, reason,
|
|
4973
|
-
}
|
|
5404
|
+
};
|
|
5405
|
+
// 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
|
|
5406
|
+
const detail = disconnectInfo.detail;
|
|
5407
|
+
if (detail && typeof detail === 'object' && Object.keys(detail).length > 0) {
|
|
5408
|
+
eventPayload.detail = detail;
|
|
5409
|
+
}
|
|
5410
|
+
if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
|
|
5411
|
+
eventPayload.code = disconnectInfo.code;
|
|
5412
|
+
}
|
|
5413
|
+
await this._dispatcher.publish('connection.state', eventPayload);
|
|
4974
5414
|
return;
|
|
4975
5415
|
}
|
|
4976
5416
|
// 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭
|
|
@@ -5057,62 +5497,80 @@ export class AUNClient {
|
|
|
5057
5497
|
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5058
5498
|
*/
|
|
5059
5499
|
async createNamedGroup(groupName, opts = {}) {
|
|
5060
|
-
const
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5500
|
+
const tStart = Date.now();
|
|
5501
|
+
this._clientLog.debug(`createNamedGroup enter: name=${groupName}`);
|
|
5502
|
+
try {
|
|
5503
|
+
const cp = new CryptoProvider();
|
|
5504
|
+
const identity = await cp.generateIdentity();
|
|
5505
|
+
const params = {};
|
|
5506
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
5507
|
+
params[k] = v;
|
|
5508
|
+
}
|
|
5509
|
+
params.group_name = groupName;
|
|
5510
|
+
params.public_key = identity.public_key_der_b64;
|
|
5511
|
+
params.curve = 'P-256';
|
|
5512
|
+
const result = await this.call('group.create', params);
|
|
5513
|
+
const groupInfo = result?.group;
|
|
5514
|
+
const aidCert = result?.aid_cert;
|
|
5515
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5516
|
+
if (groupAid && aidCert) {
|
|
5517
|
+
await this._keystore.saveIdentity(groupAid, {
|
|
5518
|
+
private_key_pem: identity.private_key_pem,
|
|
5519
|
+
public_key: identity.public_key_der_b64,
|
|
5520
|
+
curve: 'P-256',
|
|
5521
|
+
type: 'group_identity',
|
|
5522
|
+
});
|
|
5523
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5524
|
+
if (certPem) {
|
|
5525
|
+
await this._keystore.saveCert(groupAid, certPem);
|
|
5526
|
+
}
|
|
5083
5527
|
}
|
|
5528
|
+
this._clientLog.debug(`createNamedGroup exit: elapsed=${Date.now() - tStart}ms group_aid=${groupAid}`);
|
|
5529
|
+
return result;
|
|
5530
|
+
}
|
|
5531
|
+
catch (err) {
|
|
5532
|
+
this._clientLog.debug(`createNamedGroup exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
5533
|
+
throw err;
|
|
5084
5534
|
}
|
|
5085
|
-
return result;
|
|
5086
5535
|
}
|
|
5087
5536
|
/**
|
|
5088
5537
|
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5089
5538
|
*/
|
|
5090
5539
|
async bindGroupAid(groupId, groupName) {
|
|
5091
|
-
const
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
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,
|
|
5540
|
+
const tStart = Date.now();
|
|
5541
|
+
this._clientLog.debug(`bindGroupAid enter: group_id=${groupId} name=${groupName}`);
|
|
5542
|
+
try {
|
|
5543
|
+
const cp = new CryptoProvider();
|
|
5544
|
+
const identity = await cp.generateIdentity();
|
|
5545
|
+
const params = {
|
|
5546
|
+
group_id: groupId,
|
|
5547
|
+
group_name: groupName,
|
|
5106
5548
|
public_key: identity.public_key_der_b64,
|
|
5107
5549
|
curve: 'P-256',
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
const
|
|
5111
|
-
|
|
5112
|
-
|
|
5550
|
+
};
|
|
5551
|
+
const result = await this.call('group.bind_aid', params);
|
|
5552
|
+
const groupInfo = result?.group;
|
|
5553
|
+
const aidCert = result?.aid_cert;
|
|
5554
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5555
|
+
if (groupAid && aidCert) {
|
|
5556
|
+
await this._keystore.saveIdentity(groupAid, {
|
|
5557
|
+
private_key_pem: identity.private_key_pem,
|
|
5558
|
+
public_key: identity.public_key_der_b64,
|
|
5559
|
+
curve: 'P-256',
|
|
5560
|
+
type: 'group_identity',
|
|
5561
|
+
});
|
|
5562
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5563
|
+
if (certPem) {
|
|
5564
|
+
await this._keystore.saveCert(groupAid, certPem);
|
|
5565
|
+
}
|
|
5113
5566
|
}
|
|
5567
|
+
this._clientLog.debug(`bindGroupAid exit: elapsed=${Date.now() - tStart}ms group_aid=${groupAid}`);
|
|
5568
|
+
return result;
|
|
5569
|
+
}
|
|
5570
|
+
catch (err) {
|
|
5571
|
+
this._clientLog.debug(`bindGroupAid exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
5572
|
+
throw err;
|
|
5114
5573
|
}
|
|
5115
|
-
return result;
|
|
5116
5574
|
}
|
|
5117
5575
|
/** 判断是否应重试重连 */
|
|
5118
5576
|
_shouldRetryReconnect(error) {
|
|
@@ -5338,7 +5796,7 @@ export class AUNClient {
|
|
|
5338
5796
|
/** 安全执行异步操作(不阻塞调用方,错误打 warning 便于排障) */
|
|
5339
5797
|
_safeAsync(promise) {
|
|
5340
5798
|
promise.catch((exc) => {
|
|
5341
|
-
|
|
5799
|
+
this._clientLog.warn(`background task exception:${String(exc)}`);
|
|
5342
5800
|
});
|
|
5343
5801
|
}
|
|
5344
5802
|
/** 可取消的 sleep */
|