@agentunion/fastaun 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 +6 -0
- package/dist/auth.js +375 -212
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +14 -1
- package/dist/client.js +1255 -729
- package/dist/client.js.map +1 -1
- package/dist/discovery.d.ts +3 -0
- package/dist/discovery.js +29 -2
- package/dist/discovery.js.map +1 -1
- package/dist/e2ee-group.d.ts +2 -1
- package/dist/e2ee-group.js +207 -56
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.js +45 -11
- package/dist/e2ee.js.map +1 -1
- package/dist/events.js +1 -1
- package/dist/events.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +13 -1
- package/dist/keystore/aid-db.js +31 -3
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +6 -0
- package/dist/keystore/file.js +20 -9
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +2 -0
- package/dist/keystore/sqlite-backup.js +5 -5
- package/dist/keystore/sqlite-backup.js.map +1 -1
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +69 -13
- package/dist/logger.js.map +1 -1
- package/dist/namespaces/auth.d.ts +13 -4
- package/dist/namespaces/auth.js +354 -150
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/namespaces/custody.d.ts +1 -0
- package/dist/namespaces/custody.js +138 -56
- package/dist/namespaces/custody.js.map +1 -1
- package/dist/namespaces/meta.d.ts +1 -0
- package/dist/namespaces/meta.js +26 -0
- package/dist/namespaces/meta.js.map +1 -1
- package/dist/secret-store/file-store.js +3 -2
- package/dist/secret-store/file-store.js.map +1 -1
- package/dist/transport.js +83 -2
- package/dist/transport.js.map +1 -1
- package/package.json +42 -42
package/dist/client.js
CHANGED
|
@@ -344,6 +344,8 @@ export class AUNClient {
|
|
|
344
344
|
_peerPrekeysCache = new Map();
|
|
345
345
|
_prekeyReplenishInflight = new Set();
|
|
346
346
|
_prekeyReplenished = new Set();
|
|
347
|
+
// 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
|
|
348
|
+
_activePrekeyId = '';
|
|
347
349
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
348
350
|
_seqTracker = new SeqTracker();
|
|
349
351
|
_seqTrackerContext = null;
|
|
@@ -375,11 +377,14 @@ export class AUNClient {
|
|
|
375
377
|
_reconnectActive = false;
|
|
376
378
|
_reconnectAbort = null;
|
|
377
379
|
_serverKicked = false;
|
|
380
|
+
/** 缓存最近一次 gateway.disconnect 信息(含服务端附带的 detail),用于后续 connection.state 透传 */
|
|
381
|
+
_lastDisconnectInfo = null;
|
|
378
382
|
_logger;
|
|
379
383
|
_clientLog;
|
|
380
384
|
constructor(config, debug = false) {
|
|
381
385
|
const rawConfig = { ...(config ?? {}) };
|
|
382
386
|
this._configModel = configFromMap(rawConfig);
|
|
387
|
+
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
383
388
|
this.config = {
|
|
384
389
|
aun_path: this._configModel.aunPath,
|
|
385
390
|
root_ca_path: this._configModel.rootCaPath,
|
|
@@ -393,7 +398,7 @@ export class AUNClient {
|
|
|
393
398
|
});
|
|
394
399
|
this._clientLog = this._logger.for('aun_core.client');
|
|
395
400
|
if (debugFlag) {
|
|
396
|
-
this._clientLog.info(`AUNClient
|
|
401
|
+
this._clientLog.info(`AUNClient initialized (debug=true, aunPath=${this._configModel.aunPath})`);
|
|
397
402
|
}
|
|
398
403
|
this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
|
|
399
404
|
this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl });
|
|
@@ -412,13 +417,14 @@ export class AUNClient {
|
|
|
412
417
|
this._auth = new AuthFlow({
|
|
413
418
|
keystore,
|
|
414
419
|
crypto: new CryptoProvider(),
|
|
415
|
-
aid:
|
|
420
|
+
aid: initAid,
|
|
416
421
|
deviceId: this._deviceId,
|
|
417
422
|
slotId: this._slotId,
|
|
418
423
|
rootCaPath: this._configModel.rootCaPath ?? undefined,
|
|
419
424
|
verifySsl: this._configModel.verifySsl,
|
|
420
425
|
logger: this._logger.for('aun_core.auth'),
|
|
421
426
|
});
|
|
427
|
+
this._aid = initAid;
|
|
422
428
|
this._transport = new RPCTransport({
|
|
423
429
|
eventDispatcher: this._dispatcher,
|
|
424
430
|
timeout: 10_000,
|
|
@@ -481,7 +487,17 @@ export class AUNClient {
|
|
|
481
487
|
}
|
|
482
488
|
/** 向 gatewayUrl 的 /health 端点发送 GET 请求,检查网关可用性 */
|
|
483
489
|
async checkGatewayHealth(gatewayUrl, timeout = 5_000) {
|
|
484
|
-
|
|
490
|
+
const tStart = Date.now();
|
|
491
|
+
this._clientLog.debug(`checkGatewayHealth enter: gatewayUrl=${gatewayUrl}`);
|
|
492
|
+
try {
|
|
493
|
+
const result = await this._discovery.checkHealth(gatewayUrl, timeout);
|
|
494
|
+
this._clientLog.debug(`checkGatewayHealth exit: elapsed=${Date.now() - tStart}ms healthy=${result}`);
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
this._clientLog.debug(`checkGatewayHealth exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
485
501
|
}
|
|
486
502
|
// ── 生命周期 ──────────────────────────────────────────────
|
|
487
503
|
/**
|
|
@@ -491,6 +507,7 @@ export class AUNClient {
|
|
|
491
507
|
* @param options - 会话选项(auto_reconnect、heartbeat_interval 等)
|
|
492
508
|
*/
|
|
493
509
|
async connect(auth, options) {
|
|
510
|
+
const tStart = Date.now();
|
|
494
511
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
495
512
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
496
513
|
}
|
|
@@ -504,79 +521,117 @@ export class AUNClient {
|
|
|
504
521
|
const callTimeoutSec = this._sessionOptions.timeouts.call;
|
|
505
522
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
|
|
506
523
|
this._closing = false;
|
|
524
|
+
this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
|
|
507
525
|
try {
|
|
508
526
|
await this._connectOnce(normalized, false);
|
|
527
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
509
528
|
}
|
|
510
529
|
catch (err) {
|
|
511
530
|
// 连接失败时回退状态,允许重试
|
|
512
531
|
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
513
532
|
this._state = 'disconnected';
|
|
514
533
|
}
|
|
534
|
+
this._clientLog.error(`connect failed: ${formatCaughtError(err)}`, err instanceof Error ? err : undefined);
|
|
535
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
515
536
|
throw err;
|
|
516
537
|
}
|
|
517
538
|
}
|
|
518
539
|
/** 关闭连接 */
|
|
519
540
|
async close() {
|
|
520
|
-
|
|
521
|
-
this.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
541
|
+
const tStart = Date.now();
|
|
542
|
+
this._clientLog.debug(`close enter: state=${this._state}, aid=${this._aid ?? ''}`);
|
|
543
|
+
try {
|
|
544
|
+
this._closing = true;
|
|
545
|
+
this._saveSeqTrackerState();
|
|
546
|
+
this._stopBackgroundTasks();
|
|
547
|
+
this._stopReconnect();
|
|
548
|
+
if (this._state === 'idle' || this._state === 'closed') {
|
|
549
|
+
const closableKeyStore = this._keystore;
|
|
550
|
+
closableKeyStore.close?.();
|
|
551
|
+
this._state = 'closed';
|
|
552
|
+
this._logger.close();
|
|
553
|
+
this._resetSeqTrackingState();
|
|
554
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms (was idle/closed)`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
await this._transport.close();
|
|
525
558
|
const closableKeyStore = this._keystore;
|
|
526
559
|
closableKeyStore.close?.();
|
|
527
560
|
this._state = 'closed';
|
|
528
561
|
this._logger.close();
|
|
562
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
529
563
|
this._resetSeqTrackingState();
|
|
530
|
-
|
|
564
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms`);
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
this._clientLog.debug(`close exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
568
|
+
throw err;
|
|
531
569
|
}
|
|
532
|
-
await this._transport.close();
|
|
533
|
-
const closableKeyStore = this._keystore;
|
|
534
|
-
closableKeyStore.close?.();
|
|
535
|
-
this._state = 'closed';
|
|
536
|
-
this._logger.close();
|
|
537
|
-
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
538
|
-
this._resetSeqTrackingState();
|
|
539
570
|
}
|
|
540
571
|
/**
|
|
541
572
|
* 断开连接但不关闭客户端(可重新 connect,对齐 Python disconnect)。
|
|
542
573
|
* disconnect 是可恢复的:停止心跳、关闭 WebSocket,但不清理 keystore 等状态。
|
|
543
574
|
*/
|
|
544
575
|
async disconnect() {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
576
|
+
const tStart = Date.now();
|
|
577
|
+
this._clientLog.debug(`disconnect enter: state=${this._state}, aid=${this._aid ?? ''}, closing=${this._closing}`);
|
|
578
|
+
try {
|
|
579
|
+
// 若 close() 已在执行中,跳过 disconnect 避免竞态
|
|
580
|
+
if (this._closing) {
|
|
581
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms (closing)`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (this._state !== 'connected' && this._state !== 'reconnecting') {
|
|
585
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms (state=${this._state})`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
this._saveSeqTrackerState();
|
|
589
|
+
this._stopBackgroundTasks();
|
|
590
|
+
this._stopReconnect();
|
|
591
|
+
await this._transport.close();
|
|
592
|
+
this._state = 'disconnected';
|
|
593
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
594
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms`);
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
this._clientLog.debug(`disconnect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
556
600
|
}
|
|
557
601
|
/**
|
|
558
602
|
* 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID)。
|
|
559
603
|
*/
|
|
560
604
|
listIdentities() {
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
605
|
+
const tStart = Date.now();
|
|
606
|
+
this._clientLog.debug(`listIdentities enter`);
|
|
607
|
+
try {
|
|
608
|
+
const listFn = this._keystore.listIdentities;
|
|
609
|
+
if (typeof listFn !== 'function') {
|
|
610
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms (no_list_fn)`);
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
613
|
+
const aids = listFn.call(this._keystore);
|
|
614
|
+
const summaries = [];
|
|
615
|
+
for (const aid of [...aids].sort()) {
|
|
616
|
+
const identity = this._keystore.loadIdentity(aid);
|
|
617
|
+
if (!identity || !identity.private_key_pem)
|
|
618
|
+
continue;
|
|
619
|
+
const summary = { aid };
|
|
620
|
+
const loadMetadata = this._keystore.loadMetadata;
|
|
621
|
+
if (typeof loadMetadata === 'function') {
|
|
622
|
+
const md = loadMetadata.call(this._keystore, aid);
|
|
623
|
+
if (md)
|
|
624
|
+
summary.metadata = md;
|
|
625
|
+
}
|
|
626
|
+
summaries.push(summary);
|
|
576
627
|
}
|
|
577
|
-
|
|
628
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
|
|
629
|
+
return summaries;
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
this._clientLog.debug(`listIdentities exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
633
|
+
throw err;
|
|
578
634
|
}
|
|
579
|
-
return summaries;
|
|
580
635
|
}
|
|
581
636
|
// ── RPC ───────────────────────────────────────────────────
|
|
582
637
|
/**
|
|
@@ -584,214 +639,278 @@ export class AUNClient {
|
|
|
584
639
|
* 自动处理内部方法限制、E2EE 加解密、客户端签名等。
|
|
585
640
|
*/
|
|
586
641
|
async call(method, params) {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
p.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (encrypt) {
|
|
613
|
-
return await this._sendEncrypted(p);
|
|
614
|
-
}
|
|
615
|
-
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
616
|
-
}
|
|
617
|
-
// 自动加密:group.send 默认加密(encrypt 默认 True)
|
|
618
|
-
if (method === 'group.send') {
|
|
619
|
-
const encrypt = p.encrypt ?? true;
|
|
620
|
-
delete p.encrypt;
|
|
621
|
-
if (encrypt) {
|
|
622
|
-
return await this._sendGroupEncrypted(p);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
if (method === 'group.thought.put') {
|
|
626
|
-
const encrypt = p.encrypt ?? true;
|
|
627
|
-
delete p.encrypt;
|
|
628
|
-
if (encrypt) {
|
|
629
|
-
return await this._putGroupThoughtEncrypted(p);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
if (method === 'message.thought.put') {
|
|
633
|
-
const encrypt = p.encrypt ?? true;
|
|
634
|
-
delete p.encrypt;
|
|
635
|
-
if (encrypt) {
|
|
636
|
-
return await this._putMessageThoughtEncrypted(p);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
// 关键操作自动附加客户端签名
|
|
640
|
-
if (SIGNED_METHODS.has(method)) {
|
|
641
|
-
this._signClientOperation(method, p);
|
|
642
|
-
}
|
|
643
|
-
// P1-23: 非幂等方法使用更长超时
|
|
644
|
-
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
645
|
-
let result = callTimeout
|
|
646
|
-
? await this._transport.call(method, p, callTimeout)
|
|
647
|
-
: await this._transport.call(method, p);
|
|
648
|
-
// 自动解密:message.pull 返回的消息
|
|
649
|
-
if (method === 'message.pull' && isJsonObject(result)) {
|
|
650
|
-
const r = result;
|
|
651
|
-
const messages = r.messages;
|
|
652
|
-
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
653
|
-
if (rawMessages.length > 0) {
|
|
654
|
-
r.messages = await this._decryptMessages(rawMessages);
|
|
655
|
-
}
|
|
656
|
-
if (this._aid) {
|
|
657
|
-
const ns = `p2p:${this._aid}`;
|
|
658
|
-
if (rawMessages.length > 0) {
|
|
659
|
-
this._seqTracker.onPullResult(ns, rawMessages);
|
|
660
|
-
}
|
|
661
|
-
// ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
|
|
662
|
-
// 服务端在持久化/设备视图分支返回 server_ack_seq,客户端若 contiguous 落后必须 force 跳过
|
|
663
|
-
// retention window 外的空洞。与 S2 [1,seq-1] 历史 gap 配合;若去掉 force,首条消息建的 gap 会
|
|
664
|
-
// 永远悬挂触发无限 pull。临时消息淘汰走 ephemeral_earliest_available_seq(当前仅提示),与此互斥。
|
|
665
|
-
const serverAck = Number(r.server_ack_seq ?? 0);
|
|
666
|
-
if (serverAck > 0) {
|
|
667
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
668
|
-
if (contig < serverAck) {
|
|
669
|
-
this._clientLog.info(`message.pull retention-floor 推进: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAck}`);
|
|
670
|
-
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
671
|
-
}
|
|
642
|
+
const tStart = Date.now();
|
|
643
|
+
this._clientLog.debug(`call enter: method=${method}`);
|
|
644
|
+
try {
|
|
645
|
+
if (this._state !== 'connected') {
|
|
646
|
+
throw new ConnectionError('client is not connected');
|
|
647
|
+
}
|
|
648
|
+
if (INTERNAL_ONLY_METHODS.has(method)) {
|
|
649
|
+
throw new PermissionError(`method is internal_only: ${method}`);
|
|
650
|
+
}
|
|
651
|
+
const p = { ...(params ?? {}) };
|
|
652
|
+
this._validateOutboundCall(method, p);
|
|
653
|
+
this._injectMessageCursorContext(method, p);
|
|
654
|
+
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
655
|
+
if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
|
|
656
|
+
p.device_id = this._deviceId;
|
|
657
|
+
}
|
|
658
|
+
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
659
|
+
p.slot_id = this._slotId;
|
|
660
|
+
}
|
|
661
|
+
// 自动加密:message.send 默认加密(encrypt 默认 True)
|
|
662
|
+
if (method === 'message.send') {
|
|
663
|
+
const encrypt = p.encrypt ?? true;
|
|
664
|
+
delete p.encrypt;
|
|
665
|
+
if (encrypt) {
|
|
666
|
+
return await this._sendEncrypted(p);
|
|
672
667
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}).catch((e) => { this._clientLog.debug(`message.pull auto-ack 失败: ${formatCaughtError(e)}`); });
|
|
668
|
+
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
669
|
+
}
|
|
670
|
+
// 自动加密:group.send 默认加密(encrypt 默认 True)
|
|
671
|
+
if (method === 'group.send') {
|
|
672
|
+
const encrypt = p.encrypt ?? true;
|
|
673
|
+
delete p.encrypt;
|
|
674
|
+
if (encrypt) {
|
|
675
|
+
return await this._sendGroupEncrypted(p);
|
|
682
676
|
}
|
|
683
677
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (rawMessages.length > 0) {
|
|
691
|
-
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
678
|
+
if (method === 'group.thought.put') {
|
|
679
|
+
const encrypt = p.encrypt ?? true;
|
|
680
|
+
delete p.encrypt;
|
|
681
|
+
if (encrypt) {
|
|
682
|
+
return await this._putGroupThoughtEncrypted(p);
|
|
683
|
+
}
|
|
692
684
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
685
|
+
if (method === 'message.thought.put') {
|
|
686
|
+
const encrypt = p.encrypt ?? true;
|
|
687
|
+
delete p.encrypt;
|
|
688
|
+
if (encrypt) {
|
|
689
|
+
return await this._putMessageThoughtEncrypted(p);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// 关键操作自动附加客户端签名
|
|
693
|
+
if (SIGNED_METHODS.has(method)) {
|
|
694
|
+
this._signClientOperation(method, p);
|
|
695
|
+
}
|
|
696
|
+
// P1-23: 非幂等方法使用更长超时
|
|
697
|
+
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
698
|
+
let result = callTimeout
|
|
699
|
+
? await this._transport.call(method, p, callTimeout)
|
|
700
|
+
: await this._transport.call(method, p);
|
|
701
|
+
// 自动解密:message.pull 返回的消息
|
|
702
|
+
if (method === 'message.pull' && isJsonObject(result)) {
|
|
703
|
+
const r = result;
|
|
704
|
+
const messages = r.messages;
|
|
705
|
+
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
706
|
+
this._clientLog.debug(`message.pull result: ${rawMessages.length} messages`);
|
|
696
707
|
if (rawMessages.length > 0) {
|
|
697
|
-
this.
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
708
|
+
r.messages = await this._decryptMessages(rawMessages);
|
|
709
|
+
}
|
|
710
|
+
if (this._aid) {
|
|
711
|
+
const ns = `p2p:${this._aid}`;
|
|
712
|
+
if (rawMessages.length > 0) {
|
|
713
|
+
this._seqTracker.onPullResult(ns, rawMessages);
|
|
714
|
+
}
|
|
715
|
+
// ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
|
|
716
|
+
// 服务端在持久化/设备视图分支返回 server_ack_seq,客户端若 contiguous 落后必须 force 跳过
|
|
717
|
+
// retention window 外的空洞。与 S2 [1,seq-1] 历史 gap 配合;若去掉 force,首条消息建的 gap 会
|
|
718
|
+
// 永远悬挂触发无限 pull。临时消息淘汰走 ephemeral_earliest_available_seq(当前仅提示),与此互斥。
|
|
719
|
+
const serverAck = Number(r.server_ack_seq ?? 0);
|
|
705
720
|
if (serverAck > 0) {
|
|
706
721
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
707
722
|
if (contig < serverAck) {
|
|
708
|
-
this._clientLog.info(`
|
|
723
|
+
this._clientLog.info(`message.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAck}`);
|
|
709
724
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
710
725
|
}
|
|
711
726
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
slot_id: this._slotId,
|
|
723
|
-
}).catch((e) => { this._clientLog.debug(`group.pull auto-ack 失败: group=${gid} ${formatCaughtError(e)}`); });
|
|
727
|
+
this._saveSeqTrackerState();
|
|
728
|
+
// auto-ack contiguous_seq
|
|
729
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
730
|
+
if (contig > 0 && (rawMessages.length > 0 || serverAck > 0)) {
|
|
731
|
+
this._transport.call('message.ack', {
|
|
732
|
+
seq: contig,
|
|
733
|
+
device_id: this._deviceId,
|
|
734
|
+
slot_id: this._slotId,
|
|
735
|
+
}).catch((e) => { this._clientLog.debug(`message.pull auto-ack failed: ${formatCaughtError(e)}`); });
|
|
736
|
+
}
|
|
724
737
|
}
|
|
725
738
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
739
|
+
// 自动解密:group.pull 返回的群消息
|
|
740
|
+
if (method === 'group.pull' && isJsonObject(result)) {
|
|
741
|
+
const r = result;
|
|
742
|
+
const messages = r.messages;
|
|
743
|
+
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
744
|
+
this._clientLog.debug(`group.pull result: group_id=${String(p.group_id ?? '')}, ${rawMessages.length} messages`);
|
|
745
|
+
if (rawMessages.length > 0) {
|
|
746
|
+
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
747
|
+
}
|
|
748
|
+
const gid = (p.group_id ?? '');
|
|
749
|
+
if (gid) {
|
|
750
|
+
const ns = `group:${gid}`;
|
|
751
|
+
// 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
|
|
752
|
+
const decryptedOnly = [];
|
|
753
|
+
let failedCount = 0;
|
|
754
|
+
const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
|
|
755
|
+
for (const m of decryptedMessages) {
|
|
756
|
+
if (!isJsonObject(m))
|
|
757
|
+
continue;
|
|
758
|
+
const payload = isJsonObject(m.payload) ? m.payload : {};
|
|
759
|
+
const ptype = payload.type;
|
|
760
|
+
if (ptype === 'e2ee.group_encrypted') {
|
|
761
|
+
failedCount++;
|
|
762
|
+
this._enqueuePendingDecrypt(gid, m);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
decryptedOnly.push(m);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (decryptedOnly.length > 0) {
|
|
769
|
+
// 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
|
|
770
|
+
this._seqTracker.onPullResult(ns, decryptedOnly);
|
|
745
771
|
}
|
|
746
|
-
|
|
747
|
-
|
|
772
|
+
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
773
|
+
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
774
|
+
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
775
|
+
if (cursor) {
|
|
776
|
+
const serverAck = Number(cursor.current_seq ?? 0);
|
|
777
|
+
if (serverAck > 0) {
|
|
778
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
779
|
+
if (contig < serverAck) {
|
|
780
|
+
this._clientLog.info(`group.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAck}`);
|
|
781
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
this._saveSeqTrackerState();
|
|
786
|
+
// auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
|
|
787
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
788
|
+
const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
|
|
789
|
+
if (contig > 0 && shouldAck) {
|
|
790
|
+
this._transport.call('group.ack_messages', {
|
|
791
|
+
group_id: gid,
|
|
792
|
+
msg_seq: contig,
|
|
793
|
+
device_id: this._deviceId,
|
|
794
|
+
slot_id: this._slotId,
|
|
795
|
+
}).catch((e) => { this._clientLog.debug(`group.pull auto-ack failed: group=${gid} ${formatCaughtError(e)}`); });
|
|
796
|
+
}
|
|
797
|
+
// 有解密失败时调度 recovery 兜底定时
|
|
798
|
+
if (failedCount > 0) {
|
|
799
|
+
this._scheduleRecoveryTimeout(gid);
|
|
748
800
|
}
|
|
749
801
|
}
|
|
750
802
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
803
|
+
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
804
|
+
result = await this._decryptGroupThoughts(result);
|
|
805
|
+
}
|
|
806
|
+
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
807
|
+
result = await this._decryptMessageThoughts(result);
|
|
808
|
+
}
|
|
809
|
+
// ── Group E2EE 自动编排(必备能力,始终启用)────────
|
|
810
|
+
{
|
|
811
|
+
// 建群后自动创建 epoch(幂等:已有 secret 时跳过)
|
|
812
|
+
if (method === 'group.create' && isJsonObject(result)) {
|
|
813
|
+
const group = isJsonObject(result.group) ? result.group : null;
|
|
814
|
+
const gid = group ? String(group.group_id ?? '') : '';
|
|
815
|
+
if (gid && this._aid && !this._groupE2ee.hasSecret(gid)) {
|
|
816
|
+
try {
|
|
817
|
+
this._groupE2ee.createEpoch(gid, [this._aid]);
|
|
818
|
+
// 同步到服务端:将服务端 epoch 从 0 推到 1;必须在 group.create 返回前完成,
|
|
819
|
+
// 否则调用方紧接着加成员时会让初始 rotation 因成员集变化而提交失败。
|
|
820
|
+
await this._syncEpochToServer(gid);
|
|
821
|
+
}
|
|
822
|
+
catch (exc) {
|
|
823
|
+
this._logE2eeError('create_epoch', gid, '', exc);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// 入群类 RPC 的成员集变更统一由 group.changed 事件驱动 epoch 轮换。
|
|
828
|
+
}
|
|
829
|
+
// 成员集变更主要由 group.changed 事件驱动;RPC 成功返回路径做幂等兜底,避免事件丢失或延迟时不轮换。
|
|
830
|
+
const membershipMethods = new Set([
|
|
831
|
+
'group.add_member', 'group.kick', 'group.remove_member', 'group.leave',
|
|
832
|
+
'group.review_join_request', 'group.batch_review_join_request',
|
|
833
|
+
'group.use_invite_code', 'group.request_join',
|
|
834
|
+
]);
|
|
835
|
+
if (membershipMethods.has(method) && isJsonObject(result) && !('error' in result)) {
|
|
836
|
+
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
837
|
+
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
838
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
839
|
+
// 自加入方法(request_join/use_invite_code)需要 allowMember=true,
|
|
840
|
+
// 因为新成员角色是 member,必须允许 member 参与 leader 选举。
|
|
841
|
+
const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
|
|
842
|
+
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
843
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
|
|
844
|
+
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
845
|
+
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => this._clientLog.warn(`membership RPC epoch rotation fallback failed: ${formatCaughtError(exc)}`));
|
|
846
|
+
}
|
|
770
847
|
}
|
|
848
|
+
this._clientLog.debug(`call exit: method=${method} elapsed=${Date.now() - tStart}ms`);
|
|
849
|
+
return result;
|
|
850
|
+
}
|
|
851
|
+
catch (err) {
|
|
852
|
+
this._clientLog.debug(`call exit (error): method=${method} elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
853
|
+
throw err;
|
|
771
854
|
}
|
|
772
|
-
return result;
|
|
773
855
|
}
|
|
774
856
|
// ── 便利方法 ──────────────────────────────────────────────
|
|
775
857
|
/** 心跳检测 */
|
|
776
858
|
async ping(params) {
|
|
777
|
-
|
|
859
|
+
const tStart = Date.now();
|
|
860
|
+
this._clientLog.debug(`ping enter`);
|
|
861
|
+
try {
|
|
862
|
+
const result = await this.call('meta.ping', params ?? {});
|
|
863
|
+
this._clientLog.debug(`ping exit: elapsed=${Date.now() - tStart}ms`);
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
this._clientLog.debug(`ping exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
868
|
+
throw err;
|
|
869
|
+
}
|
|
778
870
|
}
|
|
779
871
|
/** 获取服务端状态 */
|
|
780
872
|
async status(params) {
|
|
781
|
-
|
|
873
|
+
const tStart = Date.now();
|
|
874
|
+
this._clientLog.debug(`status enter`);
|
|
875
|
+
try {
|
|
876
|
+
const result = await this.call('meta.status', params ?? {});
|
|
877
|
+
this._clientLog.debug(`status exit: elapsed=${Date.now() - tStart}ms`);
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
this._clientLog.debug(`status exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
882
|
+
throw err;
|
|
883
|
+
}
|
|
782
884
|
}
|
|
783
885
|
/** 获取信任根证书列表 */
|
|
784
886
|
async trustRoots(params) {
|
|
785
|
-
|
|
887
|
+
const tStart = Date.now();
|
|
888
|
+
this._clientLog.debug(`trustRoots enter`);
|
|
889
|
+
try {
|
|
890
|
+
const result = await this.call('meta.trust_roots', params ?? {});
|
|
891
|
+
this._clientLog.debug(`trustRoots exit: elapsed=${Date.now() - tStart}ms`);
|
|
892
|
+
return result;
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
this._clientLog.debug(`trustRoots exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
896
|
+
throw err;
|
|
897
|
+
}
|
|
786
898
|
}
|
|
787
899
|
// ── 事件 ──────────────────────────────────────────────────
|
|
788
900
|
/** 订阅事件 */
|
|
789
901
|
on(event, handler) {
|
|
790
|
-
|
|
902
|
+
const tStart = Date.now();
|
|
903
|
+
this._clientLog.debug(`on enter: event=${event}`);
|
|
904
|
+
const result = this._dispatcher.subscribe(event, handler);
|
|
905
|
+
this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
906
|
+
return result;
|
|
791
907
|
}
|
|
792
908
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
793
909
|
off(event, handler) {
|
|
910
|
+
const tStart = Date.now();
|
|
911
|
+
this._clientLog.debug(`off enter: event=${event}`);
|
|
794
912
|
this._dispatcher.unsubscribe(event, handler);
|
|
913
|
+
this._clientLog.debug(`off exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
795
914
|
}
|
|
796
915
|
// ── E2EE 加密发送 ────────────────────────────────────────
|
|
797
916
|
_protectedHeadersFromParams(params) {
|
|
@@ -807,6 +926,7 @@ export class AUNClient {
|
|
|
807
926
|
}
|
|
808
927
|
/** 自动加密并发送 P2P 消息 */
|
|
809
928
|
async _sendEncrypted(params) {
|
|
929
|
+
const tStart = Date.now();
|
|
810
930
|
const toAid = String(params.to ?? '');
|
|
811
931
|
this._validateMessageRecipient(toAid);
|
|
812
932
|
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
@@ -817,6 +937,7 @@ export class AUNClient {
|
|
|
817
937
|
}
|
|
818
938
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
819
939
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
940
|
+
this._clientLog.debug(`_sendEncrypted enter: to=${toAid}, message_id=${messageId}`);
|
|
820
941
|
// 惰性同步:首次发送 P2P 消息时先 pull 一次
|
|
821
942
|
if (!this._p2pSynced) {
|
|
822
943
|
await this._lazySyncP2p();
|
|
@@ -839,8 +960,10 @@ export class AUNClient {
|
|
|
839
960
|
const did = String(pk.device_id ?? '').trim();
|
|
840
961
|
return did && did !== PREKEY_FALLBACK_DEVICE_ID;
|
|
841
962
|
});
|
|
842
|
-
|
|
843
|
-
|
|
963
|
+
// 只要有 routable prekey 就走 multi_device 路径(即使只有 1 个 recipient device + 0 self copies)。
|
|
964
|
+
// 这确保服务端为每个已注册设备存储副本,离线设备重连后能 pull 到。
|
|
965
|
+
// single 路径仅在完全没有 routable prekey 时使用(legacy 兼容)。
|
|
966
|
+
const canUseMultiDevice = routablePrekeys.length > 0;
|
|
844
967
|
if (!canUseMultiDevice) {
|
|
845
968
|
return await this._sendEncryptedSingle({
|
|
846
969
|
toAid,
|
|
@@ -880,18 +1003,33 @@ export class AUNClient {
|
|
|
880
1003
|
};
|
|
881
1004
|
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
882
1005
|
try {
|
|
883
|
-
|
|
1006
|
+
const result = await sendAttempt(false);
|
|
1007
|
+
this._clientLog.debug(`_sendEncrypted exit: elapsed=${Date.now() - tStart}ms to=${toAid}, message_id=${messageId}`);
|
|
1008
|
+
return result;
|
|
884
1009
|
}
|
|
885
1010
|
catch (exc) {
|
|
886
|
-
if (!isRetryablePeerMaterialError(exc))
|
|
1011
|
+
if (!isRetryablePeerMaterialError(exc)) {
|
|
1012
|
+
this._clientLog.error(`message.send failed: to=${toAid}, message_id=${messageId}, err=${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
1013
|
+
this._clientLog.debug(`_sendEncrypted exit (error): elapsed=${Date.now() - tStart}ms to=${toAid} err=${exc instanceof Error ? exc.message : String(exc)}`);
|
|
887
1014
|
throw exc;
|
|
1015
|
+
}
|
|
888
1016
|
this._clientLog.warn(`peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
889
1017
|
}
|
|
890
|
-
|
|
1018
|
+
try {
|
|
1019
|
+
const retryResult = await sendAttempt(true);
|
|
1020
|
+
this._clientLog.debug(`_sendEncrypted exit: elapsed=${Date.now() - tStart}ms (retry success) to=${toAid}, message_id=${messageId}`);
|
|
1021
|
+
return retryResult;
|
|
1022
|
+
}
|
|
1023
|
+
catch (exc) {
|
|
1024
|
+
this._clientLog.debug(`_sendEncrypted exit (error): elapsed=${Date.now() - tStart}ms (retry failed) to=${toAid} err=${exc instanceof Error ? exc.message : String(exc)}`);
|
|
1025
|
+
throw exc;
|
|
1026
|
+
}
|
|
891
1027
|
}
|
|
892
1028
|
async _sendEncryptedSingle(opts) {
|
|
1029
|
+
this._clientLog.debug(`_sendEncryptedSingle enter: to=${opts.toAid}, message_id=${opts.messageId}, has_prekey=${!!opts.prekey}, persist_required=${!!opts.persistRequired}`);
|
|
893
1030
|
let prekey = opts.prekey ?? null;
|
|
894
1031
|
if (!prekey) {
|
|
1032
|
+
this._clientLog.debug(`_sendEncryptedSingle fetching peer prekey: to=${opts.toAid}`);
|
|
895
1033
|
prekey = await this._fetchPeerPrekey(opts.toAid);
|
|
896
1034
|
}
|
|
897
1035
|
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
@@ -906,6 +1044,7 @@ export class AUNClient {
|
|
|
906
1044
|
protectedHeaders: opts.protectedHeaders,
|
|
907
1045
|
});
|
|
908
1046
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1047
|
+
this._clientLog.debug(`_sendEncryptedSingle envelope built: to=${opts.toAid}, message_id=${opts.messageId}, scheme=${String(envelope?.scheme ?? '')}`);
|
|
909
1048
|
const sendParams = {
|
|
910
1049
|
to: opts.toAid,
|
|
911
1050
|
payload: envelope,
|
|
@@ -920,6 +1059,7 @@ export class AUNClient {
|
|
|
920
1059
|
return await this._transport.call('message.send', sendParams);
|
|
921
1060
|
}
|
|
922
1061
|
async _buildRecipientDeviceCopies(opts) {
|
|
1062
|
+
this._clientLog.debug(`_buildRecipientDeviceCopies enter: to=${opts.toAid}, message_id=${opts.messageId}, prekey_count=${opts.prekeys.length}`);
|
|
923
1063
|
const recipientCopies = [];
|
|
924
1064
|
const certCache = new Map();
|
|
925
1065
|
for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
|
|
@@ -949,6 +1089,7 @@ export class AUNClient {
|
|
|
949
1089
|
if (recipientCopies.length === 0) {
|
|
950
1090
|
throw new E2EEError(`no recipient device copies generated for ${opts.toAid}`);
|
|
951
1091
|
}
|
|
1092
|
+
this._clientLog.debug(`_buildRecipientDeviceCopies built: to=${opts.toAid}, message_id=${opts.messageId}, copies=${recipientCopies.length}`);
|
|
952
1093
|
return recipientCopies;
|
|
953
1094
|
}
|
|
954
1095
|
async _resolveSelfCopyPeerCert(certFingerprint) {
|
|
@@ -997,7 +1138,7 @@ export class AUNClient {
|
|
|
997
1138
|
}
|
|
998
1139
|
catch (e) {
|
|
999
1140
|
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
1000
|
-
this._clientLog.warn(`self-sync
|
|
1141
|
+
this._clientLog.warn(`self-sync skip device ${deviceId}: cert parse failed (${e}), possibly old prekey`);
|
|
1001
1142
|
continue;
|
|
1002
1143
|
}
|
|
1003
1144
|
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
@@ -1035,16 +1176,27 @@ export class AUNClient {
|
|
|
1035
1176
|
mode: encryptResult.mode,
|
|
1036
1177
|
reason: encryptResult.degradation_reason,
|
|
1037
1178
|
}).catch((exc) => {
|
|
1038
|
-
this._clientLog.warn(
|
|
1179
|
+
this._clientLog.warn(`failed to publish e2ee.degraded event: ${formatCaughtError(exc)}`);
|
|
1039
1180
|
});
|
|
1040
1181
|
}
|
|
1041
1182
|
}
|
|
1042
1183
|
/** 自动加密并发送群组消息 */
|
|
1043
1184
|
async _sendGroupEncrypted(params) {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1185
|
+
const tStart = Date.now();
|
|
1186
|
+
const groupId = String(params.group_id ?? '');
|
|
1187
|
+
this._clientLog.debug(`_sendGroupEncrypted enter: group_id=${groupId}`);
|
|
1188
|
+
try {
|
|
1189
|
+
const result = await this._callGroupEncryptedRpc('group.send', params, {
|
|
1190
|
+
idField: 'message_id',
|
|
1191
|
+
idPrefix: 'gm',
|
|
1192
|
+
});
|
|
1193
|
+
this._clientLog.debug(`_sendGroupEncrypted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
1194
|
+
return result;
|
|
1195
|
+
}
|
|
1196
|
+
catch (err) {
|
|
1197
|
+
this._clientLog.debug(`_sendGroupEncrypted exit (error): elapsed=${Date.now() - tStart}ms group_id=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1198
|
+
throw err;
|
|
1199
|
+
}
|
|
1048
1200
|
}
|
|
1049
1201
|
async _putGroupThoughtEncrypted(params) {
|
|
1050
1202
|
return await this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
@@ -1096,14 +1248,17 @@ export class AUNClient {
|
|
|
1096
1248
|
let { sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options);
|
|
1097
1249
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1098
1250
|
try {
|
|
1099
|
-
|
|
1251
|
+
const result = await this._transport.call(method, sendParams);
|
|
1252
|
+
this._clientLog.debug(`${method} send success: group_id=${groupId}`);
|
|
1253
|
+
return result;
|
|
1100
1254
|
}
|
|
1101
1255
|
catch (exc) {
|
|
1102
1256
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
1103
|
-
this._clientLog.warn(
|
|
1257
|
+
this._clientLog.warn(`group ${groupId} ${method} epoch stale, recovering key and retrying: ${formatCaughtError(exc)}`);
|
|
1104
1258
|
({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
|
|
1105
1259
|
continue;
|
|
1106
1260
|
}
|
|
1261
|
+
this._clientLog.error(`${method} send failed: group_id=${groupId}, err=${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
1107
1262
|
throw exc;
|
|
1108
1263
|
}
|
|
1109
1264
|
}
|
|
@@ -1118,6 +1273,7 @@ export class AUNClient {
|
|
|
1118
1273
|
if (payload === null) {
|
|
1119
1274
|
throw new ValidationError(`${method} payload must be an object when encrypt=true`);
|
|
1120
1275
|
}
|
|
1276
|
+
this._clientLog.debug(`${method} encrypt prepare: group_id=${groupId}, strictEpochReady=${String(strictEpochReady)}`);
|
|
1121
1277
|
if (!this._groupSynced.has(groupId)) {
|
|
1122
1278
|
await this._lazySyncGroup(groupId);
|
|
1123
1279
|
}
|
|
@@ -1186,11 +1342,11 @@ export class AUNClient {
|
|
|
1186
1342
|
}
|
|
1187
1343
|
if (messages.length > 0) {
|
|
1188
1344
|
this._saveSeqTrackerState();
|
|
1189
|
-
this._clientLog.info(
|
|
1345
|
+
this._clientLog.info(`lazy sync group ${groupId}: pull ${messages.length} messages, after_seq=${afterSeq}`);
|
|
1190
1346
|
}
|
|
1191
1347
|
}
|
|
1192
1348
|
catch (exc) {
|
|
1193
|
-
this._clientLog.warn(
|
|
1349
|
+
this._clientLog.warn(`lazy sync group ${groupId} failed: ${formatCaughtError(exc)}`);
|
|
1194
1350
|
}
|
|
1195
1351
|
}
|
|
1196
1352
|
/** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
|
|
@@ -1213,11 +1369,11 @@ export class AUNClient {
|
|
|
1213
1369
|
}
|
|
1214
1370
|
if (messages.length > 0) {
|
|
1215
1371
|
this._saveSeqTrackerState();
|
|
1216
|
-
this._clientLog.info(
|
|
1372
|
+
this._clientLog.info(`lazy sync P2P: pull ${messages.length} messages, after_seq=${afterSeq}`);
|
|
1217
1373
|
}
|
|
1218
1374
|
}
|
|
1219
1375
|
catch (exc) {
|
|
1220
|
-
this._clientLog.warn(
|
|
1376
|
+
this._clientLog.warn(`lazy sync P2P failed: ${formatCaughtError(exc)}`);
|
|
1221
1377
|
}
|
|
1222
1378
|
}
|
|
1223
1379
|
_isGroupEpochTooOldError(exc) {
|
|
@@ -1273,10 +1429,10 @@ export class AUNClient {
|
|
|
1273
1429
|
encrypt: true,
|
|
1274
1430
|
persist_required: true,
|
|
1275
1431
|
});
|
|
1276
|
-
this._clientLog.info(
|
|
1432
|
+
this._clientLog.info(`requested group ${groupId} epoch ${epoch} key from ${targetAid}`);
|
|
1277
1433
|
}
|
|
1278
1434
|
catch (exc) {
|
|
1279
|
-
this._clientLog.warn(
|
|
1435
|
+
this._clientLog.warn(`requesting group ${groupId} key from ${targetAid} failed: ${formatCaughtError(exc)}`);
|
|
1280
1436
|
}
|
|
1281
1437
|
}
|
|
1282
1438
|
async _requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult) {
|
|
@@ -1291,7 +1447,7 @@ export class AUNClient {
|
|
|
1291
1447
|
const secretData = this._groupE2ee.loadSecret(groupId, 1);
|
|
1292
1448
|
if (!secretData || secretData.pending_rotation_id)
|
|
1293
1449
|
return epochResult;
|
|
1294
|
-
this._clientLog.warn(
|
|
1450
|
+
this._clientLog.warn(`group ${groupId} detected local epoch 1 exists but server epoch still 0, attempting initial epoch resync`);
|
|
1295
1451
|
await this._syncEpochToServer(groupId);
|
|
1296
1452
|
try {
|
|
1297
1453
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -1299,7 +1455,7 @@ export class AUNClient {
|
|
|
1299
1455
|
return refreshed;
|
|
1300
1456
|
}
|
|
1301
1457
|
catch (exc) {
|
|
1302
|
-
this._clientLog.warn(
|
|
1458
|
+
this._clientLog.warn(`group ${groupId} initial epoch resync refresh server epoch failed: ${formatCaughtError(exc)}`);
|
|
1303
1459
|
}
|
|
1304
1460
|
return epochResult;
|
|
1305
1461
|
}
|
|
@@ -1388,7 +1544,7 @@ export class AUNClient {
|
|
|
1388
1544
|
members = isJsonObject(membersResult) ? membersResult.members : null;
|
|
1389
1545
|
}
|
|
1390
1546
|
catch (exc) {
|
|
1391
|
-
this._clientLog.debug(
|
|
1547
|
+
this._clientLog.debug(`group ${groupId} member epoch floor pre-check skipped: ${formatCaughtError(exc)}`);
|
|
1392
1548
|
return;
|
|
1393
1549
|
}
|
|
1394
1550
|
let maxMinReadEpoch = 0;
|
|
@@ -1403,7 +1559,7 @@ export class AUNClient {
|
|
|
1403
1559
|
}
|
|
1404
1560
|
if (maxMinReadEpoch <= committedEpoch)
|
|
1405
1561
|
return;
|
|
1406
|
-
this._clientLog.warn(
|
|
1562
|
+
this._clientLog.warn(`group ${groupId} member min_read_epoch higher than committed epoch, continuing with committed epoch: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
|
|
1407
1563
|
return;
|
|
1408
1564
|
}
|
|
1409
1565
|
}
|
|
@@ -1414,7 +1570,7 @@ export class AUNClient {
|
|
|
1414
1570
|
return epochResult;
|
|
1415
1571
|
}
|
|
1416
1572
|
catch (exc) {
|
|
1417
|
-
this._clientLog.warn(
|
|
1573
|
+
this._clientLog.warn(`group ${groupId} query committed epoch status failed, falling back to local epoch: ${formatCaughtError(exc)}`);
|
|
1418
1574
|
}
|
|
1419
1575
|
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1420
1576
|
return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
|
|
@@ -1442,7 +1598,7 @@ export class AUNClient {
|
|
|
1442
1598
|
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
1443
1599
|
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1444
1600
|
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
1445
|
-
this._clientLog.warn(
|
|
1601
|
+
this._clientLog.warn(`group ${groupId} committed epoch ${committedEpoch} member snapshot inconsistent with current members, triggering membership change rotation`);
|
|
1446
1602
|
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
1447
1603
|
const refreshed = await this._committedGroupEpochState(groupId);
|
|
1448
1604
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1459,7 +1615,7 @@ export class AUNClient {
|
|
|
1459
1615
|
return committedEpoch;
|
|
1460
1616
|
}
|
|
1461
1617
|
const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
|
|
1462
|
-
this._clientLog.warn(
|
|
1618
|
+
this._clientLog.warn(`group ${groupId} epoch ${committedEpoch} local pending key does not match server committed rotation, recovering key first: local_rotation=${pendingRotationId || '-'}`);
|
|
1463
1619
|
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
1464
1620
|
let refreshed = await this._committedGroupEpochState(groupId);
|
|
1465
1621
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1504,13 +1660,13 @@ export class AUNClient {
|
|
|
1504
1660
|
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
1505
1661
|
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
1506
1662
|
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
1507
|
-
this._clientLog.info(
|
|
1663
|
+
this._clientLog.info(`group ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
|
|
1508
1664
|
return true;
|
|
1509
1665
|
}
|
|
1510
1666
|
return false;
|
|
1511
1667
|
}
|
|
1512
1668
|
catch (exc) {
|
|
1513
|
-
this._clientLog.debug(
|
|
1669
|
+
this._clientLog.debug(`query current members failed, cannot determine committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
1514
1670
|
return false;
|
|
1515
1671
|
}
|
|
1516
1672
|
}
|
|
@@ -1560,9 +1716,16 @@ export class AUNClient {
|
|
|
1560
1716
|
// ── 事件自动解密管线 ──────────────────────────────────────
|
|
1561
1717
|
/** 处理 transport 层推送的原始 P2P 消息 */
|
|
1562
1718
|
async _onRawMessageReceived(data) {
|
|
1719
|
+
const tStart = Date.now();
|
|
1720
|
+
if (isJsonObject(data)) {
|
|
1721
|
+
this._clientLog.debug(`_onRawMessageReceived enter: from=${String(data.from ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
this._clientLog.debug(`_onRawMessageReceived enter: non-object payload`);
|
|
1725
|
+
}
|
|
1563
1726
|
// 异步处理,不阻塞事件调度
|
|
1564
1727
|
this._processAndPublishMessage(data).catch((exc) => {
|
|
1565
|
-
this._clientLog.warn(`P2P
|
|
1728
|
+
this._clientLog.warn(`P2P message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1566
1729
|
// H26: 不再投递原始密文 payload;改发 message.undecryptable 事件,仅携带安全 header
|
|
1567
1730
|
if (isJsonObject(data)) {
|
|
1568
1731
|
const safeEvent = {
|
|
@@ -1576,6 +1739,7 @@ export class AUNClient {
|
|
|
1576
1739
|
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1577
1740
|
}
|
|
1578
1741
|
});
|
|
1742
|
+
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1579
1743
|
}
|
|
1580
1744
|
/** 实际处理推送消息的异步任务 */
|
|
1581
1745
|
async _processAndPublishMessage(data) {
|
|
@@ -1589,6 +1753,22 @@ export class AUNClient {
|
|
|
1589
1753
|
}
|
|
1590
1754
|
// 拦截 P2P 传输的群组密钥分发/请求/响应消息
|
|
1591
1755
|
if (await this._tryHandleGroupKeyMessage(msg)) {
|
|
1756
|
+
// group_key 控制消息也要推进 seq tracker + auto-ack,
|
|
1757
|
+
// 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
|
|
1758
|
+
const seq = msg.seq;
|
|
1759
|
+
if (seq !== undefined && seq !== null && this._aid) {
|
|
1760
|
+
const ns = `p2p:${this._aid}`;
|
|
1761
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
1762
|
+
this._saveSeqTrackerState();
|
|
1763
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1764
|
+
if (contig > 0) {
|
|
1765
|
+
this._transport.call('message.ack', {
|
|
1766
|
+
seq: contig,
|
|
1767
|
+
device_id: this._deviceId,
|
|
1768
|
+
slot_id: this._slotId,
|
|
1769
|
+
}).catch(() => { });
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1592
1772
|
return;
|
|
1593
1773
|
}
|
|
1594
1774
|
// P2P 空洞检测
|
|
@@ -1598,7 +1778,8 @@ export class AUNClient {
|
|
|
1598
1778
|
const ns = `p2p:${this._aid}`;
|
|
1599
1779
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1600
1780
|
if (needPull) {
|
|
1601
|
-
this.
|
|
1781
|
+
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
1782
|
+
this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1602
1783
|
}
|
|
1603
1784
|
// auto-ack contiguous_seq
|
|
1604
1785
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -1607,12 +1788,13 @@ export class AUNClient {
|
|
|
1607
1788
|
seq: contig,
|
|
1608
1789
|
device_id: this._deviceId,
|
|
1609
1790
|
slot_id: this._slotId,
|
|
1610
|
-
}).catch((e) => { this._clientLog.debug(`P2P auto-ack
|
|
1791
|
+
}).catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1611
1792
|
}
|
|
1612
1793
|
// 即时持久化 cursor,异常断连后不回退
|
|
1613
1794
|
this._saveSeqTrackerState();
|
|
1614
1795
|
}
|
|
1615
1796
|
const decrypted = await this._decryptSingleMessage(msg);
|
|
1797
|
+
this._clientLog.debug(`P2P message decrypt done: from=${String(msg.from ?? '')}, mid=${String(msg.message_id ?? '')}, seq=${String(seq ?? '')}`);
|
|
1616
1798
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1617
1799
|
const ns = `p2p:${this._aid}`;
|
|
1618
1800
|
await this._publishOrderedMessage('message.received', ns, seq, decrypted);
|
|
@@ -1623,8 +1805,15 @@ export class AUNClient {
|
|
|
1623
1805
|
}
|
|
1624
1806
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1625
1807
|
async _onRawGroupMessageCreated(data) {
|
|
1808
|
+
const tStart = Date.now();
|
|
1809
|
+
if (isJsonObject(data)) {
|
|
1810
|
+
this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${String(data.group_id ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
this._clientLog.debug(`_onRawGroupMessageCreated enter: non-object payload`);
|
|
1814
|
+
}
|
|
1626
1815
|
this._processAndPublishGroupMessage(data).catch((exc) => {
|
|
1627
|
-
this._clientLog.warn(
|
|
1816
|
+
this._clientLog.warn(`group message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1628
1817
|
// H26: 不再投递原始密文 payload;改发 group.message_undecryptable 事件
|
|
1629
1818
|
if (isJsonObject(data)) {
|
|
1630
1819
|
const safeEvent = {
|
|
@@ -1638,6 +1827,7 @@ export class AUNClient {
|
|
|
1638
1827
|
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1639
1828
|
}
|
|
1640
1829
|
});
|
|
1830
|
+
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1641
1831
|
}
|
|
1642
1832
|
/**
|
|
1643
1833
|
* 处理群组推送消息的异步任务。
|
|
@@ -1664,12 +1854,18 @@ export class AUNClient {
|
|
|
1664
1854
|
return;
|
|
1665
1855
|
}
|
|
1666
1856
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
1667
|
-
|
|
1668
|
-
|
|
1857
|
+
this._clientLog.debug(`group message decrypt done: group=${groupId}, from=${String(msg.from ?? '')}, seq=${String(seq ?? '')}, e2ee=${String(!!decrypted.e2ee)}`);
|
|
1858
|
+
// 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
|
|
1859
|
+
// 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
|
|
1860
|
+
// _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
|
|
1861
|
+
const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
|
|
1862
|
+
const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
|
|
1863
|
+
if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
|
|
1669
1864
|
const ns = `group:${groupId}`;
|
|
1670
1865
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1671
1866
|
if (needPull) {
|
|
1672
|
-
this.
|
|
1867
|
+
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
1868
|
+
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1673
1869
|
}
|
|
1674
1870
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1675
1871
|
if (contig > 0) {
|
|
@@ -1678,15 +1874,17 @@ export class AUNClient {
|
|
|
1678
1874
|
msg_seq: contig,
|
|
1679
1875
|
device_id: this._deviceId,
|
|
1680
1876
|
slot_id: this._slotId,
|
|
1681
|
-
}).catch((e) => { this._clientLog.debug(
|
|
1877
|
+
}).catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1682
1878
|
}
|
|
1683
1879
|
this._saveSeqTrackerState();
|
|
1684
1880
|
}
|
|
1685
1881
|
// R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
if (groupId)
|
|
1882
|
+
if (isDecryptFail) {
|
|
1883
|
+
if (groupId) {
|
|
1689
1884
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
1885
|
+
// 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
|
|
1886
|
+
this._scheduleRecoveryTimeout(groupId);
|
|
1887
|
+
}
|
|
1690
1888
|
await this._publishAppEvent('group.message_undecryptable', {
|
|
1691
1889
|
message_id: msg.message_id,
|
|
1692
1890
|
group_id: groupId,
|
|
@@ -1746,7 +1944,7 @@ export class AUNClient {
|
|
|
1746
1944
|
}
|
|
1747
1945
|
}
|
|
1748
1946
|
catch (exc) {
|
|
1749
|
-
this._clientLog.debug(
|
|
1947
|
+
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1750
1948
|
}
|
|
1751
1949
|
await this._publishAppEvent('group.message_created', notification);
|
|
1752
1950
|
}
|
|
@@ -1759,6 +1957,7 @@ export class AUNClient {
|
|
|
1759
1957
|
if (this._gapFillDone.has(dedupKey))
|
|
1760
1958
|
return;
|
|
1761
1959
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1960
|
+
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1762
1961
|
try {
|
|
1763
1962
|
const result = await this.call('group.pull', {
|
|
1764
1963
|
group_id: groupId,
|
|
@@ -1766,6 +1965,7 @@ export class AUNClient {
|
|
|
1766
1965
|
device_id: this._deviceId,
|
|
1767
1966
|
limit: 50,
|
|
1768
1967
|
});
|
|
1968
|
+
let filled = 0;
|
|
1769
1969
|
if (isJsonObject(result)) {
|
|
1770
1970
|
const messages = result.messages;
|
|
1771
1971
|
if (Array.isArray(messages)) {
|
|
@@ -1782,14 +1982,16 @@ export class AUNClient {
|
|
|
1782
1982
|
else {
|
|
1783
1983
|
await this._publishAppEvent('group.message_created', msg);
|
|
1784
1984
|
}
|
|
1985
|
+
filled += 1;
|
|
1785
1986
|
}
|
|
1786
1987
|
}
|
|
1787
1988
|
this._prunePushedSeqs(ns);
|
|
1788
1989
|
}
|
|
1789
1990
|
}
|
|
1991
|
+
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1790
1992
|
}
|
|
1791
1993
|
catch (exc) {
|
|
1792
|
-
this._clientLog.warn(
|
|
1994
|
+
this._clientLog.warn(`group message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1793
1995
|
}
|
|
1794
1996
|
finally {
|
|
1795
1997
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1806,11 +2008,13 @@ export class AUNClient {
|
|
|
1806
2008
|
if (this._gapFillDone.has(dedupKey))
|
|
1807
2009
|
return;
|
|
1808
2010
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
2011
|
+
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1809
2012
|
try {
|
|
1810
2013
|
const result = await this.call('message.pull', {
|
|
1811
2014
|
after_seq: afterSeq,
|
|
1812
2015
|
limit: 50,
|
|
1813
2016
|
});
|
|
2017
|
+
let filled = 0;
|
|
1814
2018
|
if (isJsonObject(result)) {
|
|
1815
2019
|
const messages = result.messages;
|
|
1816
2020
|
if (Array.isArray(messages)) {
|
|
@@ -1827,14 +2031,16 @@ export class AUNClient {
|
|
|
1827
2031
|
else {
|
|
1828
2032
|
await this._publishAppEvent('message.received', msg);
|
|
1829
2033
|
}
|
|
2034
|
+
filled += 1;
|
|
1830
2035
|
}
|
|
1831
2036
|
}
|
|
1832
2037
|
this._prunePushedSeqs(ns);
|
|
1833
2038
|
}
|
|
1834
2039
|
}
|
|
2040
|
+
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1835
2041
|
}
|
|
1836
2042
|
catch (exc) {
|
|
1837
|
-
this._clientLog.warn(`P2P
|
|
2043
|
+
this._clientLog.warn(`P2P message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1838
2044
|
}
|
|
1839
2045
|
finally {
|
|
1840
2046
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1975,6 +2181,7 @@ export class AUNClient {
|
|
|
1975
2181
|
if (this._gapFillDone.has(dedupKey))
|
|
1976
2182
|
return;
|
|
1977
2183
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
2184
|
+
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1978
2185
|
try {
|
|
1979
2186
|
const result = await this.call('group.pull_events', {
|
|
1980
2187
|
group_id: groupId,
|
|
@@ -1982,6 +2189,7 @@ export class AUNClient {
|
|
|
1982
2189
|
device_id: this._deviceId,
|
|
1983
2190
|
limit: 50,
|
|
1984
2191
|
});
|
|
2192
|
+
let filled = 0;
|
|
1985
2193
|
if (isJsonObject(result)) {
|
|
1986
2194
|
const events = result.events;
|
|
1987
2195
|
if (Array.isArray(events)) {
|
|
@@ -1991,7 +2199,7 @@ export class AUNClient {
|
|
|
1991
2199
|
if (serverAck > 0) {
|
|
1992
2200
|
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1993
2201
|
if (contigBefore < serverAck) {
|
|
1994
|
-
this._clientLog.info(`group.pull_events retention-floor
|
|
2202
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBefore} -> cursor.current_seq=${serverAck}`);
|
|
1995
2203
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1996
2204
|
}
|
|
1997
2205
|
}
|
|
@@ -2004,7 +2212,7 @@ export class AUNClient {
|
|
|
2004
2212
|
event_seq: contig,
|
|
2005
2213
|
device_id: this._deviceId,
|
|
2006
2214
|
slot_id: this._slotId,
|
|
2007
|
-
}).catch((e) => { this._clientLog.debug(
|
|
2215
|
+
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2008
2216
|
}
|
|
2009
2217
|
for (const evt of events) {
|
|
2010
2218
|
if (isJsonObject(evt)) {
|
|
@@ -2020,13 +2228,15 @@ export class AUNClient {
|
|
|
2020
2228
|
}
|
|
2021
2229
|
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2022
2230
|
await this._dispatcher.publish('group.changed', evt);
|
|
2231
|
+
filled += 1;
|
|
2023
2232
|
}
|
|
2024
2233
|
}
|
|
2025
2234
|
}
|
|
2026
2235
|
}
|
|
2236
|
+
this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
2027
2237
|
}
|
|
2028
2238
|
catch (exc) {
|
|
2029
|
-
this._clientLog.warn(
|
|
2239
|
+
this._clientLog.warn(`group event gap fill failed: ${formatCaughtError(exc)}`);
|
|
2030
2240
|
}
|
|
2031
2241
|
finally {
|
|
2032
2242
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -2156,103 +2366,113 @@ export class AUNClient {
|
|
|
2156
2366
|
return member ? String(member.group_id ?? '') : '';
|
|
2157
2367
|
}
|
|
2158
2368
|
async _onRawGroupChanged(data) {
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
this._fillGroupEventGap(groupId).catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
|
|
2192
|
-
}
|
|
2193
|
-
// 成员退出或被踢 → 剩余 admin/owner 自动补位轮换
|
|
2194
|
-
// H21: 避免 epoch 轮换风暴——所有剩余 admin 同时收到事件不能都发起轮换,
|
|
2195
|
-
// 否则 CAS 冲突激增。策略:本地 AID 为"排序最小 admin"时才发起,其他 admin
|
|
2196
|
-
// 叠加随机 jitter 作为超时兜底(本地最小 admin 失败时由下一位顶上)。
|
|
2197
|
-
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
2198
|
-
if (groupId) {
|
|
2199
|
-
{
|
|
2200
|
-
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2201
|
-
if (expectedEpoch === null) {
|
|
2202
|
-
this._clientLog.debug(`membership event without old_epoch skipped for epoch rotation: aid=${this._aid ?? ''} group=${groupId} action=${String(d.action ?? '')} event_seq=${String(d.event_seq ?? '')}`);
|
|
2203
|
-
}
|
|
2204
|
-
else {
|
|
2205
|
-
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2206
|
-
}
|
|
2369
|
+
const tStart = Date.now();
|
|
2370
|
+
try {
|
|
2371
|
+
if (isJsonObject(data)) {
|
|
2372
|
+
const d = data;
|
|
2373
|
+
const groupId = String(d.group_id ?? '');
|
|
2374
|
+
const action = String(d.action ?? '');
|
|
2375
|
+
this._clientLog.debug(`_onRawGroupChanged enter: group_id=${groupId}, action=${action}, event_seq=${String(d.event_seq ?? '')}`);
|
|
2376
|
+
// 验签:有 client_signature 就验,没有默认安全(H20: 严格 boolean)
|
|
2377
|
+
const cs = d.client_signature;
|
|
2378
|
+
if (cs && isJsonObject(cs)) {
|
|
2379
|
+
d._verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2380
|
+
}
|
|
2381
|
+
await this._dispatcher.publish('group.changed', d);
|
|
2382
|
+
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
2383
|
+
// 用 onMessageSeq 返回值决定是否补拉,与 P2P / group.message 路径对齐。
|
|
2384
|
+
let needPull = false;
|
|
2385
|
+
const rawEventSeq = d.event_seq;
|
|
2386
|
+
if (rawEventSeq != null && groupId) {
|
|
2387
|
+
const es = Number(rawEventSeq);
|
|
2388
|
+
if (Number.isFinite(es) && es > 0) {
|
|
2389
|
+
needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
|
|
2390
|
+
}
|
|
2391
|
+
// ISSUE-TS-002: 群事件推送路径 ack + 持久化,与 P2P/群消息路径对齐
|
|
2392
|
+
this._saveSeqTrackerState();
|
|
2393
|
+
const contig = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2394
|
+
if (contig > 0) {
|
|
2395
|
+
this._transport.call('group.ack_events', {
|
|
2396
|
+
group_id: groupId,
|
|
2397
|
+
event_seq: contig,
|
|
2398
|
+
device_id: this._deviceId,
|
|
2399
|
+
slot_id: this._slotId,
|
|
2400
|
+
}).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2207
2401
|
}
|
|
2208
2402
|
}
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2224
|
-
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2225
|
-
if (!isSelfJoining) {
|
|
2226
|
-
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2403
|
+
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
2404
|
+
if (needPull && groupId && !d._from_gap_fill) {
|
|
2405
|
+
this._fillGroupEventGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
2406
|
+
}
|
|
2407
|
+
// 成员退出或被踢 → 剩余 admin/owner 自动补位轮换
|
|
2408
|
+
// H21: 避免 epoch 轮换风暴——所有剩余 admin 同时收到事件不能都发起轮换,
|
|
2409
|
+
// 否则 CAS 冲突激增。策略:本地 AID 为"排序最小 admin"时才发起,其他 admin
|
|
2410
|
+
// 叠加随机 jitter 作为超时兜底(本地最小 admin 失败时由下一位顶上)。
|
|
2411
|
+
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
2412
|
+
if (groupId) {
|
|
2413
|
+
{
|
|
2414
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2415
|
+
if (expectedEpoch === null) {
|
|
2416
|
+
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 ?? '')}`);
|
|
2227
2417
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2418
|
+
else {
|
|
2419
|
+
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2231
2420
|
}
|
|
2232
2421
|
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
// 成员加入:按 action 区分策略
|
|
2425
|
+
// - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
|
|
2426
|
+
// - joined / invite_code_used(开放群/邀请码群):新成员先恢复 committed_epoch,延迟轮换
|
|
2427
|
+
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
2428
|
+
if (groupId) {
|
|
2429
|
+
{
|
|
2430
|
+
const action = String(d.action ?? '');
|
|
2431
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2432
|
+
const joinedAids = this._joinedMemberAidsFromPayload(d);
|
|
2433
|
+
const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
|
|
2434
|
+
this._clientLog.debug(`group.changed action=${action} groupId=${groupId} joinedAids=${JSON.stringify(joinedAids)} myAid=${this._aid} isSelfJoining=${String(isSelfJoining)} expectedEpoch=${String(expectedEpoch)}`);
|
|
2435
|
+
if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
|
|
2436
|
+
// open/invite_code 群:所有在线成员都参与延迟轮换
|
|
2437
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2236
2438
|
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2237
|
-
|
|
2439
|
+
if (!isSelfJoining) {
|
|
2440
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2441
|
+
}
|
|
2442
|
+
if (expectedEpoch !== null) {
|
|
2443
|
+
const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
|
|
2444
|
+
this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2445
|
+
}
|
|
2238
2446
|
}
|
|
2239
2447
|
else {
|
|
2240
|
-
|
|
2448
|
+
// member_added / join_approved:立即轮换
|
|
2449
|
+
if (expectedEpoch === null) {
|
|
2450
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2451
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2452
|
+
}
|
|
2453
|
+
else {
|
|
2454
|
+
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2455
|
+
}
|
|
2241
2456
|
}
|
|
2242
2457
|
}
|
|
2243
2458
|
}
|
|
2244
2459
|
}
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2460
|
+
// 群组解散 → 清理本地 epoch key、seq_tracker、补洞去重缓存
|
|
2461
|
+
if (d.action === 'dissolved') {
|
|
2462
|
+
if (groupId) {
|
|
2463
|
+
this._cleanupDissolvedGroup(groupId);
|
|
2464
|
+
}
|
|
2250
2465
|
}
|
|
2251
2466
|
}
|
|
2467
|
+
else {
|
|
2468
|
+
// data 非对象也透传给用户(兼容旧版)
|
|
2469
|
+
await this._dispatcher.publish('group.changed', data);
|
|
2470
|
+
}
|
|
2471
|
+
this._clientLog.debug(`_onRawGroupChanged exit: elapsed=${Date.now() - tStart}ms`);
|
|
2252
2472
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2473
|
+
catch (err) {
|
|
2474
|
+
this._clientLog.debug(`_onRawGroupChanged exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
2475
|
+
throw err;
|
|
2256
2476
|
}
|
|
2257
2477
|
}
|
|
2258
2478
|
/**
|
|
@@ -2260,82 +2480,95 @@ export class AUNClient {
|
|
|
2260
2480
|
* 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
|
|
2261
2481
|
*/
|
|
2262
2482
|
async _onGroupStateCommitted(data) {
|
|
2263
|
-
|
|
2483
|
+
const tStart = Date.now();
|
|
2484
|
+
if (!isJsonObject(data)) {
|
|
2485
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
2264
2486
|
return;
|
|
2487
|
+
}
|
|
2265
2488
|
const d = data;
|
|
2266
2489
|
const groupId = String(d.group_id ?? '').trim();
|
|
2267
|
-
if (!groupId)
|
|
2490
|
+
if (!groupId) {
|
|
2491
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (no group_id)`);
|
|
2268
2492
|
return;
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
const
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2493
|
+
}
|
|
2494
|
+
this._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupId}, state_version=${String(d.state_version ?? '')}`);
|
|
2495
|
+
try {
|
|
2496
|
+
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
2497
|
+
const cs = d.client_signature;
|
|
2498
|
+
if (cs && isJsonObject(cs)) {
|
|
2499
|
+
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2500
|
+
if (verified === false) {
|
|
2501
|
+
this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
d._verified = verified;
|
|
2505
|
+
}
|
|
2506
|
+
const stateVersion = Number(d.state_version ?? 0);
|
|
2507
|
+
const stateHash = String(d.state_hash ?? '').trim();
|
|
2508
|
+
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
2509
|
+
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
2510
|
+
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
2511
|
+
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
2512
|
+
// 1. 验证 prev_state_hash 连续性
|
|
2513
|
+
const loadFn = this._keystore.loadGroupState;
|
|
2514
|
+
const localState = loadFn ? loadFn.call(this._keystore, groupId) : null;
|
|
2515
|
+
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
2516
|
+
this._clientLog.warn(`state_hash chain discontinuous group=${groupId} local_sv=${localState.state_version} event_sv=${stateVersion}`);
|
|
2517
|
+
// 回源同步
|
|
2518
|
+
try {
|
|
2519
|
+
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
2520
|
+
if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
|
|
2521
|
+
const sv = Number(serverState.state_version ?? 0);
|
|
2522
|
+
const sHash = String(serverState.state_hash ?? '');
|
|
2523
|
+
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
2524
|
+
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
2525
|
+
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
2526
|
+
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
2527
|
+
// 回源也做 hash 验证
|
|
2528
|
+
if (sMembersJson && sHash) {
|
|
2529
|
+
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
2530
|
+
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
2531
|
+
const computed = computeStateHash({
|
|
2532
|
+
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
2533
|
+
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
2534
|
+
});
|
|
2535
|
+
if (computed !== sHash) {
|
|
2536
|
+
this._clientLog.warn(`backfill state_hash verification failed group=${groupId} sv=${sv} expected=${sHash} got=${computed}`);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2541
|
+
if (saveFn) {
|
|
2542
|
+
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2311
2543
|
}
|
|
2312
2544
|
}
|
|
2313
|
-
const saveFn = this._keystore.saveGroupState;
|
|
2314
|
-
if (saveFn) {
|
|
2315
|
-
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2316
|
-
}
|
|
2317
2545
|
}
|
|
2546
|
+
catch (exc) {
|
|
2547
|
+
this._clientLog.warn(`state backfill failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
2548
|
+
}
|
|
2549
|
+
return;
|
|
2318
2550
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2551
|
+
// 2. 本地重算验证
|
|
2552
|
+
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
2553
|
+
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
2554
|
+
const computed = computeStateHash({
|
|
2555
|
+
groupId, stateVersion, keyEpoch,
|
|
2556
|
+
members, policy, prevStateHash,
|
|
2557
|
+
});
|
|
2558
|
+
if (computed !== stateHash) {
|
|
2559
|
+
this._clientLog.warn(`state_hash recompute mismatch group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
|
|
2560
|
+
return;
|
|
2321
2561
|
}
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
groupId, stateVersion, keyEpoch,
|
|
2329
|
-
members, policy, prevStateHash,
|
|
2330
|
-
});
|
|
2331
|
-
if (computed !== stateHash) {
|
|
2332
|
-
this._clientLog.warn(`state_hash 重算不匹配 group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
|
|
2333
|
-
return;
|
|
2562
|
+
// 3. 更新本地存储
|
|
2563
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2564
|
+
if (saveFn) {
|
|
2565
|
+
saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2566
|
+
}
|
|
2567
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
2334
2568
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2569
|
+
catch (err) {
|
|
2570
|
+
this._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2571
|
+
throw err;
|
|
2339
2572
|
}
|
|
2340
2573
|
}
|
|
2341
2574
|
/**
|
|
@@ -2343,23 +2576,34 @@ export class AUNClient {
|
|
|
2343
2576
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
2344
2577
|
*/
|
|
2345
2578
|
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
2579
|
+
const tStart = Date.now();
|
|
2346
2580
|
const myAid = this._aid;
|
|
2347
|
-
if (!myAid || this._closing || this._state !== 'connected')
|
|
2581
|
+
if (!myAid || this._closing || this._state !== 'connected') {
|
|
2582
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} reason=${!myAid ? 'no_aid' : this._closing ? 'closing' : 'not_connected'}`);
|
|
2348
2583
|
return;
|
|
2584
|
+
}
|
|
2585
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch enter: group=${groupId}, trigger=${triggerId || '-'}, expectedEpoch=${String(expectedEpoch)}, allowMember=${String(allowMember)}`);
|
|
2349
2586
|
const started = Date.now();
|
|
2350
2587
|
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
2351
|
-
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
2588
|
+
if (triggerId && this._groupMembershipRotationDone.has(triggerId)) {
|
|
2589
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (trigger_done)`);
|
|
2352
2590
|
return;
|
|
2353
|
-
|
|
2591
|
+
}
|
|
2592
|
+
if (this._closing || this._state !== 'connected') {
|
|
2593
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (closing/disconnected)`);
|
|
2354
2594
|
return;
|
|
2595
|
+
}
|
|
2355
2596
|
if (Date.now() - started > 20000) {
|
|
2356
2597
|
this._clientLog.warn(`group epoch rotation still in-flight; skip pending trigger (group=${groupId} trigger=${triggerId || '-'})`);
|
|
2598
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (inflight_timeout)`);
|
|
2357
2599
|
return;
|
|
2358
2600
|
}
|
|
2359
2601
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2360
2602
|
}
|
|
2361
|
-
if (this._closing || this._state !== 'connected')
|
|
2603
|
+
if (this._closing || this._state !== 'connected') {
|
|
2604
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (closing/disconnected after wait)`);
|
|
2362
2605
|
return;
|
|
2606
|
+
}
|
|
2363
2607
|
this._groupEpochRotationInflight.add(groupId);
|
|
2364
2608
|
try {
|
|
2365
2609
|
if (this._closing || this._state !== 'connected')
|
|
@@ -2407,11 +2651,14 @@ export class AUNClient {
|
|
|
2407
2651
|
const leader = candidates[0];
|
|
2408
2652
|
if (leader === myAid) {
|
|
2409
2653
|
// 我是 leader,直接发起
|
|
2654
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch leader election: group=${groupId}, I am leader, initiating rotation`);
|
|
2410
2655
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2411
2656
|
return;
|
|
2412
2657
|
}
|
|
2413
|
-
if (!candidates.includes(myAid))
|
|
2658
|
+
if (!candidates.includes(myAid)) {
|
|
2659
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch skipped: group=${groupId}, not in candidate list`);
|
|
2414
2660
|
return;
|
|
2661
|
+
}
|
|
2415
2662
|
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
2416
2663
|
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
2417
2664
|
let beforeEpoch = 0;
|
|
@@ -2448,14 +2695,15 @@ export class AUNClient {
|
|
|
2448
2695
|
});
|
|
2449
2696
|
return;
|
|
2450
2697
|
}
|
|
2451
|
-
this._clientLog.info(`[H21] leader
|
|
2698
|
+
this._clientLog.info(`[H21] leader did not complete epoch rotation, non-leader fallback: group=${groupId} myAid=${myAid}`);
|
|
2452
2699
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2453
2700
|
}
|
|
2454
2701
|
catch (exc) {
|
|
2455
|
-
this._clientLog.warn(`_maybeLeadRotateGroupEpoch
|
|
2702
|
+
this._clientLog.warn(`_maybeLeadRotateGroupEpoch failed: ${formatCaughtError(exc)}`);
|
|
2456
2703
|
}
|
|
2457
2704
|
finally {
|
|
2458
2705
|
this._groupEpochRotationInflight.delete(groupId);
|
|
2706
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
2459
2707
|
}
|
|
2460
2708
|
}
|
|
2461
2709
|
/**
|
|
@@ -2470,7 +2718,7 @@ export class AUNClient {
|
|
|
2470
2718
|
this._groupE2ee.removeGroup(groupId);
|
|
2471
2719
|
}
|
|
2472
2720
|
catch (exc) {
|
|
2473
|
-
this._clientLog.warn(
|
|
2721
|
+
this._clientLog.warn(`cleanup disbanded group ${groupId} epoch key failed: ${formatCaughtError(exc)}`);
|
|
2474
2722
|
}
|
|
2475
2723
|
// 2. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
2476
2724
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -2487,7 +2735,7 @@ export class AUNClient {
|
|
|
2487
2735
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
2488
2736
|
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
2489
2737
|
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
2490
|
-
this._clientLog.info(
|
|
2738
|
+
this._clientLog.info(`cleaned up disbanded group ${groupId} local state`);
|
|
2491
2739
|
}
|
|
2492
2740
|
/** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
|
|
2493
2741
|
/**
|
|
@@ -2522,7 +2770,7 @@ export class AUNClient {
|
|
|
2522
2770
|
if (expectedFP) {
|
|
2523
2771
|
const actualFP = 'sha256:' + certObj.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2524
2772
|
if (actualFP !== expectedFP) {
|
|
2525
|
-
this._clientLog.warn(
|
|
2773
|
+
this._clientLog.warn(`signature verification failed: cert fingerprint mismatch aid=${sigAid}`);
|
|
2526
2774
|
return false;
|
|
2527
2775
|
}
|
|
2528
2776
|
}
|
|
@@ -2533,7 +2781,7 @@ export class AUNClient {
|
|
|
2533
2781
|
const pubKey = certObj.publicKey;
|
|
2534
2782
|
const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
|
|
2535
2783
|
if (!ok) {
|
|
2536
|
-
this._clientLog.warn(
|
|
2784
|
+
this._clientLog.warn(`group event signature verification failed aid=${sigAid} method=${method}`);
|
|
2537
2785
|
// P1-16: 签名失败统一发布事件
|
|
2538
2786
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2539
2787
|
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
@@ -2542,7 +2790,7 @@ export class AUNClient {
|
|
|
2542
2790
|
return ok;
|
|
2543
2791
|
}
|
|
2544
2792
|
catch (exc) {
|
|
2545
|
-
this._clientLog.warn(
|
|
2793
|
+
this._clientLog.warn(`group event signature verification error: ${formatCaughtError(exc)}`);
|
|
2546
2794
|
// P1-16: 签名失败统一发布事件
|
|
2547
2795
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2548
2796
|
aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
|
|
@@ -2588,11 +2836,31 @@ export class AUNClient {
|
|
|
2588
2836
|
}
|
|
2589
2837
|
let result;
|
|
2590
2838
|
if (actualPayload.type === 'e2ee.group_key_distribution') {
|
|
2839
|
+
// 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
|
|
2840
|
+
// 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
|
|
2841
|
+
const distGroupId = String(actualPayload.group_id ?? '');
|
|
2842
|
+
const distEpoch = Number(actualPayload.epoch ?? 0);
|
|
2843
|
+
if (distGroupId && distEpoch > 0) {
|
|
2844
|
+
const localEpoch = this._groupE2ee.currentEpoch(distGroupId) ?? 0;
|
|
2845
|
+
if (localEpoch >= distEpoch) {
|
|
2846
|
+
this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
|
|
2847
|
+
return true;
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2591
2850
|
if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
|
|
2592
2851
|
return true;
|
|
2593
2852
|
}
|
|
2594
2853
|
}
|
|
2595
2854
|
else if (actualPayload.type === 'e2ee.group_key_response') {
|
|
2855
|
+
const respGroupId = String(actualPayload.group_id ?? '');
|
|
2856
|
+
const respEpoch = Number(actualPayload.epoch ?? 0);
|
|
2857
|
+
if (respGroupId && respEpoch > 0) {
|
|
2858
|
+
const localEpoch = this._groupE2ee.currentEpoch(respGroupId) ?? 0;
|
|
2859
|
+
if (localEpoch >= respEpoch) {
|
|
2860
|
+
this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
|
|
2861
|
+
return true;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2596
2864
|
if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
|
|
2597
2865
|
return true;
|
|
2598
2866
|
}
|
|
@@ -2600,6 +2868,7 @@ export class AUNClient {
|
|
|
2600
2868
|
result = this._groupE2ee.handleIncoming(actualPayload);
|
|
2601
2869
|
if (result === 'distribution') {
|
|
2602
2870
|
await this._discardGroupDistributionIfStale(actualPayload);
|
|
2871
|
+
this._clientLog.debug(`group key distribution received: group_id=${String(actualPayload.group_id ?? '')}, epoch=${String(actualPayload.epoch ?? '')}, rotation=${String(actualPayload.rotation_id ?? '')}`);
|
|
2603
2872
|
// 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
|
|
2604
2873
|
const distGroupId = actualPayload.group_id;
|
|
2605
2874
|
if (distGroupId && !this._groupSynced.has(distGroupId)) {
|
|
@@ -2613,6 +2882,7 @@ export class AUNClient {
|
|
|
2613
2882
|
// 处理密钥请求并回复
|
|
2614
2883
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2615
2884
|
const requester = String(actualPayload.requester_aid ?? '');
|
|
2885
|
+
this._clientLog.debug(`group key request received: group_id=${groupId}, requester=${requester}, epoch=${String(actualPayload.epoch ?? '')}`);
|
|
2616
2886
|
let members = this._groupE2ee.getMemberAids(groupId);
|
|
2617
2887
|
// 请求者不在本地成员列表时,回源查询服务端最新成员列表,
|
|
2618
2888
|
// 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
|
|
@@ -2626,7 +2896,7 @@ export class AUNClient {
|
|
|
2626
2896
|
members = memberList.map((m) => String(m.aid));
|
|
2627
2897
|
}
|
|
2628
2898
|
catch (exc) {
|
|
2629
|
-
this._clientLog.warn(
|
|
2899
|
+
this._clientLog.warn(`group ${groupId} member list backfill failed: ${formatCaughtError(exc)}`);
|
|
2630
2900
|
}
|
|
2631
2901
|
}
|
|
2632
2902
|
const response = this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
|
|
@@ -2640,7 +2910,7 @@ export class AUNClient {
|
|
|
2640
2910
|
});
|
|
2641
2911
|
}
|
|
2642
2912
|
catch (exc) {
|
|
2643
|
-
this._clientLog.warn(
|
|
2913
|
+
this._clientLog.warn(`replying group key to ${requester} failed: ${formatCaughtError(exc)}`);
|
|
2644
2914
|
}
|
|
2645
2915
|
}
|
|
2646
2916
|
}
|
|
@@ -2649,9 +2919,10 @@ export class AUNClient {
|
|
|
2649
2919
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2650
2920
|
const rotationId = String(actualPayload.rotation_id ?? '');
|
|
2651
2921
|
const keyCommitment = String(actualPayload.commitment ?? '');
|
|
2922
|
+
this._clientLog.debug(`group key ${result} handled: group_id=${groupId}, epoch=${String(actualPayload.epoch ?? '')}, rotation=${rotationId}`);
|
|
2652
2923
|
if (rotationId && keyCommitment) {
|
|
2653
2924
|
this._ackGroupRotationKey(rotationId, keyCommitment)
|
|
2654
|
-
.catch((exc) => this._clientLog.warn(
|
|
2925
|
+
.catch((exc) => this._clientLog.warn(`submit epoch key ack failed: ${formatCaughtError(exc)}`));
|
|
2655
2926
|
}
|
|
2656
2927
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2657
2928
|
}
|
|
@@ -2663,133 +2934,158 @@ export class AUNClient {
|
|
|
2663
2934
|
* 跨域时自动路由到 peer 所在域的 Gateway。
|
|
2664
2935
|
*/
|
|
2665
2936
|
async _fetchPeerCert(aid, certFingerprint) {
|
|
2666
|
-
const
|
|
2667
|
-
|
|
2668
|
-
const now = Date.now() / 1000;
|
|
2669
|
-
if (cached && now < cached.refreshAfter) {
|
|
2670
|
-
return cached.certPem;
|
|
2671
|
-
}
|
|
2672
|
-
const gatewayUrl = this._gatewayUrl;
|
|
2673
|
-
if (!gatewayUrl) {
|
|
2674
|
-
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2675
|
-
}
|
|
2676
|
-
// 跨域时用 peer 所在域的 Gateway URL
|
|
2677
|
-
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2678
|
-
let certPem;
|
|
2937
|
+
const tStart = Date.now();
|
|
2938
|
+
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
2679
2939
|
try {
|
|
2680
|
-
const
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2940
|
+
const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
|
|
2941
|
+
const cached = this._certCache.get(cacheKey);
|
|
2942
|
+
const now = Date.now() / 1000;
|
|
2943
|
+
if (cached && now < cached.refreshAfter) {
|
|
2944
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (cache_hit)`);
|
|
2945
|
+
return cached.certPem;
|
|
2946
|
+
}
|
|
2947
|
+
const gatewayUrl = this._gatewayUrl;
|
|
2948
|
+
if (!gatewayUrl) {
|
|
2949
|
+
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2950
|
+
}
|
|
2951
|
+
// 跨域时用 peer 所在域的 Gateway URL
|
|
2952
|
+
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2953
|
+
let certPem;
|
|
2954
|
+
try {
|
|
2955
|
+
const certUrl = AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
2956
|
+
certPem = await _httpGetText(certUrl, this._configModel.verifySsl);
|
|
2686
2957
|
}
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2958
|
+
catch (exc) {
|
|
2959
|
+
if (!certFingerprint) {
|
|
2960
|
+
throw exc;
|
|
2961
|
+
}
|
|
2962
|
+
const fallbackCert = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl);
|
|
2963
|
+
certPem = fallbackCert;
|
|
2964
|
+
}
|
|
2965
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
2966
|
+
if (certFingerprint) {
|
|
2967
|
+
const expectedFP = String(certFingerprint).trim().toLowerCase();
|
|
2968
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
2969
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
2970
|
+
}
|
|
2971
|
+
const expectedHex = expectedFP.slice('sha256:'.length);
|
|
2972
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
2973
|
+
const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2974
|
+
let spkiHex = '';
|
|
2975
|
+
try {
|
|
2976
|
+
const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
2977
|
+
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
2978
|
+
}
|
|
2979
|
+
catch {
|
|
2980
|
+
spkiHex = '';
|
|
2981
|
+
}
|
|
2982
|
+
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
2983
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
// 完整 PKI 验证
|
|
2700
2987
|
try {
|
|
2701
|
-
|
|
2702
|
-
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
2988
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
2703
2989
|
}
|
|
2704
|
-
catch {
|
|
2705
|
-
|
|
2990
|
+
catch (exc) {
|
|
2991
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2706
2992
|
}
|
|
2707
|
-
|
|
2708
|
-
|
|
2993
|
+
const nowSec = Date.now() / 1000;
|
|
2994
|
+
this._certCache.set(cacheKey, {
|
|
2995
|
+
certPem,
|
|
2996
|
+
validatedAt: nowSec,
|
|
2997
|
+
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
2998
|
+
});
|
|
2999
|
+
try {
|
|
3000
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
3001
|
+
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2709
3002
|
}
|
|
3003
|
+
catch (exc) {
|
|
3004
|
+
this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3005
|
+
}
|
|
3006
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
3007
|
+
return certPem;
|
|
2710
3008
|
}
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
}
|
|
2715
|
-
catch (exc) {
|
|
2716
|
-
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2717
|
-
}
|
|
2718
|
-
const nowSec = Date.now() / 1000;
|
|
2719
|
-
this._certCache.set(cacheKey, {
|
|
2720
|
-
certPem,
|
|
2721
|
-
validatedAt: nowSec,
|
|
2722
|
-
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
2723
|
-
});
|
|
2724
|
-
try {
|
|
2725
|
-
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
2726
|
-
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2727
|
-
}
|
|
2728
|
-
catch (exc) {
|
|
2729
|
-
this._clientLog.error(`写入证书到 keystore 失败 (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3009
|
+
catch (err) {
|
|
3010
|
+
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3011
|
+
throw err;
|
|
2730
3012
|
}
|
|
2731
|
-
return certPem;
|
|
2732
3013
|
}
|
|
2733
3014
|
/** 获取对方所有设备的 prekey 列表。 */
|
|
2734
3015
|
async _fetchPeerPrekeys(peerAid) {
|
|
2735
|
-
const
|
|
2736
|
-
|
|
2737
|
-
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2738
|
-
if (normalized.length > 0) {
|
|
2739
|
-
return normalized.map((item) => ({ ...item }));
|
|
2740
|
-
}
|
|
2741
|
-
}
|
|
2742
|
-
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2743
|
-
if (cached !== null) {
|
|
2744
|
-
const normalized = normalizePeerPrekeys([cached]);
|
|
2745
|
-
if (normalized.length > 0)
|
|
2746
|
-
return normalized.map((item) => ({ ...item }));
|
|
2747
|
-
}
|
|
3016
|
+
const tStart = Date.now();
|
|
3017
|
+
this._clientLog.debug(`_fetchPeerPrekeys enter: aid=${peerAid}`);
|
|
2748
3018
|
try {
|
|
2749
|
-
const
|
|
2750
|
-
if (
|
|
2751
|
-
|
|
2752
|
-
}
|
|
2753
|
-
if (result.found === false) {
|
|
2754
|
-
return [];
|
|
2755
|
-
}
|
|
2756
|
-
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2757
|
-
if (devicePrekeys) {
|
|
2758
|
-
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
3019
|
+
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
3020
|
+
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
3021
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2759
3022
|
if (normalized.length > 0) {
|
|
2760
|
-
this.
|
|
2761
|
-
|
|
2762
|
-
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2763
|
-
});
|
|
2764
|
-
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2765
|
-
return normalized;
|
|
3023
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (cache_hit count=${normalized.length})`);
|
|
3024
|
+
return normalized.map((item) => ({ ...item }));
|
|
2766
3025
|
}
|
|
2767
3026
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
const prekey = result.prekey;
|
|
2772
|
-
if (prekey) {
|
|
2773
|
-
const normalized = normalizePeerPrekeys([prekey]);
|
|
3027
|
+
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
3028
|
+
if (cached !== null) {
|
|
3029
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
2774
3030
|
if (normalized.length > 0) {
|
|
2775
|
-
this.
|
|
2776
|
-
items: normalized.map((item) => ({ ...item })),
|
|
2777
|
-
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2778
|
-
});
|
|
2779
|
-
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3031
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (e2ee_cache_hit)`);
|
|
2780
3032
|
return normalized.map((item) => ({ ...item }));
|
|
2781
3033
|
}
|
|
2782
3034
|
}
|
|
2783
|
-
|
|
2784
|
-
|
|
3035
|
+
try {
|
|
3036
|
+
const result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
3037
|
+
if (!isJsonObject(result)) {
|
|
3038
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3039
|
+
}
|
|
3040
|
+
if (result.found === false) {
|
|
3041
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (not_found)`);
|
|
3042
|
+
return [];
|
|
3043
|
+
}
|
|
3044
|
+
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
3045
|
+
if (devicePrekeys) {
|
|
3046
|
+
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
3047
|
+
if (normalized.length > 0) {
|
|
3048
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
3049
|
+
items: normalized.map((item) => ({ ...item })),
|
|
3050
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
3051
|
+
});
|
|
3052
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3053
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (devices=${normalized.length})`);
|
|
3054
|
+
return normalized;
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
if (!isPeerPrekeyResponse(result)) {
|
|
3058
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3059
|
+
}
|
|
3060
|
+
const prekey = result.prekey;
|
|
3061
|
+
if (prekey) {
|
|
3062
|
+
const normalized = normalizePeerPrekeys([prekey]);
|
|
3063
|
+
if (normalized.length > 0) {
|
|
3064
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
3065
|
+
items: normalized.map((item) => ({ ...item })),
|
|
3066
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
3067
|
+
});
|
|
3068
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3069
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (legacy_single)`);
|
|
3070
|
+
return normalized.map((item) => ({ ...item }));
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
if (result.found) {
|
|
3074
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3075
|
+
}
|
|
3076
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (empty)`);
|
|
3077
|
+
return [];
|
|
3078
|
+
}
|
|
3079
|
+
catch (exc) {
|
|
3080
|
+
if (exc instanceof ValidationError) {
|
|
3081
|
+
throw exc;
|
|
3082
|
+
}
|
|
3083
|
+
throw new ValidationError(`failed to fetch peer prekey for ${peerAid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2785
3084
|
}
|
|
2786
|
-
return [];
|
|
2787
3085
|
}
|
|
2788
|
-
catch (
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
}
|
|
2792
|
-
throw new ValidationError(`failed to fetch peer prekey for ${peerAid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
3086
|
+
catch (err) {
|
|
3087
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit (error): elapsed=${Date.now() - tStart}ms aid=${peerAid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3088
|
+
throw err;
|
|
2793
3089
|
}
|
|
2794
3090
|
}
|
|
2795
3091
|
/** 获取对方的单个 prekey(兼容接口,优先返回第一条 device prekey)。 */
|
|
@@ -2830,6 +3126,8 @@ export class AUNClient {
|
|
|
2830
3126
|
async _uploadPrekey() {
|
|
2831
3127
|
const prekeyMaterial = this._e2ee.generatePrekey();
|
|
2832
3128
|
const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
|
|
3129
|
+
// 上传成功后记录为活跃 prekey
|
|
3130
|
+
this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
|
|
2833
3131
|
return isJsonObject(result) ? { ...result } : { ok: true };
|
|
2834
3132
|
}
|
|
2835
3133
|
/**
|
|
@@ -2877,10 +3175,10 @@ export class AUNClient {
|
|
|
2877
3175
|
catch (exc) {
|
|
2878
3176
|
// 刷新失败时:若内存缓存有 PKI 验证过的证书(未过期 x2 倍 TTL)则继续用
|
|
2879
3177
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2880
|
-
this._clientLog.debug(
|
|
3178
|
+
this._clientLog.debug(`refresh sender ${aid} cert failed, continuing with verified memory cache: ${formatCaughtError(exc)}`);
|
|
2881
3179
|
return true;
|
|
2882
3180
|
}
|
|
2883
|
-
this._clientLog.warn(
|
|
3181
|
+
this._clientLog.warn(`failed to get sender ${aid} cert and no verified cache, rejecting trust: ${formatCaughtError(exc)}`);
|
|
2884
3182
|
return false;
|
|
2885
3183
|
}
|
|
2886
3184
|
}
|
|
@@ -2902,33 +3200,50 @@ export class AUNClient {
|
|
|
2902
3200
|
}
|
|
2903
3201
|
/** 解密单条 P2P 消息 */
|
|
2904
3202
|
async _decryptSingleMessage(message) {
|
|
2905
|
-
const
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
3203
|
+
const tStart = Date.now();
|
|
3204
|
+
const fromLog = String(message.from ?? '');
|
|
3205
|
+
const midLog = String(message.message_id ?? '');
|
|
3206
|
+
this._clientLog.debug(`_decryptSingleMessage enter: from=${fromLog}, mid=${midLog}`);
|
|
3207
|
+
try {
|
|
3208
|
+
const payload = message.payload;
|
|
3209
|
+
if (!isJsonObject(payload)) {
|
|
3210
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
3211
|
+
return message;
|
|
3212
|
+
}
|
|
3213
|
+
const payloadObj = payload;
|
|
3214
|
+
if (payloadObj.type !== 'e2ee.encrypted') {
|
|
3215
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms (not encrypted type)`);
|
|
3216
|
+
return message;
|
|
3217
|
+
}
|
|
3218
|
+
if (message.encrypted === false) {
|
|
3219
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms (encrypted=false)`);
|
|
3220
|
+
return message;
|
|
3221
|
+
}
|
|
3222
|
+
// 确保发送方证书已缓存到 keystore
|
|
3223
|
+
const fromAid = String(message.from ?? '');
|
|
3224
|
+
const senderCertFingerprint = String(payloadObj.sender_cert_fingerprint ?? payloadObj.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
3225
|
+
if (fromAid) {
|
|
3226
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
3227
|
+
if (!certReady) {
|
|
3228
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping decrypt`);
|
|
3229
|
+
throw new Error(`发送方证书不可用: from=${fromAid}, mid=${message.message_id}`);
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
// 密码学解密(E2EEManager.decryptMessage 内含本地防重放)
|
|
3233
|
+
const decrypted = this._e2ee.decryptMessage(message);
|
|
3234
|
+
this._schedulePrekeyReplenishIfConsumed(decrypted);
|
|
3235
|
+
// TS-015: 解密返回 null 表示失败(密文损坏/签名无效/重放等),
|
|
3236
|
+
// 不得回退到原始密文投递给应用层,应抛出错误触发 undecryptable 事件
|
|
3237
|
+
if (decrypted === null) {
|
|
3238
|
+
throw new Error(`E2EE 解密失败: from=${message.from}, mid=${message.message_id}`);
|
|
3239
|
+
}
|
|
3240
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms from=${fromAid}, mid=${midLog}`);
|
|
3241
|
+
return decrypted;
|
|
3242
|
+
}
|
|
3243
|
+
catch (err) {
|
|
3244
|
+
this._clientLog.debug(`_decryptSingleMessage exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3245
|
+
throw err;
|
|
3246
|
+
}
|
|
2932
3247
|
}
|
|
2933
3248
|
/** 批量解密 P2P 消息(用于 message.pull) */
|
|
2934
3249
|
async _decryptMessages(messages) {
|
|
@@ -2952,7 +3267,7 @@ export class AUNClient {
|
|
|
2952
3267
|
if (fromAid) {
|
|
2953
3268
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2954
3269
|
if (!certReady) {
|
|
2955
|
-
this._clientLog.warn(
|
|
3270
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping decrypt`);
|
|
2956
3271
|
continue;
|
|
2957
3272
|
}
|
|
2958
3273
|
}
|
|
@@ -2963,7 +3278,7 @@ export class AUNClient {
|
|
|
2963
3278
|
}
|
|
2964
3279
|
else {
|
|
2965
3280
|
// TS-015: 解密失败不回退到密文,跳过该消息并记录
|
|
2966
|
-
this._clientLog.warn(`pull
|
|
3281
|
+
this._clientLog.warn(`pull message decrypt failed, skipping: from=${msg.from} mid=${msg.message_id}`);
|
|
2967
3282
|
}
|
|
2968
3283
|
}
|
|
2969
3284
|
else {
|
|
@@ -2979,23 +3294,47 @@ export class AUNClient {
|
|
|
2979
3294
|
queue.push(msg);
|
|
2980
3295
|
this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
|
|
2981
3296
|
}
|
|
2982
|
-
async _retryPendingDecryptMsgs(groupId) {
|
|
3297
|
+
async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
|
|
2983
3298
|
const ns = `group:${groupId}`;
|
|
2984
3299
|
const queue = this._pendingDecryptMsgs.get(ns);
|
|
2985
3300
|
if (!queue || queue.length === 0)
|
|
2986
3301
|
return;
|
|
2987
3302
|
this._pendingDecryptMsgs.set(ns, []);
|
|
2988
3303
|
const stillPending = [];
|
|
3304
|
+
let forceAdvancedAny = false;
|
|
2989
3305
|
for (const msg of queue) {
|
|
2990
3306
|
try {
|
|
2991
3307
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
2992
3308
|
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2993
3309
|
if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
2994
|
-
|
|
3310
|
+
if (forceAdvanceOnFail) {
|
|
3311
|
+
// recovery 真的失败:强制推进 seq tracker + 发 undecryptable + ack
|
|
3312
|
+
this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
|
|
3313
|
+
const seq = msg.seq;
|
|
3314
|
+
if (seq !== undefined && seq !== null) {
|
|
3315
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
3316
|
+
this._saveSeqTrackerState();
|
|
3317
|
+
forceAdvancedAny = true;
|
|
3318
|
+
}
|
|
3319
|
+
await this._publishAppEvent('group.message_undecryptable', {
|
|
3320
|
+
message_id: msg.message_id,
|
|
3321
|
+
group_id: groupId,
|
|
3322
|
+
from: msg.from,
|
|
3323
|
+
seq,
|
|
3324
|
+
timestamp: msg.timestamp,
|
|
3325
|
+
_decrypt_error: 'epoch recovery failed',
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
else {
|
|
3329
|
+
stillPending.push(msg);
|
|
3330
|
+
}
|
|
2995
3331
|
continue;
|
|
2996
3332
|
}
|
|
2997
3333
|
const seq = msg.seq;
|
|
2998
3334
|
if (seq !== undefined && seq !== null) {
|
|
3335
|
+
// 推进 seq tracker(之前 push/pull 失败时没推进)
|
|
3336
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
3337
|
+
this._saveSeqTrackerState();
|
|
2999
3338
|
await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
|
|
3000
3339
|
}
|
|
3001
3340
|
else {
|
|
@@ -3006,6 +3345,18 @@ export class AUNClient {
|
|
|
3006
3345
|
stillPending.push(msg);
|
|
3007
3346
|
}
|
|
3008
3347
|
}
|
|
3348
|
+
// 强制推进有变更时,按 contig auto-ack
|
|
3349
|
+
if (forceAdvancedAny) {
|
|
3350
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
3351
|
+
if (contig > 0) {
|
|
3352
|
+
this._transport.call('group.ack_messages', {
|
|
3353
|
+
group_id: groupId,
|
|
3354
|
+
msg_seq: contig,
|
|
3355
|
+
device_id: this._deviceId,
|
|
3356
|
+
slot_id: this._slotId,
|
|
3357
|
+
}).catch((e) => { this._clientLog.debug(`group recovery force-advance ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3009
3360
|
const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
3010
3361
|
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
|
|
3011
3362
|
if (mergedPending.length)
|
|
@@ -3013,26 +3364,62 @@ export class AUNClient {
|
|
|
3013
3364
|
else
|
|
3014
3365
|
this._pendingDecryptMsgs.delete(ns);
|
|
3015
3366
|
}
|
|
3367
|
+
/**
|
|
3368
|
+
* recovery 兜底定时:N 秒后如果 pending queue 仍有未解开消息,强制推进 cursor。
|
|
3369
|
+
* 同一 group 短时间内只调度一次。
|
|
3370
|
+
*/
|
|
3371
|
+
_recoveryTimeoutScheduled = new Map();
|
|
3372
|
+
_scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
|
|
3373
|
+
if (!groupId)
|
|
3374
|
+
return;
|
|
3375
|
+
const now = Date.now();
|
|
3376
|
+
const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
|
|
3377
|
+
if (last && (last + timeoutMs) > now)
|
|
3378
|
+
return;
|
|
3379
|
+
this._recoveryTimeoutScheduled.set(groupId, now);
|
|
3380
|
+
setTimeout(() => {
|
|
3381
|
+
const ns = `group:${groupId}`;
|
|
3382
|
+
const queue = this._pendingDecryptMsgs.get(ns);
|
|
3383
|
+
if (!queue || queue.length === 0)
|
|
3384
|
+
return;
|
|
3385
|
+
this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
|
|
3386
|
+
this._retryPendingDecryptMsgs(groupId, true).catch((exc) => this._clientLog.warn(`group ${groupId} recovery timeout retry failed: ${formatCaughtError(exc)}`));
|
|
3387
|
+
}, timeoutMs).unref?.();
|
|
3388
|
+
}
|
|
3016
3389
|
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
3017
3390
|
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
3018
3391
|
return;
|
|
3019
|
-
this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(
|
|
3392
|
+
this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(`group ${groupId} pending message retry failed: ${formatCaughtError(exc)}`));
|
|
3020
3393
|
}
|
|
3021
3394
|
async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
|
|
3022
|
-
const
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3395
|
+
const tStart = Date.now();
|
|
3396
|
+
this._clientLog.debug(`_recoverGroupEpochKey enter: group=${groupId}, epoch=${epoch}, sender=${senderAid || '-'}, timeout=${timeoutMs}ms`);
|
|
3397
|
+
try {
|
|
3398
|
+
const existing = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3399
|
+
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, existing)) {
|
|
3400
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3401
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group=${groupId} (already_ready)`);
|
|
3402
|
+
return true;
|
|
3403
|
+
}
|
|
3404
|
+
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
3405
|
+
const key = `${groupId}:${epoch}`;
|
|
3406
|
+
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
3407
|
+
if (inflight) {
|
|
3408
|
+
const result = await inflight;
|
|
3409
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group=${groupId} (joined_inflight) result=${result}`);
|
|
3410
|
+
return result;
|
|
3411
|
+
}
|
|
3412
|
+
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
3413
|
+
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
3414
|
+
this._groupEpochRecoveryInflight.set(key, promise);
|
|
3415
|
+
const result = await promise;
|
|
3416
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group=${groupId} result=${result}`);
|
|
3417
|
+
return result;
|
|
3418
|
+
}
|
|
3419
|
+
catch (err) {
|
|
3420
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3421
|
+
throw err;
|
|
3026
3422
|
}
|
|
3027
|
-
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
3028
|
-
const key = `${groupId}:${epoch}`;
|
|
3029
|
-
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
3030
|
-
if (inflight)
|
|
3031
|
-
return inflight;
|
|
3032
|
-
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
3033
|
-
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
3034
|
-
this._groupEpochRecoveryInflight.set(key, promise);
|
|
3035
|
-
return promise;
|
|
3036
3423
|
}
|
|
3037
3424
|
static _extractGroupJoinMode(payload) {
|
|
3038
3425
|
if (!isJsonObject(payload))
|
|
@@ -3125,13 +3512,13 @@ export class AUNClient {
|
|
|
3125
3512
|
const myAid = this._aid || '';
|
|
3126
3513
|
const keyPair = this._keystore.loadKeyPair(myAid);
|
|
3127
3514
|
if (!keyPair?.private_key_pem) {
|
|
3128
|
-
this._clientLog.warn(
|
|
3515
|
+
this._clientLog.warn(`cannot load AID private key for ECIES decrypt: aid=${myAid}`);
|
|
3129
3516
|
return false;
|
|
3130
3517
|
}
|
|
3131
3518
|
const { eciesDecrypt } = await import('./e2ee-group.js');
|
|
3132
3519
|
const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
|
|
3133
3520
|
if (!groupSecret || groupSecret.length !== 32) {
|
|
3134
|
-
this._clientLog.warn(
|
|
3521
|
+
this._clientLog.warn(`server epoch key ECIES decrypt result length abnormal: group=${groupId} epoch=${serverEpoch} len=${groupSecret?.length ?? 0}`);
|
|
3135
3522
|
return false;
|
|
3136
3523
|
}
|
|
3137
3524
|
// 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
|
|
@@ -3168,7 +3555,7 @@ export class AUNClient {
|
|
|
3168
3555
|
}
|
|
3169
3556
|
catch { /* best effort */ }
|
|
3170
3557
|
if (memberAids.length === 0) {
|
|
3171
|
-
this._clientLog.warn(
|
|
3558
|
+
this._clientLog.warn(`server epoch key recovery missing member snapshot: group=${groupId} epoch=${serverEpoch}`);
|
|
3172
3559
|
return false;
|
|
3173
3560
|
}
|
|
3174
3561
|
const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
|
|
@@ -3179,7 +3566,7 @@ export class AUNClient {
|
|
|
3179
3566
|
const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
|
|
3180
3567
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3181
3568
|
if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
|
|
3182
|
-
this._clientLog.warn(
|
|
3569
|
+
this._clientLog.warn(`server epoch key recovery commitment mismatch: group=${groupId} epoch=${serverEpoch}`);
|
|
3183
3570
|
return false;
|
|
3184
3571
|
}
|
|
3185
3572
|
if (epochChain && committedEpoch === serverEpoch) {
|
|
@@ -3195,7 +3582,7 @@ export class AUNClient {
|
|
|
3195
3582
|
const prevChain = String(prevData?.epoch_chain ?? '').trim();
|
|
3196
3583
|
if (prevChain && rotatorAid) {
|
|
3197
3584
|
if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
|
|
3198
|
-
this._clientLog.warn(
|
|
3585
|
+
this._clientLog.warn(`server epoch key recovery epoch_chain verification failed: group=${groupId} epoch=${serverEpoch} rotator=${rotatorAid}`);
|
|
3199
3586
|
return false;
|
|
3200
3587
|
}
|
|
3201
3588
|
epochChainUnverified = false;
|
|
@@ -3208,14 +3595,14 @@ export class AUNClient {
|
|
|
3208
3595
|
}
|
|
3209
3596
|
const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
|
|
3210
3597
|
if (!stored) {
|
|
3211
|
-
this._clientLog.warn(
|
|
3598
|
+
this._clientLog.warn(`server epoch key recovery store failed: group=${groupId} epoch=${serverEpoch}`);
|
|
3212
3599
|
return false;
|
|
3213
3600
|
}
|
|
3214
|
-
this._clientLog.info(
|
|
3601
|
+
this._clientLog.info(`recovered epoch key from server: group=${groupId} epoch=${serverEpoch}`);
|
|
3215
3602
|
return true;
|
|
3216
3603
|
}
|
|
3217
3604
|
catch (exc) {
|
|
3218
|
-
this._clientLog.debug(
|
|
3605
|
+
this._clientLog.debug(`recover epoch key from server failed: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3219
3606
|
return false;
|
|
3220
3607
|
}
|
|
3221
3608
|
}
|
|
@@ -3242,7 +3629,7 @@ export class AUNClient {
|
|
|
3242
3629
|
groupSecretBytes = loaded.secret;
|
|
3243
3630
|
}
|
|
3244
3631
|
else {
|
|
3245
|
-
this._clientLog.debug(
|
|
3632
|
+
this._clientLog.debug(`cannot get group_secret for ECIES encrypt: group=${groupId} epoch=${targetEpoch}`);
|
|
3246
3633
|
return {};
|
|
3247
3634
|
}
|
|
3248
3635
|
}
|
|
@@ -3263,21 +3650,23 @@ export class AUNClient {
|
|
|
3263
3650
|
encryptedKeys[aid] = ciphertext.toString('base64');
|
|
3264
3651
|
}
|
|
3265
3652
|
catch (exc) {
|
|
3266
|
-
this._clientLog.debug(
|
|
3653
|
+
this._clientLog.debug(`building ECIES epoch key for member ${aid} failed: ${formatCaughtError(exc)}`);
|
|
3267
3654
|
continue;
|
|
3268
3655
|
}
|
|
3269
3656
|
}
|
|
3270
3657
|
return encryptedKeys;
|
|
3271
3658
|
}
|
|
3272
3659
|
catch (exc) {
|
|
3273
|
-
this._clientLog.debug(
|
|
3660
|
+
this._clientLog.debug(`building encrypted_keys failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
3274
3661
|
return {};
|
|
3275
3662
|
}
|
|
3276
3663
|
}
|
|
3277
3664
|
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
3665
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} key recovery start: sender=${senderAid || '-'}, timeout=${timeoutMs}ms`);
|
|
3278
3666
|
// 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
|
|
3279
3667
|
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3280
3668
|
if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
|
|
3669
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} recovered from server`);
|
|
3281
3670
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3282
3671
|
return true;
|
|
3283
3672
|
}
|
|
@@ -3308,16 +3697,18 @@ export class AUNClient {
|
|
|
3308
3697
|
}
|
|
3309
3698
|
}
|
|
3310
3699
|
catch {
|
|
3311
|
-
this._clientLog.debug(
|
|
3700
|
+
this._clientLog.debug(`group ${groupId} query online members failed, falling back to all candidates`);
|
|
3312
3701
|
}
|
|
3313
3702
|
if (onlineAids !== null) {
|
|
3314
3703
|
if (onlineAids.length === 0) {
|
|
3315
|
-
this._clientLog.info(
|
|
3704
|
+
this._clientLog.info(`group ${groupId} epoch ${String(epoch)} recovery failed: no online members to request key`);
|
|
3316
3705
|
return false;
|
|
3317
3706
|
}
|
|
3707
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery: requesting from ${onlineAids.length} online members`);
|
|
3318
3708
|
await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
|
|
3319
3709
|
}
|
|
3320
3710
|
else {
|
|
3711
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery: requesting from all candidates`);
|
|
3321
3712
|
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
3322
3713
|
}
|
|
3323
3714
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -3325,14 +3716,20 @@ export class AUNClient {
|
|
|
3325
3716
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
3326
3717
|
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3327
3718
|
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret)) {
|
|
3719
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery success`);
|
|
3328
3720
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3329
3721
|
return true;
|
|
3330
3722
|
}
|
|
3331
3723
|
}
|
|
3332
3724
|
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3333
3725
|
const ready = await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret);
|
|
3334
|
-
if (ready)
|
|
3726
|
+
if (ready) {
|
|
3727
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery success (post-deadline check)`);
|
|
3335
3728
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3729
|
+
}
|
|
3730
|
+
else {
|
|
3731
|
+
this._clientLog.warn(`group ${groupId} epoch ${epoch} P2P key recovery timeout: timeout=${timeoutMs}ms`);
|
|
3732
|
+
}
|
|
3336
3733
|
return ready;
|
|
3337
3734
|
}
|
|
3338
3735
|
/** 只向在线成员发送密钥恢复请求 */
|
|
@@ -3382,48 +3779,70 @@ export class AUNClient {
|
|
|
3382
3779
|
}
|
|
3383
3780
|
}
|
|
3384
3781
|
async _decryptGroupMessage(message, opts) {
|
|
3385
|
-
const
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
3411
|
-
const groupId = String(message.group_id ?? '');
|
|
3412
|
-
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
3413
|
-
const epoch = Number(payloadObj.epoch ?? 0);
|
|
3414
|
-
if (epoch > 0 && groupId) {
|
|
3415
|
-
try {
|
|
3416
|
-
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
3417
|
-
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
3418
|
-
if (retry !== null && retry.e2ee)
|
|
3419
|
-
return this._attachGroupDispatchModeToPayload(retry);
|
|
3782
|
+
const tStart = Date.now();
|
|
3783
|
+
const groupIdLog = String(message.group_id ?? '');
|
|
3784
|
+
const senderLog = String(message.from ?? message.sender_aid ?? '');
|
|
3785
|
+
this._clientLog.debug(`_decryptGroupMessage enter: group=${groupIdLog}, from=${senderLog}, mid=${String(message.message_id ?? '')}`);
|
|
3786
|
+
try {
|
|
3787
|
+
const payload = message.payload;
|
|
3788
|
+
if (!isJsonObject(payload)) {
|
|
3789
|
+
const r = this._attachGroupDispatchModeToPayload(message);
|
|
3790
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
3791
|
+
return r;
|
|
3792
|
+
}
|
|
3793
|
+
const payloadObj = payload;
|
|
3794
|
+
if (payloadObj.type !== 'e2ee.group_encrypted') {
|
|
3795
|
+
const r = this._attachGroupDispatchModeToPayload(message);
|
|
3796
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (not group_encrypted)`);
|
|
3797
|
+
return r;
|
|
3798
|
+
}
|
|
3799
|
+
// 确保发送方证书已缓存(签名验证需要)
|
|
3800
|
+
const senderAid = String(message.from ?? message.sender_aid ?? '');
|
|
3801
|
+
if (senderAid) {
|
|
3802
|
+
const certOk = await this._ensureSenderCertCached(senderAid);
|
|
3803
|
+
if (!certOk) {
|
|
3804
|
+
this._clientLog.warn(`group message decrypt skipped: sender ${senderAid} cert unavailable`);
|
|
3805
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (cert_unavailable)`);
|
|
3806
|
+
return message;
|
|
3420
3807
|
}
|
|
3421
3808
|
}
|
|
3422
|
-
|
|
3423
|
-
|
|
3809
|
+
// 先尝试直接解密
|
|
3810
|
+
const result = this._groupE2ee.decrypt(message, opts);
|
|
3811
|
+
if (result !== null && result.e2ee) {
|
|
3812
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (direct_ok)`);
|
|
3813
|
+
return this._attachGroupDispatchModeToPayload(result);
|
|
3814
|
+
}
|
|
3815
|
+
// replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
|
|
3816
|
+
// 不是解密失败,不应触发 recover
|
|
3817
|
+
if (result !== null) {
|
|
3818
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (replay_guard)`);
|
|
3819
|
+
return result;
|
|
3820
|
+
}
|
|
3821
|
+
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
3822
|
+
const groupId = String(message.group_id ?? '');
|
|
3823
|
+
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
3824
|
+
const epoch = Number(payloadObj.epoch ?? 0);
|
|
3825
|
+
if (epoch > 0 && groupId) {
|
|
3826
|
+
try {
|
|
3827
|
+
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
3828
|
+
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
3829
|
+
if (retry !== null && retry.e2ee) {
|
|
3830
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (recover_ok)`);
|
|
3831
|
+
return this._attachGroupDispatchModeToPayload(retry);
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
catch (exc) {
|
|
3836
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} sync recovery failed: ${formatCaughtError(exc)}`);
|
|
3837
|
+
}
|
|
3424
3838
|
}
|
|
3839
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (fallback_plain)`);
|
|
3840
|
+
return message;
|
|
3841
|
+
}
|
|
3842
|
+
catch (err) {
|
|
3843
|
+
this._clientLog.debug(`_decryptGroupMessage exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3844
|
+
throw err;
|
|
3425
3845
|
}
|
|
3426
|
-
return message;
|
|
3427
3846
|
}
|
|
3428
3847
|
_attachGroupDispatchModeToPayload(message) {
|
|
3429
3848
|
const payload = message.payload;
|
|
@@ -3541,7 +3960,7 @@ export class AUNClient {
|
|
|
3541
3960
|
if (fromAid) {
|
|
3542
3961
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
3543
3962
|
if (!certReady) {
|
|
3544
|
-
this._clientLog.warn(
|
|
3963
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping message.thought.get decrypt`);
|
|
3545
3964
|
decryptFailed = true;
|
|
3546
3965
|
}
|
|
3547
3966
|
}
|
|
@@ -3602,6 +4021,7 @@ export class AUNClient {
|
|
|
3602
4021
|
const failed = [];
|
|
3603
4022
|
let lastHeartbeat = Date.now();
|
|
3604
4023
|
const distributions = (Array.isArray(info.distributions) ? info.distributions : []);
|
|
4024
|
+
this._clientLog.debug(`epoch key distribution start: rotation=${rotationId}, target_members=${distributions.length}`);
|
|
3605
4025
|
for (const dist of distributions) {
|
|
3606
4026
|
if (!isJsonObject(dist) || !dist.to || !isJsonObject(dist.payload))
|
|
3607
4027
|
continue;
|
|
@@ -3626,7 +4046,7 @@ export class AUNClient {
|
|
|
3626
4046
|
}
|
|
3627
4047
|
else {
|
|
3628
4048
|
failed.push(String(dist.to));
|
|
3629
|
-
this._clientLog.warn(`epoch
|
|
4049
|
+
this._clientLog.warn(`epoch key distribution failed (to=${dist.to}): ${formatCaughtError(exc)}`);
|
|
3630
4050
|
}
|
|
3631
4051
|
}
|
|
3632
4052
|
}
|
|
@@ -3644,23 +4064,26 @@ export class AUNClient {
|
|
|
3644
4064
|
return isJsonObject(result) && result.success === true;
|
|
3645
4065
|
}
|
|
3646
4066
|
catch (exc) {
|
|
3647
|
-
this._clientLog.warn(
|
|
4067
|
+
this._clientLog.warn(`refresh epoch rotation lease failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3648
4068
|
return false;
|
|
3649
4069
|
}
|
|
3650
4070
|
}
|
|
3651
4071
|
async _ackGroupRotationKey(rotationId, keyCommitment, deviceId) {
|
|
3652
4072
|
if (!rotationId)
|
|
3653
4073
|
return false;
|
|
4074
|
+
this._clientLog.debug(`_ackGroupRotationKey enter: rotation=${rotationId}, commitment=${keyCommitment}, device=${deviceId ?? this._deviceId}`);
|
|
3654
4075
|
try {
|
|
3655
4076
|
const result = await this.call('group.e2ee.ack_rotation_key', {
|
|
3656
4077
|
rotation_id: rotationId,
|
|
3657
4078
|
key_commitment: keyCommitment,
|
|
3658
4079
|
device_id: deviceId ?? this._deviceId,
|
|
3659
4080
|
});
|
|
3660
|
-
|
|
4081
|
+
const success = isJsonObject(result) && result.success === true;
|
|
4082
|
+
this._clientLog.debug(`_ackGroupRotationKey done: rotation=${rotationId}, success=${success}`);
|
|
4083
|
+
return success;
|
|
3661
4084
|
}
|
|
3662
4085
|
catch (exc) {
|
|
3663
|
-
this._clientLog.warn(
|
|
4086
|
+
this._clientLog.warn(`submit epoch key ack failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3664
4087
|
return false;
|
|
3665
4088
|
}
|
|
3666
4089
|
}
|
|
@@ -3669,6 +4092,11 @@ export class AUNClient {
|
|
|
3669
4092
|
const groupId = String(payload.group_id ?? '').trim();
|
|
3670
4093
|
if (!groupId)
|
|
3671
4094
|
return false;
|
|
4095
|
+
// 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
|
|
4096
|
+
if (!this._groupSynced.has(groupId)) {
|
|
4097
|
+
this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
|
|
4098
|
+
return true;
|
|
4099
|
+
}
|
|
3672
4100
|
const epoch = Number(payload.epoch ?? 0);
|
|
3673
4101
|
if (!Number.isFinite(epoch) || epoch <= 0)
|
|
3674
4102
|
return false;
|
|
@@ -3688,7 +4116,7 @@ export class AUNClient {
|
|
|
3688
4116
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3689
4117
|
: [];
|
|
3690
4118
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3691
|
-
this._clientLog.debug(
|
|
4119
|
+
this._clientLog.debug(`allowing group key distribution: new member recovery commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3692
4120
|
}
|
|
3693
4121
|
else {
|
|
3694
4122
|
return false;
|
|
@@ -3697,7 +4125,7 @@ export class AUNClient {
|
|
|
3697
4125
|
}
|
|
3698
4126
|
return true;
|
|
3699
4127
|
}
|
|
3700
|
-
this._clientLog.info(
|
|
4128
|
+
this._clientLog.info(`rejecting future epoch key distribution missing rotation_id: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3701
4129
|
return false;
|
|
3702
4130
|
}
|
|
3703
4131
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -3718,10 +4146,10 @@ export class AUNClient {
|
|
|
3718
4146
|
}
|
|
3719
4147
|
}
|
|
3720
4148
|
catch (exc) {
|
|
3721
|
-
this._clientLog.warn(
|
|
4149
|
+
this._clientLog.warn(`rejecting epoch key distribution: cannot verify active rotation: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3722
4150
|
return false;
|
|
3723
4151
|
}
|
|
3724
|
-
this._clientLog.info(
|
|
4152
|
+
this._clientLog.info(`rejecting epoch key distribution: rotation not in pending/committed state: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
|
|
3725
4153
|
return false;
|
|
3726
4154
|
}
|
|
3727
4155
|
async _discardGroupDistributionIfStale(payload) {
|
|
@@ -3736,10 +4164,10 @@ export class AUNClient {
|
|
|
3736
4164
|
return;
|
|
3737
4165
|
try {
|
|
3738
4166
|
this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
|
|
3739
|
-
this._clientLog.info(
|
|
4167
|
+
this._clientLog.info(`discarding stale group epoch key after verify: group=${groupId} epoch=${epoch} rotation=${rotationId}`);
|
|
3740
4168
|
}
|
|
3741
4169
|
catch (exc) {
|
|
3742
|
-
this._clientLog.debug(
|
|
4170
|
+
this._clientLog.debug(`cleanup stale group epoch key failed: group=${groupId} epoch=${epoch} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3743
4171
|
}
|
|
3744
4172
|
}
|
|
3745
4173
|
async _verifyGroupKeyResponseEpoch(payload) {
|
|
@@ -3756,7 +4184,7 @@ export class AUNClient {
|
|
|
3756
4184
|
return false;
|
|
3757
4185
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
3758
4186
|
if (epoch > committedEpoch) {
|
|
3759
|
-
this._clientLog.info(
|
|
4187
|
+
this._clientLog.info(`rejecting uncommitted epoch group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3760
4188
|
return false;
|
|
3761
4189
|
}
|
|
3762
4190
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
@@ -3767,7 +4195,7 @@ export class AUNClient {
|
|
|
3767
4195
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3768
4196
|
: [];
|
|
3769
4197
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3770
|
-
this._clientLog.debug(
|
|
4198
|
+
this._clientLog.debug(`allowing group key response: new member recovery commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3771
4199
|
}
|
|
3772
4200
|
else {
|
|
3773
4201
|
return false;
|
|
@@ -3777,7 +4205,7 @@ export class AUNClient {
|
|
|
3777
4205
|
return true;
|
|
3778
4206
|
}
|
|
3779
4207
|
catch (exc) {
|
|
3780
|
-
this._clientLog.warn(
|
|
4208
|
+
this._clientLog.warn(`rejecting group key response: cannot verify committed epoch: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3781
4209
|
return false;
|
|
3782
4210
|
}
|
|
3783
4211
|
}
|
|
@@ -3792,7 +4220,7 @@ export class AUNClient {
|
|
|
3792
4220
|
return isJsonObject(result) && result.success === true;
|
|
3793
4221
|
}
|
|
3794
4222
|
catch (exc) {
|
|
3795
|
-
this._clientLog.warn(
|
|
4223
|
+
this._clientLog.warn(`abort epoch rotation failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3796
4224
|
return false;
|
|
3797
4225
|
}
|
|
3798
4226
|
}
|
|
@@ -3899,11 +4327,11 @@ export class AUNClient {
|
|
|
3899
4327
|
catch (exc) {
|
|
3900
4328
|
if (attempt < maxRetries) {
|
|
3901
4329
|
const delay = 500 * Math.pow(2, attempt - 1);
|
|
3902
|
-
this._clientLog.warn(
|
|
4330
|
+
this._clientLog.warn(`sync epoch to server failed (group=${groupId}, attempt ${attempt}/${maxRetries}): ${formatCaughtError(exc)}, retrying in ${delay}ms`);
|
|
3903
4331
|
await new Promise(r => setTimeout(r, delay));
|
|
3904
4332
|
}
|
|
3905
4333
|
else {
|
|
3906
|
-
this._clientLog.error(
|
|
4334
|
+
this._clientLog.error(`sync epoch to server final failure (group=${groupId}, retried ${maxRetries} times): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3907
4335
|
}
|
|
3908
4336
|
}
|
|
3909
4337
|
}
|
|
@@ -3917,9 +4345,13 @@ export class AUNClient {
|
|
|
3917
4345
|
* 使用服务端两阶段 rotation,避免服务端先提交但密钥未分发。
|
|
3918
4346
|
*/
|
|
3919
4347
|
async _rotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
4348
|
+
const tStart = Date.now();
|
|
4349
|
+
this._clientLog.debug(`_rotateGroupEpoch enter: group=${groupId}, trigger=${triggerId || '-'}, expectedEpoch=${String(expectedEpoch)}`);
|
|
3920
4350
|
try {
|
|
3921
|
-
if (!this._aid)
|
|
4351
|
+
if (!this._aid) {
|
|
4352
|
+
this._clientLog.debug(`_rotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (no_aid)`);
|
|
3922
4353
|
return;
|
|
4354
|
+
}
|
|
3923
4355
|
const memberAids = await this._getGroupMemberAids(groupId);
|
|
3924
4356
|
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
3925
4357
|
return;
|
|
@@ -3965,7 +4397,7 @@ export class AUNClient {
|
|
|
3965
4397
|
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
3966
4398
|
if (rawChain) {
|
|
3967
4399
|
prevChainHint = rawChain;
|
|
3968
|
-
this._clientLog.info(
|
|
4400
|
+
this._clientLog.info(`new member rotation supplementing prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
|
|
3969
4401
|
}
|
|
3970
4402
|
}
|
|
3971
4403
|
}
|
|
@@ -3977,7 +4409,7 @@ export class AUNClient {
|
|
|
3977
4409
|
this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
|
|
3978
4410
|
}
|
|
3979
4411
|
catch (cleanupExc) {
|
|
3980
|
-
this._clientLog.debug(
|
|
4412
|
+
this._clientLog.debug(`cleanup local pending group key failed: group=${groupId} epoch=${targetEpoch} rotation=${rotationId} err=${formatCaughtError(cleanupExc)}`);
|
|
3981
4413
|
}
|
|
3982
4414
|
};
|
|
3983
4415
|
const rotateParams = {
|
|
@@ -4105,13 +4537,17 @@ export class AUNClient {
|
|
|
4105
4537
|
this._groupMembershipRotationDone = new Set(Array.from(this._groupMembershipRotationDone).slice(-1000));
|
|
4106
4538
|
}
|
|
4107
4539
|
}
|
|
4540
|
+
this._clientLog.debug(`_rotateGroupEpoch done: group=${groupId}, targetEpoch=${targetEpoch}, rotation=${activeRotationId}, trigger=${triggerId || '-'}`);
|
|
4541
|
+
this._clientLog.debug(`_rotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
4108
4542
|
}
|
|
4109
4543
|
catch (exc) {
|
|
4110
4544
|
this._logE2eeError('rotate_epoch', groupId, '', exc);
|
|
4545
|
+
this._clientLog.debug(`_rotateGroupEpoch exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${exc instanceof Error ? exc.message : String(exc)}`);
|
|
4111
4546
|
}
|
|
4112
4547
|
}
|
|
4113
4548
|
/** 将当前 group_secret 通过 P2P E2EE 分发给新成员 */
|
|
4114
4549
|
async _distributeKeyToNewMember(groupId, newMemberAid) {
|
|
4550
|
+
this._clientLog.debug(`_distributeKeyToNewMember enter: group=${groupId}, new_member=${newMemberAid}`);
|
|
4115
4551
|
try {
|
|
4116
4552
|
const secretData = this._groupE2ee.loadSecret(groupId);
|
|
4117
4553
|
if (secretData === null)
|
|
@@ -4142,16 +4578,19 @@ export class AUNClient {
|
|
|
4142
4578
|
// 重试 3 次,间隔递增(1s, 2s)
|
|
4143
4579
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4144
4580
|
try {
|
|
4581
|
+
this._clientLog.debug(`_distributeKeyToNewMember send attempt: group=${groupId}, new_member=${newMemberAid}, attempt=${attempt + 1}/3, epoch=${epoch}`);
|
|
4145
4582
|
await this.call('message.send', {
|
|
4146
4583
|
to: newMemberAid,
|
|
4147
4584
|
payload: distPayload,
|
|
4148
4585
|
encrypt: true,
|
|
4149
4586
|
persist_required: true,
|
|
4150
4587
|
});
|
|
4588
|
+
this._clientLog.debug(`_distributeKeyToNewMember success: group=${groupId}, new_member=${newMemberAid}, epoch=${epoch}`);
|
|
4151
4589
|
break; // 成功则跳出重试循环
|
|
4152
4590
|
}
|
|
4153
4591
|
catch (sendExc) {
|
|
4154
4592
|
if (attempt < 2) {
|
|
4593
|
+
this._clientLog.debug(`_distributeKeyToNewMember attempt failed, will retry: group=${groupId}, new_member=${newMemberAid}, attempt=${attempt + 1}/3, err=${formatCaughtError(sendExc)}`);
|
|
4155
4594
|
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
|
4156
4595
|
}
|
|
4157
4596
|
else {
|
|
@@ -4215,7 +4654,9 @@ export class AUNClient {
|
|
|
4215
4654
|
static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
|
|
4216
4655
|
/** open/invite_code 入群后延迟轮换。 */
|
|
4217
4656
|
async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
|
|
4218
|
-
|
|
4657
|
+
const delay = delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS;
|
|
4658
|
+
this._clientLog.debug(`_delayedRotateAfterJoin enter: group=${groupId}, trigger_id=${triggerId}, expected_epoch=${expectedEpoch}, allow_member=${allowMember}, delay_ms=${delay}`);
|
|
4659
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
4219
4660
|
await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
|
|
4220
4661
|
}
|
|
4221
4662
|
/** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
|
|
@@ -4224,8 +4665,11 @@ export class AUNClient {
|
|
|
4224
4665
|
.filter(aid => aid && aid !== this._aid);
|
|
4225
4666
|
if (!groupId || !this._aid || memberAids.length === 0)
|
|
4226
4667
|
return;
|
|
4227
|
-
if (!this._groupE2ee.hasSecret(groupId))
|
|
4668
|
+
if (!this._groupE2ee.hasSecret(groupId)) {
|
|
4669
|
+
this._clientLog.debug(`backfill skipped: group=${groupId}, no local epoch key`);
|
|
4228
4670
|
return;
|
|
4671
|
+
}
|
|
4672
|
+
this._clientLog.debug(`backfill key distribution start: group=${groupId}, target_members=${JSON.stringify(memberAids)}`);
|
|
4229
4673
|
for (const memberAid of memberAids) {
|
|
4230
4674
|
const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
|
|
4231
4675
|
if (this._groupMemberKeyBackfillDone.has(dedupeKey))
|
|
@@ -4308,7 +4752,7 @@ export class AUNClient {
|
|
|
4308
4752
|
}
|
|
4309
4753
|
}
|
|
4310
4754
|
catch (exc) {
|
|
4311
|
-
this._clientLog.warn(
|
|
4755
|
+
this._clientLog.warn(`restore SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
4312
4756
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
4313
4757
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4314
4758
|
phase: 'restore',
|
|
@@ -4348,7 +4792,7 @@ export class AUNClient {
|
|
|
4348
4792
|
delete newState[oldNs];
|
|
4349
4793
|
newState[newNs] = Math.max(oldVal, curVal);
|
|
4350
4794
|
}
|
|
4351
|
-
this._clientLog.info(`SeqTracker group_id
|
|
4795
|
+
this._clientLog.info(`SeqTracker group_id migration: ${Object.keys(renameMap).length} namespaces rewritten`);
|
|
4352
4796
|
// 落盘
|
|
4353
4797
|
const saver = this._keystore.saveSeq;
|
|
4354
4798
|
const deleter = this._keystore.deleteSeq;
|
|
@@ -4359,14 +4803,14 @@ export class AUNClient {
|
|
|
4359
4803
|
deleter.call(this._keystore, this._aid, this._deviceId, this._slotId, oldNs);
|
|
4360
4804
|
}
|
|
4361
4805
|
catch (e) {
|
|
4362
|
-
this._clientLog.debug(
|
|
4806
|
+
this._clientLog.debug(`delete old seq ns failed: ns=${oldNs} err=${formatCaughtError(e)}`);
|
|
4363
4807
|
}
|
|
4364
4808
|
}
|
|
4365
4809
|
try {
|
|
4366
4810
|
saver.call(this._keystore, this._aid, this._deviceId, this._slotId, newNs, newState[newNs]);
|
|
4367
4811
|
}
|
|
4368
4812
|
catch (e) {
|
|
4369
|
-
this._clientLog.debug(
|
|
4813
|
+
this._clientLog.debug(`write new seq ns failed: ns=${newNs} err=${formatCaughtError(e)}`);
|
|
4370
4814
|
}
|
|
4371
4815
|
}
|
|
4372
4816
|
}
|
|
@@ -4426,7 +4870,7 @@ export class AUNClient {
|
|
|
4426
4870
|
}
|
|
4427
4871
|
}
|
|
4428
4872
|
catch (exc) {
|
|
4429
|
-
this._clientLog.warn(
|
|
4873
|
+
this._clientLog.warn(`save SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
4430
4874
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
4431
4875
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4432
4876
|
phase: 'save',
|
|
@@ -4481,19 +4925,25 @@ export class AUNClient {
|
|
|
4481
4925
|
// ── 内部:连接 ────────────────────────────────────────────
|
|
4482
4926
|
/** 执行一次连接流程 */
|
|
4483
4927
|
async _connectOnce(params, allowReauth) {
|
|
4928
|
+
const tStart = Date.now();
|
|
4484
4929
|
const gatewayUrl = this._resolveGateway(params);
|
|
4485
4930
|
this._gatewayUrl = gatewayUrl;
|
|
4486
4931
|
this._slotId = String(params.slot_id ?? '');
|
|
4487
4932
|
this._connectDeliveryMode = { ...(params.delivery_mode ?? this._connectDeliveryMode) };
|
|
4933
|
+
const extraInfo = (params.extra_info && typeof params.extra_info === 'object' && !Array.isArray(params.extra_info))
|
|
4934
|
+
? params.extra_info
|
|
4935
|
+
: undefined;
|
|
4488
4936
|
const prevState = this._state;
|
|
4489
4937
|
this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
|
|
4490
4938
|
this._state = 'connecting';
|
|
4939
|
+
this._clientLog.debug(`_connectOnce enter: gateway=${gatewayUrl}, allowReauth=${allowReauth}`);
|
|
4491
4940
|
// 前置 restore:在 transport.connect 启动 reader 之前完成,
|
|
4492
4941
|
// 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
|
|
4493
4942
|
this._refreshSeqTrackerContext();
|
|
4494
4943
|
this._restoreSeqTrackerState();
|
|
4495
4944
|
try {
|
|
4496
4945
|
const challenge = await this._transport.connect(gatewayUrl);
|
|
4946
|
+
this._clientLog.debug(`WebSocket connection established: gateway=${gatewayUrl}`);
|
|
4497
4947
|
this._state = 'authenticating';
|
|
4498
4948
|
if (allowReauth) {
|
|
4499
4949
|
const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
|
|
@@ -4501,6 +4951,9 @@ export class AUNClient {
|
|
|
4501
4951
|
deviceId: this._deviceId,
|
|
4502
4952
|
slotId: this._slotId,
|
|
4503
4953
|
deliveryMode: this._connectDeliveryMode,
|
|
4954
|
+
connectionKind: String(params.connection_kind ?? 'long'),
|
|
4955
|
+
shortTtlMs: Number(params.short_ttl_ms ?? 0),
|
|
4956
|
+
extraInfo,
|
|
4504
4957
|
});
|
|
4505
4958
|
if (isJsonObject(authContext)) {
|
|
4506
4959
|
const auth = authContext;
|
|
@@ -4521,10 +4974,14 @@ export class AUNClient {
|
|
|
4521
4974
|
deviceId: this._deviceId,
|
|
4522
4975
|
slotId: this._slotId,
|
|
4523
4976
|
deliveryMode: this._connectDeliveryMode,
|
|
4977
|
+
connectionKind: String(params.connection_kind ?? 'long'),
|
|
4978
|
+
shortTtlMs: Number(params.short_ttl_ms ?? 0),
|
|
4979
|
+
extraInfo,
|
|
4524
4980
|
});
|
|
4525
4981
|
this._syncIdentityAfterConnect(String(params.access_token));
|
|
4526
4982
|
}
|
|
4527
4983
|
this._state = 'connected';
|
|
4984
|
+
this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
|
|
4528
4985
|
await this._dispatcher.publish('connection.state', { state: this._state, gateway: gatewayUrl });
|
|
4529
4986
|
// auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
|
|
4530
4987
|
// 若 context 发生变化,重新 refresh + restore,保持 tracker 与真实身份一致。
|
|
@@ -4538,11 +4995,18 @@ export class AUNClient {
|
|
|
4538
4995
|
await this._uploadPrekey();
|
|
4539
4996
|
}
|
|
4540
4997
|
catch (exc) {
|
|
4541
|
-
this._clientLog.warn(`prekey
|
|
4998
|
+
this._clientLog.warn(`prekey upload failed: ${formatCaughtError(exc)}`);
|
|
4542
4999
|
}
|
|
5000
|
+
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
5001
|
+
// 群消息按惰性触发,不在此处主动 pull
|
|
5002
|
+
void this._fillP2pGap().catch((exc) => {
|
|
5003
|
+
this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
|
|
5004
|
+
});
|
|
5005
|
+
this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl}, aid=${this._aid ?? ''}`);
|
|
4543
5006
|
}
|
|
4544
5007
|
catch (err) {
|
|
4545
5008
|
this._state = prevState === 'connected' ? 'disconnected' : 'idle';
|
|
5009
|
+
this._clientLog.debug(`_connectOnce exit (error): elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl} err=${err instanceof Error ? err.message : String(err)}`);
|
|
4546
5010
|
throw err;
|
|
4547
5011
|
}
|
|
4548
5012
|
}
|
|
@@ -4624,16 +5088,29 @@ export class AUNClient {
|
|
|
4624
5088
|
if ('timeouts' in request && request.timeouts != null && !isJsonObject(request.timeouts)) {
|
|
4625
5089
|
throw new ValidationError('timeouts must be a dict');
|
|
4626
5090
|
}
|
|
5091
|
+
// 长短连接参数校验
|
|
5092
|
+
const connectionKind = String(request.connection_kind ?? 'long');
|
|
5093
|
+
if (connectionKind !== 'long' && connectionKind !== 'short') {
|
|
5094
|
+
throw new ValidationError(`connection_kind must be "long" or "short", got "${connectionKind}"`);
|
|
5095
|
+
}
|
|
5096
|
+
request.connection_kind = connectionKind;
|
|
5097
|
+
const shortTtlMs = Number(request.short_ttl_ms ?? 0);
|
|
5098
|
+
if (!Number.isFinite(shortTtlMs) || shortTtlMs < 0 || Math.floor(shortTtlMs) !== shortTtlMs) {
|
|
5099
|
+
throw new ValidationError('short_ttl_ms must be a non-negative integer');
|
|
5100
|
+
}
|
|
5101
|
+
request.short_ttl_ms = connectionKind === 'short' ? shortTtlMs : 0;
|
|
4627
5102
|
return request;
|
|
4628
5103
|
}
|
|
4629
5104
|
/** 从参数构建会话选项 */
|
|
4630
5105
|
_buildSessionOptions(params) {
|
|
5106
|
+
const connectionKind = String(params.connection_kind ?? 'long');
|
|
4631
5107
|
const options = {
|
|
4632
|
-
auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
|
|
5108
|
+
auto_reconnect: connectionKind === 'short' ? false : DEFAULT_SESSION_OPTIONS.auto_reconnect,
|
|
4633
5109
|
heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
|
|
4634
5110
|
token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
|
|
4635
5111
|
retry: { ...DEFAULT_SESSION_OPTIONS.retry },
|
|
4636
5112
|
timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
|
|
5113
|
+
connection_kind: connectionKind,
|
|
4637
5114
|
};
|
|
4638
5115
|
if ('auto_reconnect' in params)
|
|
4639
5116
|
options.auto_reconnect = Boolean(params.auto_reconnect);
|
|
@@ -4652,6 +5129,9 @@ export class AUNClient {
|
|
|
4652
5129
|
// ── 内部:后台任务 ────────────────────────────────────────
|
|
4653
5130
|
/** 启动所有后台任务 */
|
|
4654
5131
|
_startBackgroundTasks() {
|
|
5132
|
+
// 短连接生命周期短,禁用心跳与 token 刷新(不接收推送、不需要长期会话维护)
|
|
5133
|
+
if (this._sessionOptions.connection_kind === 'short')
|
|
5134
|
+
return;
|
|
4655
5135
|
this._startHeartbeatTask();
|
|
4656
5136
|
this._startTokenRefreshTask();
|
|
4657
5137
|
this._startGroupEpochTasks();
|
|
@@ -4708,10 +5188,10 @@ export class AUNClient {
|
|
|
4708
5188
|
consecutiveFailures = 0;
|
|
4709
5189
|
}).catch((exc) => {
|
|
4710
5190
|
consecutiveFailures++;
|
|
4711
|
-
this._clientLog.warn(
|
|
5191
|
+
this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
|
|
4712
5192
|
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) }).catch(() => { });
|
|
4713
5193
|
if (consecutiveFailures >= maxFailures) {
|
|
4714
|
-
this._clientLog.warn(
|
|
5194
|
+
this._clientLog.warn(`${maxFailures} consecutive heartbeat failures, triggering reconnect`);
|
|
4715
5195
|
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
4716
5196
|
}
|
|
4717
5197
|
});
|
|
@@ -4775,7 +5255,7 @@ export class AUNClient {
|
|
|
4775
5255
|
if (exc instanceof AuthError) {
|
|
4776
5256
|
this._tokenRefreshFailures++;
|
|
4777
5257
|
if (this._tokenRefreshFailures >= 3) {
|
|
4778
|
-
this._clientLog.warn(`token
|
|
5258
|
+
this._clientLog.warn(`token refresh failed ${this._tokenRefreshFailures} consecutive times, stopping refresh loop and triggering reconnect`);
|
|
4779
5259
|
await this._dispatcher.publish('token.refresh_exhausted', {
|
|
4780
5260
|
aid: this._identity?.aid ?? null,
|
|
4781
5261
|
consecutive_failures: this._tokenRefreshFailures,
|
|
@@ -4785,7 +5265,7 @@ export class AUNClient {
|
|
|
4785
5265
|
this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
|
|
4786
5266
|
return;
|
|
4787
5267
|
}
|
|
4788
|
-
this._clientLog.debug(`token
|
|
5268
|
+
this._clientLog.debug(`token refresh failed (${this._tokenRefreshFailures}/3), will retry: ${exc}`);
|
|
4789
5269
|
}
|
|
4790
5270
|
else {
|
|
4791
5271
|
await this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
@@ -4875,22 +5355,17 @@ export class AUNClient {
|
|
|
4875
5355
|
const prekeyId = this._extractConsumedPrekeyId(message);
|
|
4876
5356
|
if (!prekeyId || this._state !== 'connected')
|
|
4877
5357
|
return;
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
// 同一时刻只允许一个 put_prekey inflight
|
|
4881
|
-
if (this._prekeyReplenishInflight.size > 0)
|
|
5358
|
+
// 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
|
|
5359
|
+
if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
|
|
4882
5360
|
return;
|
|
4883
|
-
|
|
5361
|
+
// 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
|
|
5362
|
+
this._activePrekeyId = '';
|
|
4884
5363
|
void (async () => {
|
|
4885
5364
|
try {
|
|
4886
5365
|
await this._uploadPrekey();
|
|
4887
|
-
this._prekeyReplenished.add(prekeyId);
|
|
4888
5366
|
}
|
|
4889
5367
|
catch (exc) {
|
|
4890
|
-
this._clientLog.warn(
|
|
4891
|
-
}
|
|
4892
|
-
finally {
|
|
4893
|
-
this._prekeyReplenishInflight.delete(prekeyId);
|
|
5368
|
+
this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${formatCaughtError(exc)}`);
|
|
4894
5369
|
}
|
|
4895
5370
|
})();
|
|
4896
5371
|
}
|
|
@@ -4912,7 +5387,7 @@ export class AUNClient {
|
|
|
4912
5387
|
}
|
|
4913
5388
|
}
|
|
4914
5389
|
catch (exc) {
|
|
4915
|
-
this._clientLog.warn(`epoch
|
|
5390
|
+
this._clientLog.warn(`epoch cleanup failed: ${formatCaughtError(exc)}`);
|
|
4916
5391
|
}
|
|
4917
5392
|
}, 3600_000);
|
|
4918
5393
|
this._unrefTimer(this._groupEpochCleanupTimer);
|
|
@@ -4928,11 +5403,11 @@ export class AUNClient {
|
|
|
4928
5403
|
? this._keystore.listGroupSecretIds(this._aid)
|
|
4929
5404
|
: [];
|
|
4930
5405
|
for (const gid of groupIds) {
|
|
4931
|
-
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch
|
|
5406
|
+
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch rotation failed: ${formatCaughtError(exc)}`));
|
|
4932
5407
|
}
|
|
4933
5408
|
}
|
|
4934
5409
|
catch (exc) {
|
|
4935
|
-
this._clientLog.warn(`epoch
|
|
5410
|
+
this._clientLog.warn(`epoch rotation failed: ${formatCaughtError(exc)}`);
|
|
4936
5411
|
}
|
|
4937
5412
|
}, rotateInterval * 1000);
|
|
4938
5413
|
this._unrefTimer(this._groupEpochRotateTimer);
|
|
@@ -4975,13 +5450,34 @@ export class AUNClient {
|
|
|
4975
5450
|
}
|
|
4976
5451
|
// ── 内部:断线重连 ────────────────────────────────────────
|
|
4977
5452
|
/** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
|
|
4978
|
-
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011]);
|
|
4979
|
-
/** 处理服务端主动断开通知 event/gateway.disconnect
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
5453
|
+
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
|
|
5454
|
+
/** 处理服务端主动断开通知 event/gateway.disconnect。
|
|
5455
|
+
*
|
|
5456
|
+
* 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
|
|
5457
|
+
* 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
|
|
5458
|
+
*/
|
|
5459
|
+
async _onGatewayDisconnect(data) {
|
|
5460
|
+
const payload = (data && typeof data === 'object') ? data : {};
|
|
5461
|
+
const code = payload.code;
|
|
5462
|
+
const reason = payload.reason ?? '';
|
|
5463
|
+
const detail = (payload.detail && typeof payload.detail === 'object' && !Array.isArray(payload.detail))
|
|
5464
|
+
? payload.detail
|
|
5465
|
+
: {};
|
|
5466
|
+
this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
|
|
4984
5467
|
this._serverKicked = true;
|
|
5468
|
+
// 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
|
|
5469
|
+
this._lastDisconnectInfo = { code, reason, detail };
|
|
5470
|
+
// 透传给应用层订阅者
|
|
5471
|
+
try {
|
|
5472
|
+
await this._dispatcher.publish('gateway.disconnect', {
|
|
5473
|
+
code,
|
|
5474
|
+
reason,
|
|
5475
|
+
detail,
|
|
5476
|
+
});
|
|
5477
|
+
}
|
|
5478
|
+
catch (exc) {
|
|
5479
|
+
this._clientLog.debug(`publish gateway.disconnect failed: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
5480
|
+
}
|
|
4985
5481
|
}
|
|
4986
5482
|
/** 传输层断线回调 */
|
|
4987
5483
|
async _handleTransportDisconnect(error, closeCode) {
|
|
@@ -4990,6 +5486,7 @@ export class AUNClient {
|
|
|
4990
5486
|
// 已在重连中则跳过,避免心跳超时和 transport 断线回调重复触发
|
|
4991
5487
|
if (this._reconnectActive)
|
|
4992
5488
|
return;
|
|
5489
|
+
this._clientLog.warn(`transport disconnected: closeCode=${closeCode ?? 'none'}, error=${error ? formatCaughtError(error) : 'none'}`);
|
|
4993
5490
|
this._state = 'disconnected';
|
|
4994
5491
|
this._stopBackgroundTasks();
|
|
4995
5492
|
await this._dispatcher.publish('connection.state', { state: this._state, error });
|
|
@@ -5001,10 +5498,19 @@ export class AUNClient {
|
|
|
5001
5498
|
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
5002
5499
|
this._state = 'terminal_failed';
|
|
5003
5500
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
5004
|
-
this._clientLog.warn(
|
|
5005
|
-
|
|
5501
|
+
this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
|
|
5502
|
+
const disconnectInfo = this._lastDisconnectInfo ?? {};
|
|
5503
|
+
const eventPayload = {
|
|
5006
5504
|
state: this._state, error, reason,
|
|
5007
|
-
}
|
|
5505
|
+
};
|
|
5506
|
+
// 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
|
|
5507
|
+
if (disconnectInfo.detail && Object.keys(disconnectInfo.detail).length > 0) {
|
|
5508
|
+
eventPayload.detail = disconnectInfo.detail;
|
|
5509
|
+
}
|
|
5510
|
+
if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
|
|
5511
|
+
eventPayload.code = disconnectInfo.code;
|
|
5512
|
+
}
|
|
5513
|
+
await this._dispatcher.publish('connection.state', eventPayload);
|
|
5008
5514
|
return;
|
|
5009
5515
|
}
|
|
5010
5516
|
// 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭
|
|
@@ -5017,8 +5523,9 @@ export class AUNClient {
|
|
|
5017
5523
|
return;
|
|
5018
5524
|
this._reconnectActive = true;
|
|
5019
5525
|
this._reconnectAbort = new AbortController();
|
|
5526
|
+
this._clientLog.debug(`reconnect loop started: serverInitiated=${String(serverInitiated)}, aid=${this._aid ?? ''}`);
|
|
5020
5527
|
this._reconnectLoop(serverInitiated).catch((exc) => {
|
|
5021
|
-
this._clientLog.warn(
|
|
5528
|
+
this._clientLog.warn(`reconnect loop error: ${formatCaughtError(exc)}`);
|
|
5022
5529
|
});
|
|
5023
5530
|
}
|
|
5024
5531
|
/** 重连循环(for 循环 + AbortController,与 JS/Python 对齐) */
|
|
@@ -5065,6 +5572,7 @@ export class AUNClient {
|
|
|
5065
5572
|
}
|
|
5066
5573
|
await this._connectOnce(this._sessionParams, true);
|
|
5067
5574
|
// 重连成功,退出循环
|
|
5575
|
+
this._clientLog.debug(`reconnect success: attempt=${attempt}, aid=${this._aid ?? ''}`);
|
|
5068
5576
|
this._reconnectActive = false;
|
|
5069
5577
|
this._reconnectAbort = null;
|
|
5070
5578
|
return;
|
|
@@ -5110,62 +5618,80 @@ export class AUNClient {
|
|
|
5110
5618
|
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5111
5619
|
*/
|
|
5112
5620
|
async createNamedGroup(groupName, opts = {}) {
|
|
5113
|
-
const
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5621
|
+
const tStart = Date.now();
|
|
5622
|
+
this._clientLog.debug(`createNamedGroup enter: groupName=${groupName}`);
|
|
5623
|
+
try {
|
|
5624
|
+
const cp = new CryptoProvider();
|
|
5625
|
+
const identity = cp.generateIdentity();
|
|
5626
|
+
const params = {};
|
|
5627
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
5628
|
+
params[k] = v;
|
|
5629
|
+
}
|
|
5630
|
+
params.group_name = groupName;
|
|
5631
|
+
params.public_key = identity.public_key_der_b64;
|
|
5632
|
+
params.curve = 'P-256';
|
|
5633
|
+
const result = await this.call('group.create', params);
|
|
5634
|
+
const groupInfo = result?.group;
|
|
5635
|
+
const aidCert = result?.aid_cert;
|
|
5636
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5637
|
+
if (groupAid && aidCert) {
|
|
5638
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5639
|
+
private_key_pem: identity.private_key_pem,
|
|
5640
|
+
public_key: identity.public_key_der_b64,
|
|
5641
|
+
curve: 'P-256',
|
|
5642
|
+
type: 'group_identity',
|
|
5643
|
+
});
|
|
5644
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5645
|
+
if (certPem) {
|
|
5646
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5647
|
+
}
|
|
5136
5648
|
}
|
|
5649
|
+
this._clientLog.debug(`createNamedGroup exit: elapsed=${Date.now() - tStart}ms groupAid=${groupAid}`);
|
|
5650
|
+
return result;
|
|
5651
|
+
}
|
|
5652
|
+
catch (err) {
|
|
5653
|
+
this._clientLog.debug(`createNamedGroup exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
5654
|
+
throw err;
|
|
5137
5655
|
}
|
|
5138
|
-
return result;
|
|
5139
5656
|
}
|
|
5140
5657
|
/**
|
|
5141
5658
|
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5142
5659
|
*/
|
|
5143
5660
|
async bindGroupAid(groupId, groupName) {
|
|
5144
|
-
const
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
const result = await this.call('group.bind_aid', params);
|
|
5153
|
-
const groupInfo = result?.group;
|
|
5154
|
-
const aidCert = result?.aid_cert;
|
|
5155
|
-
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5156
|
-
if (groupAid && aidCert) {
|
|
5157
|
-
this._keystore.saveIdentity(groupAid, {
|
|
5158
|
-
private_key_pem: identity.private_key_pem,
|
|
5661
|
+
const tStart = Date.now();
|
|
5662
|
+
this._clientLog.debug(`bindGroupAid enter: groupId=${groupId}, groupName=${groupName}`);
|
|
5663
|
+
try {
|
|
5664
|
+
const cp = new CryptoProvider();
|
|
5665
|
+
const identity = cp.generateIdentity();
|
|
5666
|
+
const params = {
|
|
5667
|
+
group_id: groupId,
|
|
5668
|
+
group_name: groupName,
|
|
5159
5669
|
public_key: identity.public_key_der_b64,
|
|
5160
5670
|
curve: 'P-256',
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
const
|
|
5164
|
-
|
|
5165
|
-
|
|
5671
|
+
};
|
|
5672
|
+
const result = await this.call('group.bind_aid', params);
|
|
5673
|
+
const groupInfo = result?.group;
|
|
5674
|
+
const aidCert = result?.aid_cert;
|
|
5675
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5676
|
+
if (groupAid && aidCert) {
|
|
5677
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5678
|
+
private_key_pem: identity.private_key_pem,
|
|
5679
|
+
public_key: identity.public_key_der_b64,
|
|
5680
|
+
curve: 'P-256',
|
|
5681
|
+
type: 'group_identity',
|
|
5682
|
+
});
|
|
5683
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5684
|
+
if (certPem) {
|
|
5685
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5686
|
+
}
|
|
5166
5687
|
}
|
|
5688
|
+
this._clientLog.debug(`bindGroupAid exit: elapsed=${Date.now() - tStart}ms groupAid=${groupAid}`);
|
|
5689
|
+
return result;
|
|
5690
|
+
}
|
|
5691
|
+
catch (err) {
|
|
5692
|
+
this._clientLog.debug(`bindGroupAid exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
5693
|
+
throw err;
|
|
5167
5694
|
}
|
|
5168
|
-
return result;
|
|
5169
5695
|
}
|
|
5170
5696
|
/** 判断是否应重试重连 */
|
|
5171
5697
|
static _shouldRetryReconnect(error) {
|