@agentunion/fastaun 0.2.17 → 0.2.18
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.js +306 -209
- package/dist/auth.js.map +1 -1
- package/dist/client.js +1041 -704
- 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 +24 -9
- 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 +7 -1
- package/dist/keystore/aid-db.js +10 -3
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.js +12 -9
- package/dist/keystore/file.js.map +1 -1
- 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 +1 -0
- package/dist/namespaces/auth.js +289 -146
- 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 +1 -1
package/dist/client.js
CHANGED
|
@@ -380,6 +380,7 @@ export class AUNClient {
|
|
|
380
380
|
constructor(config, debug = false) {
|
|
381
381
|
const rawConfig = { ...(config ?? {}) };
|
|
382
382
|
this._configModel = configFromMap(rawConfig);
|
|
383
|
+
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
383
384
|
this.config = {
|
|
384
385
|
aun_path: this._configModel.aunPath,
|
|
385
386
|
root_ca_path: this._configModel.rootCaPath,
|
|
@@ -393,7 +394,7 @@ export class AUNClient {
|
|
|
393
394
|
});
|
|
394
395
|
this._clientLog = this._logger.for('aun_core.client');
|
|
395
396
|
if (debugFlag) {
|
|
396
|
-
this._clientLog.info(`AUNClient
|
|
397
|
+
this._clientLog.info(`AUNClient initialized (debug=true, aunPath=${this._configModel.aunPath})`);
|
|
397
398
|
}
|
|
398
399
|
this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
|
|
399
400
|
this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl });
|
|
@@ -412,13 +413,14 @@ export class AUNClient {
|
|
|
412
413
|
this._auth = new AuthFlow({
|
|
413
414
|
keystore,
|
|
414
415
|
crypto: new CryptoProvider(),
|
|
415
|
-
aid:
|
|
416
|
+
aid: initAid,
|
|
416
417
|
deviceId: this._deviceId,
|
|
417
418
|
slotId: this._slotId,
|
|
418
419
|
rootCaPath: this._configModel.rootCaPath ?? undefined,
|
|
419
420
|
verifySsl: this._configModel.verifySsl,
|
|
420
421
|
logger: this._logger.for('aun_core.auth'),
|
|
421
422
|
});
|
|
423
|
+
this._aid = initAid;
|
|
422
424
|
this._transport = new RPCTransport({
|
|
423
425
|
eventDispatcher: this._dispatcher,
|
|
424
426
|
timeout: 10_000,
|
|
@@ -481,7 +483,17 @@ export class AUNClient {
|
|
|
481
483
|
}
|
|
482
484
|
/** 向 gatewayUrl 的 /health 端点发送 GET 请求,检查网关可用性 */
|
|
483
485
|
async checkGatewayHealth(gatewayUrl, timeout = 5_000) {
|
|
484
|
-
|
|
486
|
+
const tStart = Date.now();
|
|
487
|
+
this._clientLog.debug(`checkGatewayHealth enter: gatewayUrl=${gatewayUrl}`);
|
|
488
|
+
try {
|
|
489
|
+
const result = await this._discovery.checkHealth(gatewayUrl, timeout);
|
|
490
|
+
this._clientLog.debug(`checkGatewayHealth exit: elapsed=${Date.now() - tStart}ms healthy=${result}`);
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
this._clientLog.debug(`checkGatewayHealth exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
485
497
|
}
|
|
486
498
|
// ── 生命周期 ──────────────────────────────────────────────
|
|
487
499
|
/**
|
|
@@ -491,6 +503,7 @@ export class AUNClient {
|
|
|
491
503
|
* @param options - 会话选项(auto_reconnect、heartbeat_interval 等)
|
|
492
504
|
*/
|
|
493
505
|
async connect(auth, options) {
|
|
506
|
+
const tStart = Date.now();
|
|
494
507
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
495
508
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
496
509
|
}
|
|
@@ -504,79 +517,117 @@ export class AUNClient {
|
|
|
504
517
|
const callTimeoutSec = this._sessionOptions.timeouts.call;
|
|
505
518
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
|
|
506
519
|
this._closing = false;
|
|
520
|
+
this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
|
|
507
521
|
try {
|
|
508
522
|
await this._connectOnce(normalized, false);
|
|
523
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
509
524
|
}
|
|
510
525
|
catch (err) {
|
|
511
526
|
// 连接失败时回退状态,允许重试
|
|
512
527
|
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
513
528
|
this._state = 'disconnected';
|
|
514
529
|
}
|
|
530
|
+
this._clientLog.error(`connect failed: ${formatCaughtError(err)}`, err instanceof Error ? err : undefined);
|
|
531
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
515
532
|
throw err;
|
|
516
533
|
}
|
|
517
534
|
}
|
|
518
535
|
/** 关闭连接 */
|
|
519
536
|
async close() {
|
|
520
|
-
|
|
521
|
-
this.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
537
|
+
const tStart = Date.now();
|
|
538
|
+
this._clientLog.debug(`close enter: state=${this._state}, aid=${this._aid ?? ''}`);
|
|
539
|
+
try {
|
|
540
|
+
this._closing = true;
|
|
541
|
+
this._saveSeqTrackerState();
|
|
542
|
+
this._stopBackgroundTasks();
|
|
543
|
+
this._stopReconnect();
|
|
544
|
+
if (this._state === 'idle' || this._state === 'closed') {
|
|
545
|
+
const closableKeyStore = this._keystore;
|
|
546
|
+
closableKeyStore.close?.();
|
|
547
|
+
this._state = 'closed';
|
|
548
|
+
this._logger.close();
|
|
549
|
+
this._resetSeqTrackingState();
|
|
550
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms (was idle/closed)`);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
await this._transport.close();
|
|
525
554
|
const closableKeyStore = this._keystore;
|
|
526
555
|
closableKeyStore.close?.();
|
|
527
556
|
this._state = 'closed';
|
|
528
557
|
this._logger.close();
|
|
558
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
529
559
|
this._resetSeqTrackingState();
|
|
530
|
-
|
|
560
|
+
this._clientLog.debug(`close exit: elapsed=${Date.now() - tStart}ms`);
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
this._clientLog.debug(`close exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
564
|
+
throw err;
|
|
531
565
|
}
|
|
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
566
|
}
|
|
540
567
|
/**
|
|
541
568
|
* 断开连接但不关闭客户端(可重新 connect,对齐 Python disconnect)。
|
|
542
569
|
* disconnect 是可恢复的:停止心跳、关闭 WebSocket,但不清理 keystore 等状态。
|
|
543
570
|
*/
|
|
544
571
|
async disconnect() {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
572
|
+
const tStart = Date.now();
|
|
573
|
+
this._clientLog.debug(`disconnect enter: state=${this._state}, aid=${this._aid ?? ''}, closing=${this._closing}`);
|
|
574
|
+
try {
|
|
575
|
+
// 若 close() 已在执行中,跳过 disconnect 避免竞态
|
|
576
|
+
if (this._closing) {
|
|
577
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms (closing)`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (this._state !== 'connected' && this._state !== 'reconnecting') {
|
|
581
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms (state=${this._state})`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this._saveSeqTrackerState();
|
|
585
|
+
this._stopBackgroundTasks();
|
|
586
|
+
this._stopReconnect();
|
|
587
|
+
await this._transport.close();
|
|
588
|
+
this._state = 'disconnected';
|
|
589
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
590
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms`);
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
this._clientLog.debug(`disconnect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
556
596
|
}
|
|
557
597
|
/**
|
|
558
598
|
* 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID)。
|
|
559
599
|
*/
|
|
560
600
|
listIdentities() {
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (!identity || !identity.private_key_pem)
|
|
569
|
-
continue;
|
|
570
|
-
const summary = { aid };
|
|
571
|
-
const loadMetadata = this._keystore.loadMetadata;
|
|
572
|
-
if (typeof loadMetadata === 'function') {
|
|
573
|
-
const md = loadMetadata.call(this._keystore, aid);
|
|
574
|
-
if (md)
|
|
575
|
-
summary.metadata = md;
|
|
601
|
+
const tStart = Date.now();
|
|
602
|
+
this._clientLog.debug(`listIdentities enter`);
|
|
603
|
+
try {
|
|
604
|
+
const listFn = this._keystore.listIdentities;
|
|
605
|
+
if (typeof listFn !== 'function') {
|
|
606
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms (no_list_fn)`);
|
|
607
|
+
return [];
|
|
576
608
|
}
|
|
577
|
-
|
|
609
|
+
const aids = listFn.call(this._keystore);
|
|
610
|
+
const summaries = [];
|
|
611
|
+
for (const aid of [...aids].sort()) {
|
|
612
|
+
const identity = this._keystore.loadIdentity(aid);
|
|
613
|
+
if (!identity || !identity.private_key_pem)
|
|
614
|
+
continue;
|
|
615
|
+
const summary = { aid };
|
|
616
|
+
const loadMetadata = this._keystore.loadMetadata;
|
|
617
|
+
if (typeof loadMetadata === 'function') {
|
|
618
|
+
const md = loadMetadata.call(this._keystore, aid);
|
|
619
|
+
if (md)
|
|
620
|
+
summary.metadata = md;
|
|
621
|
+
}
|
|
622
|
+
summaries.push(summary);
|
|
623
|
+
}
|
|
624
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
|
|
625
|
+
return summaries;
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
this._clientLog.debug(`listIdentities exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
629
|
+
throw err;
|
|
578
630
|
}
|
|
579
|
-
return summaries;
|
|
580
631
|
}
|
|
581
632
|
// ── RPC ───────────────────────────────────────────────────
|
|
582
633
|
/**
|
|
@@ -584,214 +635,257 @@ export class AUNClient {
|
|
|
584
635
|
* 自动处理内部方法限制、E2EE 加解密、客户端签名等。
|
|
585
636
|
*/
|
|
586
637
|
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
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
// encrypt
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
return await this._sendGroupEncrypted(p);
|
|
638
|
+
const tStart = Date.now();
|
|
639
|
+
this._clientLog.debug(`call enter: method=${method}`);
|
|
640
|
+
try {
|
|
641
|
+
if (this._state !== 'connected') {
|
|
642
|
+
throw new ConnectionError('client is not connected');
|
|
643
|
+
}
|
|
644
|
+
if (INTERNAL_ONLY_METHODS.has(method)) {
|
|
645
|
+
throw new PermissionError(`method is internal_only: ${method}`);
|
|
646
|
+
}
|
|
647
|
+
const p = { ...(params ?? {}) };
|
|
648
|
+
this._validateOutboundCall(method, p);
|
|
649
|
+
this._injectMessageCursorContext(method, p);
|
|
650
|
+
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
651
|
+
if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
|
|
652
|
+
p.device_id = this._deviceId;
|
|
653
|
+
}
|
|
654
|
+
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
655
|
+
p.slot_id = this._slotId;
|
|
656
|
+
}
|
|
657
|
+
// 自动加密:message.send 默认加密(encrypt 默认 True)
|
|
658
|
+
if (method === 'message.send') {
|
|
659
|
+
const encrypt = p.encrypt ?? true;
|
|
660
|
+
delete p.encrypt;
|
|
661
|
+
if (encrypt) {
|
|
662
|
+
return await this._sendEncrypted(p);
|
|
663
|
+
}
|
|
664
|
+
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
665
|
+
}
|
|
666
|
+
// 自动加密:group.send 默认加密(encrypt 默认 True)
|
|
667
|
+
if (method === 'group.send') {
|
|
668
|
+
const encrypt = p.encrypt ?? true;
|
|
669
|
+
delete p.encrypt;
|
|
670
|
+
if (encrypt) {
|
|
671
|
+
return await this._sendGroupEncrypted(p);
|
|
672
|
+
}
|
|
623
673
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
674
|
+
if (method === 'group.thought.put') {
|
|
675
|
+
const encrypt = p.encrypt ?? true;
|
|
676
|
+
delete p.encrypt;
|
|
677
|
+
if (encrypt) {
|
|
678
|
+
return await this._putGroupThoughtEncrypted(p);
|
|
679
|
+
}
|
|
630
680
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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);
|
|
681
|
+
if (method === 'message.thought.put') {
|
|
682
|
+
const encrypt = p.encrypt ?? true;
|
|
683
|
+
delete p.encrypt;
|
|
684
|
+
if (encrypt) {
|
|
685
|
+
return await this._putMessageThoughtEncrypted(p);
|
|
686
|
+
}
|
|
655
687
|
}
|
|
656
|
-
|
|
657
|
-
|
|
688
|
+
// 关键操作自动附加客户端签名
|
|
689
|
+
if (SIGNED_METHODS.has(method)) {
|
|
690
|
+
this._signClientOperation(method, p);
|
|
691
|
+
}
|
|
692
|
+
// P1-23: 非幂等方法使用更长超时
|
|
693
|
+
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
694
|
+
let result = callTimeout
|
|
695
|
+
? await this._transport.call(method, p, callTimeout)
|
|
696
|
+
: await this._transport.call(method, p);
|
|
697
|
+
// 自动解密:message.pull 返回的消息
|
|
698
|
+
if (method === 'message.pull' && isJsonObject(result)) {
|
|
699
|
+
const r = result;
|
|
700
|
+
const messages = r.messages;
|
|
701
|
+
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
702
|
+
this._clientLog.debug(`message.pull result: ${rawMessages.length} messages`);
|
|
658
703
|
if (rawMessages.length > 0) {
|
|
659
|
-
this.
|
|
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
|
-
}
|
|
704
|
+
r.messages = await this._decryptMessages(rawMessages);
|
|
672
705
|
}
|
|
673
|
-
this.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
// 自动解密:group.pull 返回的群消息
|
|
686
|
-
if (method === 'group.pull' && isJsonObject(result)) {
|
|
687
|
-
const r = result;
|
|
688
|
-
const messages = r.messages;
|
|
689
|
-
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
690
|
-
if (rawMessages.length > 0) {
|
|
691
|
-
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
692
|
-
}
|
|
693
|
-
const gid = (p.group_id ?? '');
|
|
694
|
-
if (gid) {
|
|
695
|
-
const ns = `group:${gid}`;
|
|
696
|
-
if (rawMessages.length > 0) {
|
|
697
|
-
this._seqTracker.onPullResult(ns, rawMessages);
|
|
698
|
-
}
|
|
699
|
-
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
700
|
-
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
701
|
-
// 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
|
|
702
|
-
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
703
|
-
if (cursor) {
|
|
704
|
-
const serverAck = Number(cursor.current_seq ?? 0);
|
|
706
|
+
if (this._aid) {
|
|
707
|
+
const ns = `p2p:${this._aid}`;
|
|
708
|
+
if (rawMessages.length > 0) {
|
|
709
|
+
this._seqTracker.onPullResult(ns, rawMessages);
|
|
710
|
+
}
|
|
711
|
+
// ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
|
|
712
|
+
// 服务端在持久化/设备视图分支返回 server_ack_seq,客户端若 contiguous 落后必须 force 跳过
|
|
713
|
+
// retention window 外的空洞。与 S2 [1,seq-1] 历史 gap 配合;若去掉 force,首条消息建的 gap 会
|
|
714
|
+
// 永远悬挂触发无限 pull。临时消息淘汰走 ephemeral_earliest_available_seq(当前仅提示),与此互斥。
|
|
715
|
+
const serverAck = Number(r.server_ack_seq ?? 0);
|
|
705
716
|
if (serverAck > 0) {
|
|
706
717
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
707
718
|
if (contig < serverAck) {
|
|
708
|
-
this._clientLog.info(`
|
|
719
|
+
this._clientLog.info(`message.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAck}`);
|
|
709
720
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
710
721
|
}
|
|
711
722
|
}
|
|
723
|
+
this._saveSeqTrackerState();
|
|
724
|
+
// auto-ack contiguous_seq
|
|
725
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
726
|
+
if (contig > 0 && (rawMessages.length > 0 || serverAck > 0)) {
|
|
727
|
+
this._transport.call('message.ack', {
|
|
728
|
+
seq: contig,
|
|
729
|
+
device_id: this._deviceId,
|
|
730
|
+
slot_id: this._slotId,
|
|
731
|
+
}).catch((e) => { this._clientLog.debug(`message.pull auto-ack failed: ${formatCaughtError(e)}`); });
|
|
732
|
+
}
|
|
712
733
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
// 同步到服务端:将服务端 epoch 从 0 推到 1;必须在 group.create 返回前完成,
|
|
743
|
-
// 否则调用方紧接着加成员时会让初始 rotation 因成员集变化而提交失败。
|
|
744
|
-
await this._syncEpochToServer(gid);
|
|
734
|
+
}
|
|
735
|
+
// 自动解密:group.pull 返回的群消息
|
|
736
|
+
if (method === 'group.pull' && isJsonObject(result)) {
|
|
737
|
+
const r = result;
|
|
738
|
+
const messages = r.messages;
|
|
739
|
+
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
740
|
+
this._clientLog.debug(`group.pull result: group_id=${String(p.group_id ?? '')}, ${rawMessages.length} messages`);
|
|
741
|
+
if (rawMessages.length > 0) {
|
|
742
|
+
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
743
|
+
}
|
|
744
|
+
const gid = (p.group_id ?? '');
|
|
745
|
+
if (gid) {
|
|
746
|
+
const ns = `group:${gid}`;
|
|
747
|
+
if (rawMessages.length > 0) {
|
|
748
|
+
this._seqTracker.onPullResult(ns, rawMessages);
|
|
749
|
+
}
|
|
750
|
+
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
751
|
+
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
752
|
+
// 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
|
|
753
|
+
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
754
|
+
if (cursor) {
|
|
755
|
+
const serverAck = Number(cursor.current_seq ?? 0);
|
|
756
|
+
if (serverAck > 0) {
|
|
757
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
758
|
+
if (contig < serverAck) {
|
|
759
|
+
this._clientLog.info(`group.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAck}`);
|
|
760
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
745
763
|
}
|
|
746
|
-
|
|
747
|
-
|
|
764
|
+
this._saveSeqTrackerState();
|
|
765
|
+
// auto-ack contiguous_seq
|
|
766
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
767
|
+
const shouldAck = rawMessages.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0);
|
|
768
|
+
if (contig > 0 && shouldAck) {
|
|
769
|
+
this._transport.call('group.ack_messages', {
|
|
770
|
+
group_id: gid,
|
|
771
|
+
msg_seq: contig,
|
|
772
|
+
device_id: this._deviceId,
|
|
773
|
+
slot_id: this._slotId,
|
|
774
|
+
}).catch((e) => { this._clientLog.debug(`group.pull auto-ack failed: group=${gid} ${formatCaughtError(e)}`); });
|
|
748
775
|
}
|
|
749
776
|
}
|
|
750
777
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
778
|
+
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
779
|
+
result = await this._decryptGroupThoughts(result);
|
|
780
|
+
}
|
|
781
|
+
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
782
|
+
result = await this._decryptMessageThoughts(result);
|
|
783
|
+
}
|
|
784
|
+
// ── Group E2EE 自动编排(必备能力,始终启用)────────
|
|
785
|
+
{
|
|
786
|
+
// 建群后自动创建 epoch(幂等:已有 secret 时跳过)
|
|
787
|
+
if (method === 'group.create' && isJsonObject(result)) {
|
|
788
|
+
const group = isJsonObject(result.group) ? result.group : null;
|
|
789
|
+
const gid = group ? String(group.group_id ?? '') : '';
|
|
790
|
+
if (gid && this._aid && !this._groupE2ee.hasSecret(gid)) {
|
|
791
|
+
try {
|
|
792
|
+
this._groupE2ee.createEpoch(gid, [this._aid]);
|
|
793
|
+
// 同步到服务端:将服务端 epoch 从 0 推到 1;必须在 group.create 返回前完成,
|
|
794
|
+
// 否则调用方紧接着加成员时会让初始 rotation 因成员集变化而提交失败。
|
|
795
|
+
await this._syncEpochToServer(gid);
|
|
796
|
+
}
|
|
797
|
+
catch (exc) {
|
|
798
|
+
this._logE2eeError('create_epoch', gid, '', exc);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
// 入群类 RPC 的成员集变更统一由 group.changed 事件驱动 epoch 轮换。
|
|
803
|
+
}
|
|
804
|
+
// 成员集变更主要由 group.changed 事件驱动;RPC 成功返回路径做幂等兜底,避免事件丢失或延迟时不轮换。
|
|
805
|
+
const membershipMethods = new Set([
|
|
806
|
+
'group.add_member', 'group.kick', 'group.remove_member', 'group.leave',
|
|
807
|
+
'group.review_join_request', 'group.batch_review_join_request',
|
|
808
|
+
'group.use_invite_code', 'group.request_join',
|
|
809
|
+
]);
|
|
810
|
+
if (membershipMethods.has(method) && isJsonObject(result) && !('error' in result)) {
|
|
811
|
+
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
812
|
+
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
813
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
814
|
+
// 自加入方法(request_join/use_invite_code)需要 allowMember=true,
|
|
815
|
+
// 因为新成员角色是 member,必须允许 member 参与 leader 选举。
|
|
816
|
+
const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
|
|
817
|
+
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
818
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
|
|
819
|
+
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
820
|
+
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => this._clientLog.warn(`membership RPC epoch rotation fallback failed: ${formatCaughtError(exc)}`));
|
|
821
|
+
}
|
|
770
822
|
}
|
|
823
|
+
this._clientLog.debug(`call exit: method=${method} elapsed=${Date.now() - tStart}ms`);
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
this._clientLog.debug(`call exit (error): method=${method} elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
828
|
+
throw err;
|
|
771
829
|
}
|
|
772
|
-
return result;
|
|
773
830
|
}
|
|
774
831
|
// ── 便利方法 ──────────────────────────────────────────────
|
|
775
832
|
/** 心跳检测 */
|
|
776
833
|
async ping(params) {
|
|
777
|
-
|
|
834
|
+
const tStart = Date.now();
|
|
835
|
+
this._clientLog.debug(`ping enter`);
|
|
836
|
+
try {
|
|
837
|
+
const result = await this.call('meta.ping', params ?? {});
|
|
838
|
+
this._clientLog.debug(`ping exit: elapsed=${Date.now() - tStart}ms`);
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
this._clientLog.debug(`ping exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
778
845
|
}
|
|
779
846
|
/** 获取服务端状态 */
|
|
780
847
|
async status(params) {
|
|
781
|
-
|
|
848
|
+
const tStart = Date.now();
|
|
849
|
+
this._clientLog.debug(`status enter`);
|
|
850
|
+
try {
|
|
851
|
+
const result = await this.call('meta.status', params ?? {});
|
|
852
|
+
this._clientLog.debug(`status exit: elapsed=${Date.now() - tStart}ms`);
|
|
853
|
+
return result;
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
this._clientLog.debug(`status exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
857
|
+
throw err;
|
|
858
|
+
}
|
|
782
859
|
}
|
|
783
860
|
/** 获取信任根证书列表 */
|
|
784
861
|
async trustRoots(params) {
|
|
785
|
-
|
|
862
|
+
const tStart = Date.now();
|
|
863
|
+
this._clientLog.debug(`trustRoots enter`);
|
|
864
|
+
try {
|
|
865
|
+
const result = await this.call('meta.trust_roots', params ?? {});
|
|
866
|
+
this._clientLog.debug(`trustRoots exit: elapsed=${Date.now() - tStart}ms`);
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
catch (err) {
|
|
870
|
+
this._clientLog.debug(`trustRoots exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
871
|
+
throw err;
|
|
872
|
+
}
|
|
786
873
|
}
|
|
787
874
|
// ── 事件 ──────────────────────────────────────────────────
|
|
788
875
|
/** 订阅事件 */
|
|
789
876
|
on(event, handler) {
|
|
790
|
-
|
|
877
|
+
const tStart = Date.now();
|
|
878
|
+
this._clientLog.debug(`on enter: event=${event}`);
|
|
879
|
+
const result = this._dispatcher.subscribe(event, handler);
|
|
880
|
+
this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
881
|
+
return result;
|
|
791
882
|
}
|
|
792
883
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
793
884
|
off(event, handler) {
|
|
885
|
+
const tStart = Date.now();
|
|
886
|
+
this._clientLog.debug(`off enter: event=${event}`);
|
|
794
887
|
this._dispatcher.unsubscribe(event, handler);
|
|
888
|
+
this._clientLog.debug(`off exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
795
889
|
}
|
|
796
890
|
// ── E2EE 加密发送 ────────────────────────────────────────
|
|
797
891
|
_protectedHeadersFromParams(params) {
|
|
@@ -807,6 +901,7 @@ export class AUNClient {
|
|
|
807
901
|
}
|
|
808
902
|
/** 自动加密并发送 P2P 消息 */
|
|
809
903
|
async _sendEncrypted(params) {
|
|
904
|
+
const tStart = Date.now();
|
|
810
905
|
const toAid = String(params.to ?? '');
|
|
811
906
|
this._validateMessageRecipient(toAid);
|
|
812
907
|
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
@@ -817,6 +912,7 @@ export class AUNClient {
|
|
|
817
912
|
}
|
|
818
913
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
819
914
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
915
|
+
this._clientLog.debug(`_sendEncrypted enter: to=${toAid}, message_id=${messageId}`);
|
|
820
916
|
// 惰性同步:首次发送 P2P 消息时先 pull 一次
|
|
821
917
|
if (!this._p2pSynced) {
|
|
822
918
|
await this._lazySyncP2p();
|
|
@@ -880,18 +976,33 @@ export class AUNClient {
|
|
|
880
976
|
};
|
|
881
977
|
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
882
978
|
try {
|
|
883
|
-
|
|
979
|
+
const result = await sendAttempt(false);
|
|
980
|
+
this._clientLog.debug(`_sendEncrypted exit: elapsed=${Date.now() - tStart}ms to=${toAid}, message_id=${messageId}`);
|
|
981
|
+
return result;
|
|
884
982
|
}
|
|
885
983
|
catch (exc) {
|
|
886
|
-
if (!isRetryablePeerMaterialError(exc))
|
|
984
|
+
if (!isRetryablePeerMaterialError(exc)) {
|
|
985
|
+
this._clientLog.error(`message.send failed: to=${toAid}, message_id=${messageId}, err=${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
986
|
+
this._clientLog.debug(`_sendEncrypted exit (error): elapsed=${Date.now() - tStart}ms to=${toAid} err=${exc instanceof Error ? exc.message : String(exc)}`);
|
|
887
987
|
throw exc;
|
|
988
|
+
}
|
|
888
989
|
this._clientLog.warn(`peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
889
990
|
}
|
|
890
|
-
|
|
991
|
+
try {
|
|
992
|
+
const retryResult = await sendAttempt(true);
|
|
993
|
+
this._clientLog.debug(`_sendEncrypted exit: elapsed=${Date.now() - tStart}ms (retry success) to=${toAid}, message_id=${messageId}`);
|
|
994
|
+
return retryResult;
|
|
995
|
+
}
|
|
996
|
+
catch (exc) {
|
|
997
|
+
this._clientLog.debug(`_sendEncrypted exit (error): elapsed=${Date.now() - tStart}ms (retry failed) to=${toAid} err=${exc instanceof Error ? exc.message : String(exc)}`);
|
|
998
|
+
throw exc;
|
|
999
|
+
}
|
|
891
1000
|
}
|
|
892
1001
|
async _sendEncryptedSingle(opts) {
|
|
1002
|
+
this._clientLog.debug(`_sendEncryptedSingle enter: to=${opts.toAid}, message_id=${opts.messageId}, has_prekey=${!!opts.prekey}, persist_required=${!!opts.persistRequired}`);
|
|
893
1003
|
let prekey = opts.prekey ?? null;
|
|
894
1004
|
if (!prekey) {
|
|
1005
|
+
this._clientLog.debug(`_sendEncryptedSingle fetching peer prekey: to=${opts.toAid}`);
|
|
895
1006
|
prekey = await this._fetchPeerPrekey(opts.toAid);
|
|
896
1007
|
}
|
|
897
1008
|
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
@@ -906,6 +1017,7 @@ export class AUNClient {
|
|
|
906
1017
|
protectedHeaders: opts.protectedHeaders,
|
|
907
1018
|
});
|
|
908
1019
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1020
|
+
this._clientLog.debug(`_sendEncryptedSingle envelope built: to=${opts.toAid}, message_id=${opts.messageId}, scheme=${String(envelope?.scheme ?? '')}`);
|
|
909
1021
|
const sendParams = {
|
|
910
1022
|
to: opts.toAid,
|
|
911
1023
|
payload: envelope,
|
|
@@ -920,6 +1032,7 @@ export class AUNClient {
|
|
|
920
1032
|
return await this._transport.call('message.send', sendParams);
|
|
921
1033
|
}
|
|
922
1034
|
async _buildRecipientDeviceCopies(opts) {
|
|
1035
|
+
this._clientLog.debug(`_buildRecipientDeviceCopies enter: to=${opts.toAid}, message_id=${opts.messageId}, prekey_count=${opts.prekeys.length}`);
|
|
923
1036
|
const recipientCopies = [];
|
|
924
1037
|
const certCache = new Map();
|
|
925
1038
|
for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
|
|
@@ -949,6 +1062,7 @@ export class AUNClient {
|
|
|
949
1062
|
if (recipientCopies.length === 0) {
|
|
950
1063
|
throw new E2EEError(`no recipient device copies generated for ${opts.toAid}`);
|
|
951
1064
|
}
|
|
1065
|
+
this._clientLog.debug(`_buildRecipientDeviceCopies built: to=${opts.toAid}, message_id=${opts.messageId}, copies=${recipientCopies.length}`);
|
|
952
1066
|
return recipientCopies;
|
|
953
1067
|
}
|
|
954
1068
|
async _resolveSelfCopyPeerCert(certFingerprint) {
|
|
@@ -997,7 +1111,7 @@ export class AUNClient {
|
|
|
997
1111
|
}
|
|
998
1112
|
catch (e) {
|
|
999
1113
|
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
1000
|
-
this._clientLog.warn(`self-sync
|
|
1114
|
+
this._clientLog.warn(`self-sync skip device ${deviceId}: cert parse failed (${e}), possibly old prekey`);
|
|
1001
1115
|
continue;
|
|
1002
1116
|
}
|
|
1003
1117
|
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
@@ -1035,16 +1149,27 @@ export class AUNClient {
|
|
|
1035
1149
|
mode: encryptResult.mode,
|
|
1036
1150
|
reason: encryptResult.degradation_reason,
|
|
1037
1151
|
}).catch((exc) => {
|
|
1038
|
-
this._clientLog.warn(
|
|
1152
|
+
this._clientLog.warn(`failed to publish e2ee.degraded event: ${formatCaughtError(exc)}`);
|
|
1039
1153
|
});
|
|
1040
1154
|
}
|
|
1041
1155
|
}
|
|
1042
1156
|
/** 自动加密并发送群组消息 */
|
|
1043
1157
|
async _sendGroupEncrypted(params) {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1158
|
+
const tStart = Date.now();
|
|
1159
|
+
const groupId = String(params.group_id ?? '');
|
|
1160
|
+
this._clientLog.debug(`_sendGroupEncrypted enter: group_id=${groupId}`);
|
|
1161
|
+
try {
|
|
1162
|
+
const result = await this._callGroupEncryptedRpc('group.send', params, {
|
|
1163
|
+
idField: 'message_id',
|
|
1164
|
+
idPrefix: 'gm',
|
|
1165
|
+
});
|
|
1166
|
+
this._clientLog.debug(`_sendGroupEncrypted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
1167
|
+
return result;
|
|
1168
|
+
}
|
|
1169
|
+
catch (err) {
|
|
1170
|
+
this._clientLog.debug(`_sendGroupEncrypted exit (error): elapsed=${Date.now() - tStart}ms group_id=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1171
|
+
throw err;
|
|
1172
|
+
}
|
|
1048
1173
|
}
|
|
1049
1174
|
async _putGroupThoughtEncrypted(params) {
|
|
1050
1175
|
return await this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
@@ -1096,14 +1221,17 @@ export class AUNClient {
|
|
|
1096
1221
|
let { sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options);
|
|
1097
1222
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1098
1223
|
try {
|
|
1099
|
-
|
|
1224
|
+
const result = await this._transport.call(method, sendParams);
|
|
1225
|
+
this._clientLog.debug(`${method} send success: group_id=${groupId}`);
|
|
1226
|
+
return result;
|
|
1100
1227
|
}
|
|
1101
1228
|
catch (exc) {
|
|
1102
1229
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
1103
|
-
this._clientLog.warn(
|
|
1230
|
+
this._clientLog.warn(`group ${groupId} ${method} epoch stale, recovering key and retrying: ${formatCaughtError(exc)}`);
|
|
1104
1231
|
({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
|
|
1105
1232
|
continue;
|
|
1106
1233
|
}
|
|
1234
|
+
this._clientLog.error(`${method} send failed: group_id=${groupId}, err=${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
1107
1235
|
throw exc;
|
|
1108
1236
|
}
|
|
1109
1237
|
}
|
|
@@ -1118,6 +1246,7 @@ export class AUNClient {
|
|
|
1118
1246
|
if (payload === null) {
|
|
1119
1247
|
throw new ValidationError(`${method} payload must be an object when encrypt=true`);
|
|
1120
1248
|
}
|
|
1249
|
+
this._clientLog.debug(`${method} encrypt prepare: group_id=${groupId}, strictEpochReady=${String(strictEpochReady)}`);
|
|
1121
1250
|
if (!this._groupSynced.has(groupId)) {
|
|
1122
1251
|
await this._lazySyncGroup(groupId);
|
|
1123
1252
|
}
|
|
@@ -1186,11 +1315,11 @@ export class AUNClient {
|
|
|
1186
1315
|
}
|
|
1187
1316
|
if (messages.length > 0) {
|
|
1188
1317
|
this._saveSeqTrackerState();
|
|
1189
|
-
this._clientLog.info(
|
|
1318
|
+
this._clientLog.info(`lazy sync group ${groupId}: pull ${messages.length} messages, after_seq=${afterSeq}`);
|
|
1190
1319
|
}
|
|
1191
1320
|
}
|
|
1192
1321
|
catch (exc) {
|
|
1193
|
-
this._clientLog.warn(
|
|
1322
|
+
this._clientLog.warn(`lazy sync group ${groupId} failed: ${formatCaughtError(exc)}`);
|
|
1194
1323
|
}
|
|
1195
1324
|
}
|
|
1196
1325
|
/** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
|
|
@@ -1213,11 +1342,11 @@ export class AUNClient {
|
|
|
1213
1342
|
}
|
|
1214
1343
|
if (messages.length > 0) {
|
|
1215
1344
|
this._saveSeqTrackerState();
|
|
1216
|
-
this._clientLog.info(
|
|
1345
|
+
this._clientLog.info(`lazy sync P2P: pull ${messages.length} messages, after_seq=${afterSeq}`);
|
|
1217
1346
|
}
|
|
1218
1347
|
}
|
|
1219
1348
|
catch (exc) {
|
|
1220
|
-
this._clientLog.warn(
|
|
1349
|
+
this._clientLog.warn(`lazy sync P2P failed: ${formatCaughtError(exc)}`);
|
|
1221
1350
|
}
|
|
1222
1351
|
}
|
|
1223
1352
|
_isGroupEpochTooOldError(exc) {
|
|
@@ -1273,10 +1402,10 @@ export class AUNClient {
|
|
|
1273
1402
|
encrypt: true,
|
|
1274
1403
|
persist_required: true,
|
|
1275
1404
|
});
|
|
1276
|
-
this._clientLog.info(
|
|
1405
|
+
this._clientLog.info(`requested group ${groupId} epoch ${epoch} key from ${targetAid}`);
|
|
1277
1406
|
}
|
|
1278
1407
|
catch (exc) {
|
|
1279
|
-
this._clientLog.warn(
|
|
1408
|
+
this._clientLog.warn(`requesting group ${groupId} key from ${targetAid} failed: ${formatCaughtError(exc)}`);
|
|
1280
1409
|
}
|
|
1281
1410
|
}
|
|
1282
1411
|
async _requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult) {
|
|
@@ -1291,7 +1420,7 @@ export class AUNClient {
|
|
|
1291
1420
|
const secretData = this._groupE2ee.loadSecret(groupId, 1);
|
|
1292
1421
|
if (!secretData || secretData.pending_rotation_id)
|
|
1293
1422
|
return epochResult;
|
|
1294
|
-
this._clientLog.warn(
|
|
1423
|
+
this._clientLog.warn(`group ${groupId} detected local epoch 1 exists but server epoch still 0, attempting initial epoch resync`);
|
|
1295
1424
|
await this._syncEpochToServer(groupId);
|
|
1296
1425
|
try {
|
|
1297
1426
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -1299,7 +1428,7 @@ export class AUNClient {
|
|
|
1299
1428
|
return refreshed;
|
|
1300
1429
|
}
|
|
1301
1430
|
catch (exc) {
|
|
1302
|
-
this._clientLog.warn(
|
|
1431
|
+
this._clientLog.warn(`group ${groupId} initial epoch resync refresh server epoch failed: ${formatCaughtError(exc)}`);
|
|
1303
1432
|
}
|
|
1304
1433
|
return epochResult;
|
|
1305
1434
|
}
|
|
@@ -1388,7 +1517,7 @@ export class AUNClient {
|
|
|
1388
1517
|
members = isJsonObject(membersResult) ? membersResult.members : null;
|
|
1389
1518
|
}
|
|
1390
1519
|
catch (exc) {
|
|
1391
|
-
this._clientLog.debug(
|
|
1520
|
+
this._clientLog.debug(`group ${groupId} member epoch floor pre-check skipped: ${formatCaughtError(exc)}`);
|
|
1392
1521
|
return;
|
|
1393
1522
|
}
|
|
1394
1523
|
let maxMinReadEpoch = 0;
|
|
@@ -1403,7 +1532,7 @@ export class AUNClient {
|
|
|
1403
1532
|
}
|
|
1404
1533
|
if (maxMinReadEpoch <= committedEpoch)
|
|
1405
1534
|
return;
|
|
1406
|
-
this._clientLog.warn(
|
|
1535
|
+
this._clientLog.warn(`group ${groupId} member min_read_epoch higher than committed epoch, continuing with committed epoch: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
|
|
1407
1536
|
return;
|
|
1408
1537
|
}
|
|
1409
1538
|
}
|
|
@@ -1414,7 +1543,7 @@ export class AUNClient {
|
|
|
1414
1543
|
return epochResult;
|
|
1415
1544
|
}
|
|
1416
1545
|
catch (exc) {
|
|
1417
|
-
this._clientLog.warn(
|
|
1546
|
+
this._clientLog.warn(`group ${groupId} query committed epoch status failed, falling back to local epoch: ${formatCaughtError(exc)}`);
|
|
1418
1547
|
}
|
|
1419
1548
|
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1420
1549
|
return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
|
|
@@ -1442,7 +1571,7 @@ export class AUNClient {
|
|
|
1442
1571
|
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
1443
1572
|
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1444
1573
|
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
1445
|
-
this._clientLog.warn(
|
|
1574
|
+
this._clientLog.warn(`group ${groupId} committed epoch ${committedEpoch} member snapshot inconsistent with current members, triggering membership change rotation`);
|
|
1446
1575
|
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
1447
1576
|
const refreshed = await this._committedGroupEpochState(groupId);
|
|
1448
1577
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1459,7 +1588,7 @@ export class AUNClient {
|
|
|
1459
1588
|
return committedEpoch;
|
|
1460
1589
|
}
|
|
1461
1590
|
const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
|
|
1462
|
-
this._clientLog.warn(
|
|
1591
|
+
this._clientLog.warn(`group ${groupId} epoch ${committedEpoch} local pending key does not match server committed rotation, recovering key first: local_rotation=${pendingRotationId || '-'}`);
|
|
1463
1592
|
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
1464
1593
|
let refreshed = await this._committedGroupEpochState(groupId);
|
|
1465
1594
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1504,13 +1633,13 @@ export class AUNClient {
|
|
|
1504
1633
|
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
1505
1634
|
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
1506
1635
|
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
1507
|
-
this._clientLog.info(
|
|
1636
|
+
this._clientLog.info(`group ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
|
|
1508
1637
|
return true;
|
|
1509
1638
|
}
|
|
1510
1639
|
return false;
|
|
1511
1640
|
}
|
|
1512
1641
|
catch (exc) {
|
|
1513
|
-
this._clientLog.debug(
|
|
1642
|
+
this._clientLog.debug(`query current members failed, cannot determine committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
1514
1643
|
return false;
|
|
1515
1644
|
}
|
|
1516
1645
|
}
|
|
@@ -1560,9 +1689,16 @@ export class AUNClient {
|
|
|
1560
1689
|
// ── 事件自动解密管线 ──────────────────────────────────────
|
|
1561
1690
|
/** 处理 transport 层推送的原始 P2P 消息 */
|
|
1562
1691
|
async _onRawMessageReceived(data) {
|
|
1692
|
+
const tStart = Date.now();
|
|
1693
|
+
if (isJsonObject(data)) {
|
|
1694
|
+
this._clientLog.debug(`_onRawMessageReceived enter: from=${String(data.from ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1695
|
+
}
|
|
1696
|
+
else {
|
|
1697
|
+
this._clientLog.debug(`_onRawMessageReceived enter: non-object payload`);
|
|
1698
|
+
}
|
|
1563
1699
|
// 异步处理,不阻塞事件调度
|
|
1564
1700
|
this._processAndPublishMessage(data).catch((exc) => {
|
|
1565
|
-
this._clientLog.warn(`P2P
|
|
1701
|
+
this._clientLog.warn(`P2P message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1566
1702
|
// H26: 不再投递原始密文 payload;改发 message.undecryptable 事件,仅携带安全 header
|
|
1567
1703
|
if (isJsonObject(data)) {
|
|
1568
1704
|
const safeEvent = {
|
|
@@ -1576,6 +1712,7 @@ export class AUNClient {
|
|
|
1576
1712
|
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1577
1713
|
}
|
|
1578
1714
|
});
|
|
1715
|
+
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1579
1716
|
}
|
|
1580
1717
|
/** 实际处理推送消息的异步任务 */
|
|
1581
1718
|
async _processAndPublishMessage(data) {
|
|
@@ -1598,7 +1735,8 @@ export class AUNClient {
|
|
|
1598
1735
|
const ns = `p2p:${this._aid}`;
|
|
1599
1736
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1600
1737
|
if (needPull) {
|
|
1601
|
-
this.
|
|
1738
|
+
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
1739
|
+
this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1602
1740
|
}
|
|
1603
1741
|
// auto-ack contiguous_seq
|
|
1604
1742
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -1607,12 +1745,13 @@ export class AUNClient {
|
|
|
1607
1745
|
seq: contig,
|
|
1608
1746
|
device_id: this._deviceId,
|
|
1609
1747
|
slot_id: this._slotId,
|
|
1610
|
-
}).catch((e) => { this._clientLog.debug(`P2P auto-ack
|
|
1748
|
+
}).catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1611
1749
|
}
|
|
1612
1750
|
// 即时持久化 cursor,异常断连后不回退
|
|
1613
1751
|
this._saveSeqTrackerState();
|
|
1614
1752
|
}
|
|
1615
1753
|
const decrypted = await this._decryptSingleMessage(msg);
|
|
1754
|
+
this._clientLog.debug(`P2P message decrypt done: from=${String(msg.from ?? '')}, mid=${String(msg.message_id ?? '')}, seq=${String(seq ?? '')}`);
|
|
1616
1755
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1617
1756
|
const ns = `p2p:${this._aid}`;
|
|
1618
1757
|
await this._publishOrderedMessage('message.received', ns, seq, decrypted);
|
|
@@ -1623,8 +1762,15 @@ export class AUNClient {
|
|
|
1623
1762
|
}
|
|
1624
1763
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1625
1764
|
async _onRawGroupMessageCreated(data) {
|
|
1765
|
+
const tStart = Date.now();
|
|
1766
|
+
if (isJsonObject(data)) {
|
|
1767
|
+
this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${String(data.group_id ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1768
|
+
}
|
|
1769
|
+
else {
|
|
1770
|
+
this._clientLog.debug(`_onRawGroupMessageCreated enter: non-object payload`);
|
|
1771
|
+
}
|
|
1626
1772
|
this._processAndPublishGroupMessage(data).catch((exc) => {
|
|
1627
|
-
this._clientLog.warn(
|
|
1773
|
+
this._clientLog.warn(`group message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1628
1774
|
// H26: 不再投递原始密文 payload;改发 group.message_undecryptable 事件
|
|
1629
1775
|
if (isJsonObject(data)) {
|
|
1630
1776
|
const safeEvent = {
|
|
@@ -1638,6 +1784,7 @@ export class AUNClient {
|
|
|
1638
1784
|
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1639
1785
|
}
|
|
1640
1786
|
});
|
|
1787
|
+
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1641
1788
|
}
|
|
1642
1789
|
/**
|
|
1643
1790
|
* 处理群组推送消息的异步任务。
|
|
@@ -1664,12 +1811,14 @@ export class AUNClient {
|
|
|
1664
1811
|
return;
|
|
1665
1812
|
}
|
|
1666
1813
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
1814
|
+
this._clientLog.debug(`group message decrypt done: group=${groupId}, from=${String(msg.from ?? '')}, seq=${String(seq ?? '')}, e2ee=${String(!!decrypted.e2ee)}`);
|
|
1667
1815
|
// 只有带 payload 的真实消息,在同步解密/恢复尝试结束后才推进游标。
|
|
1668
1816
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1669
1817
|
const ns = `group:${groupId}`;
|
|
1670
1818
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1671
1819
|
if (needPull) {
|
|
1672
|
-
this.
|
|
1820
|
+
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
1821
|
+
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1673
1822
|
}
|
|
1674
1823
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1675
1824
|
if (contig > 0) {
|
|
@@ -1678,7 +1827,7 @@ export class AUNClient {
|
|
|
1678
1827
|
msg_seq: contig,
|
|
1679
1828
|
device_id: this._deviceId,
|
|
1680
1829
|
slot_id: this._slotId,
|
|
1681
|
-
}).catch((e) => { this._clientLog.debug(
|
|
1830
|
+
}).catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1682
1831
|
}
|
|
1683
1832
|
this._saveSeqTrackerState();
|
|
1684
1833
|
}
|
|
@@ -1746,7 +1895,7 @@ export class AUNClient {
|
|
|
1746
1895
|
}
|
|
1747
1896
|
}
|
|
1748
1897
|
catch (exc) {
|
|
1749
|
-
this._clientLog.debug(
|
|
1898
|
+
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1750
1899
|
}
|
|
1751
1900
|
await this._publishAppEvent('group.message_created', notification);
|
|
1752
1901
|
}
|
|
@@ -1759,6 +1908,7 @@ export class AUNClient {
|
|
|
1759
1908
|
if (this._gapFillDone.has(dedupKey))
|
|
1760
1909
|
return;
|
|
1761
1910
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1911
|
+
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1762
1912
|
try {
|
|
1763
1913
|
const result = await this.call('group.pull', {
|
|
1764
1914
|
group_id: groupId,
|
|
@@ -1766,6 +1916,7 @@ export class AUNClient {
|
|
|
1766
1916
|
device_id: this._deviceId,
|
|
1767
1917
|
limit: 50,
|
|
1768
1918
|
});
|
|
1919
|
+
let filled = 0;
|
|
1769
1920
|
if (isJsonObject(result)) {
|
|
1770
1921
|
const messages = result.messages;
|
|
1771
1922
|
if (Array.isArray(messages)) {
|
|
@@ -1782,14 +1933,16 @@ export class AUNClient {
|
|
|
1782
1933
|
else {
|
|
1783
1934
|
await this._publishAppEvent('group.message_created', msg);
|
|
1784
1935
|
}
|
|
1936
|
+
filled += 1;
|
|
1785
1937
|
}
|
|
1786
1938
|
}
|
|
1787
1939
|
this._prunePushedSeqs(ns);
|
|
1788
1940
|
}
|
|
1789
1941
|
}
|
|
1942
|
+
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1790
1943
|
}
|
|
1791
1944
|
catch (exc) {
|
|
1792
|
-
this._clientLog.warn(
|
|
1945
|
+
this._clientLog.warn(`group message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1793
1946
|
}
|
|
1794
1947
|
finally {
|
|
1795
1948
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1806,11 +1959,13 @@ export class AUNClient {
|
|
|
1806
1959
|
if (this._gapFillDone.has(dedupKey))
|
|
1807
1960
|
return;
|
|
1808
1961
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1962
|
+
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1809
1963
|
try {
|
|
1810
1964
|
const result = await this.call('message.pull', {
|
|
1811
1965
|
after_seq: afterSeq,
|
|
1812
1966
|
limit: 50,
|
|
1813
1967
|
});
|
|
1968
|
+
let filled = 0;
|
|
1814
1969
|
if (isJsonObject(result)) {
|
|
1815
1970
|
const messages = result.messages;
|
|
1816
1971
|
if (Array.isArray(messages)) {
|
|
@@ -1827,14 +1982,16 @@ export class AUNClient {
|
|
|
1827
1982
|
else {
|
|
1828
1983
|
await this._publishAppEvent('message.received', msg);
|
|
1829
1984
|
}
|
|
1985
|
+
filled += 1;
|
|
1830
1986
|
}
|
|
1831
1987
|
}
|
|
1832
1988
|
this._prunePushedSeqs(ns);
|
|
1833
1989
|
}
|
|
1834
1990
|
}
|
|
1991
|
+
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1835
1992
|
}
|
|
1836
1993
|
catch (exc) {
|
|
1837
|
-
this._clientLog.warn(`P2P
|
|
1994
|
+
this._clientLog.warn(`P2P message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1838
1995
|
}
|
|
1839
1996
|
finally {
|
|
1840
1997
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1975,6 +2132,7 @@ export class AUNClient {
|
|
|
1975
2132
|
if (this._gapFillDone.has(dedupKey))
|
|
1976
2133
|
return;
|
|
1977
2134
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
2135
|
+
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1978
2136
|
try {
|
|
1979
2137
|
const result = await this.call('group.pull_events', {
|
|
1980
2138
|
group_id: groupId,
|
|
@@ -1982,6 +2140,7 @@ export class AUNClient {
|
|
|
1982
2140
|
device_id: this._deviceId,
|
|
1983
2141
|
limit: 50,
|
|
1984
2142
|
});
|
|
2143
|
+
let filled = 0;
|
|
1985
2144
|
if (isJsonObject(result)) {
|
|
1986
2145
|
const events = result.events;
|
|
1987
2146
|
if (Array.isArray(events)) {
|
|
@@ -1991,7 +2150,7 @@ export class AUNClient {
|
|
|
1991
2150
|
if (serverAck > 0) {
|
|
1992
2151
|
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1993
2152
|
if (contigBefore < serverAck) {
|
|
1994
|
-
this._clientLog.info(`group.pull_events retention-floor
|
|
2153
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBefore} -> cursor.current_seq=${serverAck}`);
|
|
1995
2154
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1996
2155
|
}
|
|
1997
2156
|
}
|
|
@@ -2004,7 +2163,7 @@ export class AUNClient {
|
|
|
2004
2163
|
event_seq: contig,
|
|
2005
2164
|
device_id: this._deviceId,
|
|
2006
2165
|
slot_id: this._slotId,
|
|
2007
|
-
}).catch((e) => { this._clientLog.debug(
|
|
2166
|
+
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2008
2167
|
}
|
|
2009
2168
|
for (const evt of events) {
|
|
2010
2169
|
if (isJsonObject(evt)) {
|
|
@@ -2020,13 +2179,15 @@ export class AUNClient {
|
|
|
2020
2179
|
}
|
|
2021
2180
|
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2022
2181
|
await this._dispatcher.publish('group.changed', evt);
|
|
2182
|
+
filled += 1;
|
|
2023
2183
|
}
|
|
2024
2184
|
}
|
|
2025
2185
|
}
|
|
2026
2186
|
}
|
|
2187
|
+
this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
2027
2188
|
}
|
|
2028
2189
|
catch (exc) {
|
|
2029
|
-
this._clientLog.warn(
|
|
2190
|
+
this._clientLog.warn(`group event gap fill failed: ${formatCaughtError(exc)}`);
|
|
2030
2191
|
}
|
|
2031
2192
|
finally {
|
|
2032
2193
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -2156,103 +2317,113 @@ export class AUNClient {
|
|
|
2156
2317
|
return member ? String(member.group_id ?? '') : '';
|
|
2157
2318
|
}
|
|
2158
2319
|
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
|
-
}
|
|
2320
|
+
const tStart = Date.now();
|
|
2321
|
+
try {
|
|
2322
|
+
if (isJsonObject(data)) {
|
|
2323
|
+
const d = data;
|
|
2324
|
+
const groupId = String(d.group_id ?? '');
|
|
2325
|
+
const action = String(d.action ?? '');
|
|
2326
|
+
this._clientLog.debug(`_onRawGroupChanged enter: group_id=${groupId}, action=${action}, event_seq=${String(d.event_seq ?? '')}`);
|
|
2327
|
+
// 验签:有 client_signature 就验,没有默认安全(H20: 严格 boolean)
|
|
2328
|
+
const cs = d.client_signature;
|
|
2329
|
+
if (cs && isJsonObject(cs)) {
|
|
2330
|
+
d._verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2331
|
+
}
|
|
2332
|
+
await this._dispatcher.publish('group.changed', d);
|
|
2333
|
+
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
2334
|
+
// 用 onMessageSeq 返回值决定是否补拉,与 P2P / group.message 路径对齐。
|
|
2335
|
+
let needPull = false;
|
|
2336
|
+
const rawEventSeq = d.event_seq;
|
|
2337
|
+
if (rawEventSeq != null && groupId) {
|
|
2338
|
+
const es = Number(rawEventSeq);
|
|
2339
|
+
if (Number.isFinite(es) && es > 0) {
|
|
2340
|
+
needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
|
|
2341
|
+
}
|
|
2342
|
+
// ISSUE-TS-002: 群事件推送路径 ack + 持久化,与 P2P/群消息路径对齐
|
|
2343
|
+
this._saveSeqTrackerState();
|
|
2344
|
+
const contig = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2345
|
+
if (contig > 0) {
|
|
2346
|
+
this._transport.call('group.ack_events', {
|
|
2347
|
+
group_id: groupId,
|
|
2348
|
+
event_seq: contig,
|
|
2349
|
+
device_id: this._deviceId,
|
|
2350
|
+
slot_id: this._slotId,
|
|
2351
|
+
}).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2207
2352
|
}
|
|
2208
2353
|
}
|
|
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));
|
|
2354
|
+
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
2355
|
+
if (needPull && groupId && !d._from_gap_fill) {
|
|
2356
|
+
this._fillGroupEventGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
2357
|
+
}
|
|
2358
|
+
// 成员退出或被踢 → 剩余 admin/owner 自动补位轮换
|
|
2359
|
+
// H21: 避免 epoch 轮换风暴——所有剩余 admin 同时收到事件不能都发起轮换,
|
|
2360
|
+
// 否则 CAS 冲突激增。策略:本地 AID 为"排序最小 admin"时才发起,其他 admin
|
|
2361
|
+
// 叠加随机 jitter 作为超时兜底(本地最小 admin 失败时由下一位顶上)。
|
|
2362
|
+
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
2363
|
+
if (groupId) {
|
|
2364
|
+
{
|
|
2365
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2366
|
+
if (expectedEpoch === null) {
|
|
2367
|
+
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
2368
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2369
|
+
else {
|
|
2370
|
+
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2231
2371
|
}
|
|
2232
2372
|
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
// 成员加入:按 action 区分策略
|
|
2376
|
+
// - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
|
|
2377
|
+
// - joined / invite_code_used(开放群/邀请码群):新成员先恢复 committed_epoch,延迟轮换
|
|
2378
|
+
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
2379
|
+
if (groupId) {
|
|
2380
|
+
{
|
|
2381
|
+
const action = String(d.action ?? '');
|
|
2382
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2383
|
+
const joinedAids = this._joinedMemberAidsFromPayload(d);
|
|
2384
|
+
const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
|
|
2385
|
+
this._clientLog.debug(`group.changed action=${action} groupId=${groupId} joinedAids=${JSON.stringify(joinedAids)} myAid=${this._aid} isSelfJoining=${String(isSelfJoining)} expectedEpoch=${String(expectedEpoch)}`);
|
|
2386
|
+
if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
|
|
2387
|
+
// open/invite_code 群:所有在线成员都参与延迟轮换
|
|
2388
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2236
2389
|
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2237
|
-
|
|
2390
|
+
if (!isSelfJoining) {
|
|
2391
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2392
|
+
}
|
|
2393
|
+
if (expectedEpoch !== null) {
|
|
2394
|
+
const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
|
|
2395
|
+
this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2396
|
+
}
|
|
2238
2397
|
}
|
|
2239
2398
|
else {
|
|
2240
|
-
|
|
2399
|
+
// member_added / join_approved:立即轮换
|
|
2400
|
+
if (expectedEpoch === null) {
|
|
2401
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2402
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2403
|
+
}
|
|
2404
|
+
else {
|
|
2405
|
+
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2406
|
+
}
|
|
2241
2407
|
}
|
|
2242
2408
|
}
|
|
2243
2409
|
}
|
|
2244
2410
|
}
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2411
|
+
// 群组解散 → 清理本地 epoch key、seq_tracker、补洞去重缓存
|
|
2412
|
+
if (d.action === 'dissolved') {
|
|
2413
|
+
if (groupId) {
|
|
2414
|
+
this._cleanupDissolvedGroup(groupId);
|
|
2415
|
+
}
|
|
2250
2416
|
}
|
|
2251
2417
|
}
|
|
2418
|
+
else {
|
|
2419
|
+
// data 非对象也透传给用户(兼容旧版)
|
|
2420
|
+
await this._dispatcher.publish('group.changed', data);
|
|
2421
|
+
}
|
|
2422
|
+
this._clientLog.debug(`_onRawGroupChanged exit: elapsed=${Date.now() - tStart}ms`);
|
|
2252
2423
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2424
|
+
catch (err) {
|
|
2425
|
+
this._clientLog.debug(`_onRawGroupChanged exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
2426
|
+
throw err;
|
|
2256
2427
|
}
|
|
2257
2428
|
}
|
|
2258
2429
|
/**
|
|
@@ -2260,82 +2431,95 @@ export class AUNClient {
|
|
|
2260
2431
|
* 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
|
|
2261
2432
|
*/
|
|
2262
2433
|
async _onGroupStateCommitted(data) {
|
|
2263
|
-
|
|
2434
|
+
const tStart = Date.now();
|
|
2435
|
+
if (!isJsonObject(data)) {
|
|
2436
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
2264
2437
|
return;
|
|
2438
|
+
}
|
|
2265
2439
|
const d = data;
|
|
2266
2440
|
const groupId = String(d.group_id ?? '').trim();
|
|
2267
|
-
if (!groupId)
|
|
2441
|
+
if (!groupId) {
|
|
2442
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (no group_id)`);
|
|
2268
2443
|
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
|
-
|
|
2444
|
+
}
|
|
2445
|
+
this._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupId}, state_version=${String(d.state_version ?? '')}`);
|
|
2446
|
+
try {
|
|
2447
|
+
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
2448
|
+
const cs = d.client_signature;
|
|
2449
|
+
if (cs && isJsonObject(cs)) {
|
|
2450
|
+
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2451
|
+
if (verified === false) {
|
|
2452
|
+
this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
d._verified = verified;
|
|
2456
|
+
}
|
|
2457
|
+
const stateVersion = Number(d.state_version ?? 0);
|
|
2458
|
+
const stateHash = String(d.state_hash ?? '').trim();
|
|
2459
|
+
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
2460
|
+
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
2461
|
+
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
2462
|
+
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
2463
|
+
// 1. 验证 prev_state_hash 连续性
|
|
2464
|
+
const loadFn = this._keystore.loadGroupState;
|
|
2465
|
+
const localState = loadFn ? loadFn.call(this._keystore, groupId) : null;
|
|
2466
|
+
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
2467
|
+
this._clientLog.warn(`state_hash chain discontinuous group=${groupId} local_sv=${localState.state_version} event_sv=${stateVersion}`);
|
|
2468
|
+
// 回源同步
|
|
2469
|
+
try {
|
|
2470
|
+
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
2471
|
+
if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
|
|
2472
|
+
const sv = Number(serverState.state_version ?? 0);
|
|
2473
|
+
const sHash = String(serverState.state_hash ?? '');
|
|
2474
|
+
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
2475
|
+
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
2476
|
+
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
2477
|
+
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
2478
|
+
// 回源也做 hash 验证
|
|
2479
|
+
if (sMembersJson && sHash) {
|
|
2480
|
+
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
2481
|
+
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
2482
|
+
const computed = computeStateHash({
|
|
2483
|
+
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
2484
|
+
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
2485
|
+
});
|
|
2486
|
+
if (computed !== sHash) {
|
|
2487
|
+
this._clientLog.warn(`backfill state_hash verification failed group=${groupId} sv=${sv} expected=${sHash} got=${computed}`);
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2492
|
+
if (saveFn) {
|
|
2493
|
+
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2311
2494
|
}
|
|
2312
2495
|
}
|
|
2313
|
-
const saveFn = this._keystore.saveGroupState;
|
|
2314
|
-
if (saveFn) {
|
|
2315
|
-
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2316
|
-
}
|
|
2317
2496
|
}
|
|
2497
|
+
catch (exc) {
|
|
2498
|
+
this._clientLog.warn(`state backfill failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
2499
|
+
}
|
|
2500
|
+
return;
|
|
2318
2501
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2502
|
+
// 2. 本地重算验证
|
|
2503
|
+
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
2504
|
+
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
2505
|
+
const computed = computeStateHash({
|
|
2506
|
+
groupId, stateVersion, keyEpoch,
|
|
2507
|
+
members, policy, prevStateHash,
|
|
2508
|
+
});
|
|
2509
|
+
if (computed !== stateHash) {
|
|
2510
|
+
this._clientLog.warn(`state_hash recompute mismatch group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
|
|
2511
|
+
return;
|
|
2321
2512
|
}
|
|
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;
|
|
2513
|
+
// 3. 更新本地存储
|
|
2514
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2515
|
+
if (saveFn) {
|
|
2516
|
+
saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2517
|
+
}
|
|
2518
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
2334
2519
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2520
|
+
catch (err) {
|
|
2521
|
+
this._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2522
|
+
throw err;
|
|
2339
2523
|
}
|
|
2340
2524
|
}
|
|
2341
2525
|
/**
|
|
@@ -2343,23 +2527,34 @@ export class AUNClient {
|
|
|
2343
2527
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
2344
2528
|
*/
|
|
2345
2529
|
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
2530
|
+
const tStart = Date.now();
|
|
2346
2531
|
const myAid = this._aid;
|
|
2347
|
-
if (!myAid || this._closing || this._state !== 'connected')
|
|
2532
|
+
if (!myAid || this._closing || this._state !== 'connected') {
|
|
2533
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} reason=${!myAid ? 'no_aid' : this._closing ? 'closing' : 'not_connected'}`);
|
|
2348
2534
|
return;
|
|
2535
|
+
}
|
|
2536
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch enter: group=${groupId}, trigger=${triggerId || '-'}, expectedEpoch=${String(expectedEpoch)}, allowMember=${String(allowMember)}`);
|
|
2349
2537
|
const started = Date.now();
|
|
2350
2538
|
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
2351
|
-
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
2539
|
+
if (triggerId && this._groupMembershipRotationDone.has(triggerId)) {
|
|
2540
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (trigger_done)`);
|
|
2352
2541
|
return;
|
|
2353
|
-
|
|
2542
|
+
}
|
|
2543
|
+
if (this._closing || this._state !== 'connected') {
|
|
2544
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (closing/disconnected)`);
|
|
2354
2545
|
return;
|
|
2546
|
+
}
|
|
2355
2547
|
if (Date.now() - started > 20000) {
|
|
2356
2548
|
this._clientLog.warn(`group epoch rotation still in-flight; skip pending trigger (group=${groupId} trigger=${triggerId || '-'})`);
|
|
2549
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (inflight_timeout)`);
|
|
2357
2550
|
return;
|
|
2358
2551
|
}
|
|
2359
2552
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2360
2553
|
}
|
|
2361
|
-
if (this._closing || this._state !== 'connected')
|
|
2554
|
+
if (this._closing || this._state !== 'connected') {
|
|
2555
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (closing/disconnected after wait)`);
|
|
2362
2556
|
return;
|
|
2557
|
+
}
|
|
2363
2558
|
this._groupEpochRotationInflight.add(groupId);
|
|
2364
2559
|
try {
|
|
2365
2560
|
if (this._closing || this._state !== 'connected')
|
|
@@ -2407,11 +2602,14 @@ export class AUNClient {
|
|
|
2407
2602
|
const leader = candidates[0];
|
|
2408
2603
|
if (leader === myAid) {
|
|
2409
2604
|
// 我是 leader,直接发起
|
|
2605
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch leader election: group=${groupId}, I am leader, initiating rotation`);
|
|
2410
2606
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2411
2607
|
return;
|
|
2412
2608
|
}
|
|
2413
|
-
if (!candidates.includes(myAid))
|
|
2609
|
+
if (!candidates.includes(myAid)) {
|
|
2610
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch skipped: group=${groupId}, not in candidate list`);
|
|
2414
2611
|
return;
|
|
2612
|
+
}
|
|
2415
2613
|
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
2416
2614
|
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
2417
2615
|
let beforeEpoch = 0;
|
|
@@ -2448,14 +2646,15 @@ export class AUNClient {
|
|
|
2448
2646
|
});
|
|
2449
2647
|
return;
|
|
2450
2648
|
}
|
|
2451
|
-
this._clientLog.info(`[H21] leader
|
|
2649
|
+
this._clientLog.info(`[H21] leader did not complete epoch rotation, non-leader fallback: group=${groupId} myAid=${myAid}`);
|
|
2452
2650
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2453
2651
|
}
|
|
2454
2652
|
catch (exc) {
|
|
2455
|
-
this._clientLog.warn(`_maybeLeadRotateGroupEpoch
|
|
2653
|
+
this._clientLog.warn(`_maybeLeadRotateGroupEpoch failed: ${formatCaughtError(exc)}`);
|
|
2456
2654
|
}
|
|
2457
2655
|
finally {
|
|
2458
2656
|
this._groupEpochRotationInflight.delete(groupId);
|
|
2657
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
2459
2658
|
}
|
|
2460
2659
|
}
|
|
2461
2660
|
/**
|
|
@@ -2470,7 +2669,7 @@ export class AUNClient {
|
|
|
2470
2669
|
this._groupE2ee.removeGroup(groupId);
|
|
2471
2670
|
}
|
|
2472
2671
|
catch (exc) {
|
|
2473
|
-
this._clientLog.warn(
|
|
2672
|
+
this._clientLog.warn(`cleanup disbanded group ${groupId} epoch key failed: ${formatCaughtError(exc)}`);
|
|
2474
2673
|
}
|
|
2475
2674
|
// 2. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
2476
2675
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -2487,7 +2686,7 @@ export class AUNClient {
|
|
|
2487
2686
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
2488
2687
|
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
2489
2688
|
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
2490
|
-
this._clientLog.info(
|
|
2689
|
+
this._clientLog.info(`cleaned up disbanded group ${groupId} local state`);
|
|
2491
2690
|
}
|
|
2492
2691
|
/** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
|
|
2493
2692
|
/**
|
|
@@ -2522,7 +2721,7 @@ export class AUNClient {
|
|
|
2522
2721
|
if (expectedFP) {
|
|
2523
2722
|
const actualFP = 'sha256:' + certObj.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2524
2723
|
if (actualFP !== expectedFP) {
|
|
2525
|
-
this._clientLog.warn(
|
|
2724
|
+
this._clientLog.warn(`signature verification failed: cert fingerprint mismatch aid=${sigAid}`);
|
|
2526
2725
|
return false;
|
|
2527
2726
|
}
|
|
2528
2727
|
}
|
|
@@ -2533,7 +2732,7 @@ export class AUNClient {
|
|
|
2533
2732
|
const pubKey = certObj.publicKey;
|
|
2534
2733
|
const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
|
|
2535
2734
|
if (!ok) {
|
|
2536
|
-
this._clientLog.warn(
|
|
2735
|
+
this._clientLog.warn(`group event signature verification failed aid=${sigAid} method=${method}`);
|
|
2537
2736
|
// P1-16: 签名失败统一发布事件
|
|
2538
2737
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2539
2738
|
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
@@ -2542,7 +2741,7 @@ export class AUNClient {
|
|
|
2542
2741
|
return ok;
|
|
2543
2742
|
}
|
|
2544
2743
|
catch (exc) {
|
|
2545
|
-
this._clientLog.warn(
|
|
2744
|
+
this._clientLog.warn(`group event signature verification error: ${formatCaughtError(exc)}`);
|
|
2546
2745
|
// P1-16: 签名失败统一发布事件
|
|
2547
2746
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2548
2747
|
aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
|
|
@@ -2600,6 +2799,7 @@ export class AUNClient {
|
|
|
2600
2799
|
result = this._groupE2ee.handleIncoming(actualPayload);
|
|
2601
2800
|
if (result === 'distribution') {
|
|
2602
2801
|
await this._discardGroupDistributionIfStale(actualPayload);
|
|
2802
|
+
this._clientLog.debug(`group key distribution received: group_id=${String(actualPayload.group_id ?? '')}, epoch=${String(actualPayload.epoch ?? '')}, rotation=${String(actualPayload.rotation_id ?? '')}`);
|
|
2603
2803
|
// 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
|
|
2604
2804
|
const distGroupId = actualPayload.group_id;
|
|
2605
2805
|
if (distGroupId && !this._groupSynced.has(distGroupId)) {
|
|
@@ -2613,6 +2813,7 @@ export class AUNClient {
|
|
|
2613
2813
|
// 处理密钥请求并回复
|
|
2614
2814
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2615
2815
|
const requester = String(actualPayload.requester_aid ?? '');
|
|
2816
|
+
this._clientLog.debug(`group key request received: group_id=${groupId}, requester=${requester}, epoch=${String(actualPayload.epoch ?? '')}`);
|
|
2616
2817
|
let members = this._groupE2ee.getMemberAids(groupId);
|
|
2617
2818
|
// 请求者不在本地成员列表时,回源查询服务端最新成员列表,
|
|
2618
2819
|
// 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
|
|
@@ -2626,7 +2827,7 @@ export class AUNClient {
|
|
|
2626
2827
|
members = memberList.map((m) => String(m.aid));
|
|
2627
2828
|
}
|
|
2628
2829
|
catch (exc) {
|
|
2629
|
-
this._clientLog.warn(
|
|
2830
|
+
this._clientLog.warn(`group ${groupId} member list backfill failed: ${formatCaughtError(exc)}`);
|
|
2630
2831
|
}
|
|
2631
2832
|
}
|
|
2632
2833
|
const response = this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
|
|
@@ -2640,7 +2841,7 @@ export class AUNClient {
|
|
|
2640
2841
|
});
|
|
2641
2842
|
}
|
|
2642
2843
|
catch (exc) {
|
|
2643
|
-
this._clientLog.warn(
|
|
2844
|
+
this._clientLog.warn(`replying group key to ${requester} failed: ${formatCaughtError(exc)}`);
|
|
2644
2845
|
}
|
|
2645
2846
|
}
|
|
2646
2847
|
}
|
|
@@ -2649,9 +2850,10 @@ export class AUNClient {
|
|
|
2649
2850
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2650
2851
|
const rotationId = String(actualPayload.rotation_id ?? '');
|
|
2651
2852
|
const keyCommitment = String(actualPayload.commitment ?? '');
|
|
2853
|
+
this._clientLog.debug(`group key ${result} handled: group_id=${groupId}, epoch=${String(actualPayload.epoch ?? '')}, rotation=${rotationId}`);
|
|
2652
2854
|
if (rotationId && keyCommitment) {
|
|
2653
2855
|
this._ackGroupRotationKey(rotationId, keyCommitment)
|
|
2654
|
-
.catch((exc) => this._clientLog.warn(
|
|
2856
|
+
.catch((exc) => this._clientLog.warn(`submit epoch key ack failed: ${formatCaughtError(exc)}`));
|
|
2655
2857
|
}
|
|
2656
2858
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2657
2859
|
}
|
|
@@ -2663,133 +2865,158 @@ export class AUNClient {
|
|
|
2663
2865
|
* 跨域时自动路由到 peer 所在域的 Gateway。
|
|
2664
2866
|
*/
|
|
2665
2867
|
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;
|
|
2868
|
+
const tStart = Date.now();
|
|
2869
|
+
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
2679
2870
|
try {
|
|
2680
|
-
const
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2871
|
+
const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
|
|
2872
|
+
const cached = this._certCache.get(cacheKey);
|
|
2873
|
+
const now = Date.now() / 1000;
|
|
2874
|
+
if (cached && now < cached.refreshAfter) {
|
|
2875
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (cache_hit)`);
|
|
2876
|
+
return cached.certPem;
|
|
2877
|
+
}
|
|
2878
|
+
const gatewayUrl = this._gatewayUrl;
|
|
2879
|
+
if (!gatewayUrl) {
|
|
2880
|
+
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2881
|
+
}
|
|
2882
|
+
// 跨域时用 peer 所在域的 Gateway URL
|
|
2883
|
+
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2884
|
+
let certPem;
|
|
2885
|
+
try {
|
|
2886
|
+
const certUrl = AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
2887
|
+
certPem = await _httpGetText(certUrl, this._configModel.verifySsl);
|
|
2686
2888
|
}
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2889
|
+
catch (exc) {
|
|
2890
|
+
if (!certFingerprint) {
|
|
2891
|
+
throw exc;
|
|
2892
|
+
}
|
|
2893
|
+
const fallbackCert = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl);
|
|
2894
|
+
certPem = fallbackCert;
|
|
2895
|
+
}
|
|
2896
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
2897
|
+
if (certFingerprint) {
|
|
2898
|
+
const expectedFP = String(certFingerprint).trim().toLowerCase();
|
|
2899
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
2900
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
2901
|
+
}
|
|
2902
|
+
const expectedHex = expectedFP.slice('sha256:'.length);
|
|
2903
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
2904
|
+
const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2905
|
+
let spkiHex = '';
|
|
2906
|
+
try {
|
|
2907
|
+
const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
2908
|
+
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
2909
|
+
}
|
|
2910
|
+
catch {
|
|
2911
|
+
spkiHex = '';
|
|
2912
|
+
}
|
|
2913
|
+
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
2914
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
// 完整 PKI 验证
|
|
2700
2918
|
try {
|
|
2701
|
-
|
|
2702
|
-
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
2919
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
2703
2920
|
}
|
|
2704
|
-
catch {
|
|
2705
|
-
|
|
2921
|
+
catch (exc) {
|
|
2922
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2706
2923
|
}
|
|
2707
|
-
|
|
2708
|
-
|
|
2924
|
+
const nowSec = Date.now() / 1000;
|
|
2925
|
+
this._certCache.set(cacheKey, {
|
|
2926
|
+
certPem,
|
|
2927
|
+
validatedAt: nowSec,
|
|
2928
|
+
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
2929
|
+
});
|
|
2930
|
+
try {
|
|
2931
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
2932
|
+
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2709
2933
|
}
|
|
2934
|
+
catch (exc) {
|
|
2935
|
+
this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
2936
|
+
}
|
|
2937
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
2938
|
+
return certPem;
|
|
2710
2939
|
}
|
|
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);
|
|
2940
|
+
catch (err) {
|
|
2941
|
+
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2942
|
+
throw err;
|
|
2730
2943
|
}
|
|
2731
|
-
return certPem;
|
|
2732
2944
|
}
|
|
2733
2945
|
/** 获取对方所有设备的 prekey 列表。 */
|
|
2734
2946
|
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
|
-
}
|
|
2947
|
+
const tStart = Date.now();
|
|
2948
|
+
this._clientLog.debug(`_fetchPeerPrekeys enter: aid=${peerAid}`);
|
|
2748
2949
|
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);
|
|
2950
|
+
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2951
|
+
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
2952
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2759
2953
|
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;
|
|
2954
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (cache_hit count=${normalized.length})`);
|
|
2955
|
+
return normalized.map((item) => ({ ...item }));
|
|
2766
2956
|
}
|
|
2767
2957
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
const prekey = result.prekey;
|
|
2772
|
-
if (prekey) {
|
|
2773
|
-
const normalized = normalizePeerPrekeys([prekey]);
|
|
2958
|
+
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2959
|
+
if (cached !== null) {
|
|
2960
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
2774
2961
|
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]);
|
|
2962
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (e2ee_cache_hit)`);
|
|
2780
2963
|
return normalized.map((item) => ({ ...item }));
|
|
2781
2964
|
}
|
|
2782
2965
|
}
|
|
2783
|
-
|
|
2784
|
-
|
|
2966
|
+
try {
|
|
2967
|
+
const result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
2968
|
+
if (!isJsonObject(result)) {
|
|
2969
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2970
|
+
}
|
|
2971
|
+
if (result.found === false) {
|
|
2972
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (not_found)`);
|
|
2973
|
+
return [];
|
|
2974
|
+
}
|
|
2975
|
+
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2976
|
+
if (devicePrekeys) {
|
|
2977
|
+
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
2978
|
+
if (normalized.length > 0) {
|
|
2979
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
2980
|
+
items: normalized.map((item) => ({ ...item })),
|
|
2981
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2982
|
+
});
|
|
2983
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2984
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (devices=${normalized.length})`);
|
|
2985
|
+
return normalized;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
if (!isPeerPrekeyResponse(result)) {
|
|
2989
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2990
|
+
}
|
|
2991
|
+
const prekey = result.prekey;
|
|
2992
|
+
if (prekey) {
|
|
2993
|
+
const normalized = normalizePeerPrekeys([prekey]);
|
|
2994
|
+
if (normalized.length > 0) {
|
|
2995
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
2996
|
+
items: normalized.map((item) => ({ ...item })),
|
|
2997
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2998
|
+
});
|
|
2999
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3000
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (legacy_single)`);
|
|
3001
|
+
return normalized.map((item) => ({ ...item }));
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
if (result.found) {
|
|
3005
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
3006
|
+
}
|
|
3007
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (empty)`);
|
|
3008
|
+
return [];
|
|
2785
3009
|
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
throw exc;
|
|
3010
|
+
catch (exc) {
|
|
3011
|
+
if (exc instanceof ValidationError) {
|
|
3012
|
+
throw exc;
|
|
3013
|
+
}
|
|
3014
|
+
throw new ValidationError(`failed to fetch peer prekey for ${peerAid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2791
3015
|
}
|
|
2792
|
-
|
|
3016
|
+
}
|
|
3017
|
+
catch (err) {
|
|
3018
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit (error): elapsed=${Date.now() - tStart}ms aid=${peerAid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3019
|
+
throw err;
|
|
2793
3020
|
}
|
|
2794
3021
|
}
|
|
2795
3022
|
/** 获取对方的单个 prekey(兼容接口,优先返回第一条 device prekey)。 */
|
|
@@ -2877,10 +3104,10 @@ export class AUNClient {
|
|
|
2877
3104
|
catch (exc) {
|
|
2878
3105
|
// 刷新失败时:若内存缓存有 PKI 验证过的证书(未过期 x2 倍 TTL)则继续用
|
|
2879
3106
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2880
|
-
this._clientLog.debug(
|
|
3107
|
+
this._clientLog.debug(`refresh sender ${aid} cert failed, continuing with verified memory cache: ${formatCaughtError(exc)}`);
|
|
2881
3108
|
return true;
|
|
2882
3109
|
}
|
|
2883
|
-
this._clientLog.warn(
|
|
3110
|
+
this._clientLog.warn(`failed to get sender ${aid} cert and no verified cache, rejecting trust: ${formatCaughtError(exc)}`);
|
|
2884
3111
|
return false;
|
|
2885
3112
|
}
|
|
2886
3113
|
}
|
|
@@ -2902,33 +3129,50 @@ export class AUNClient {
|
|
|
2902
3129
|
}
|
|
2903
3130
|
/** 解密单条 P2P 消息 */
|
|
2904
3131
|
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
|
-
|
|
3132
|
+
const tStart = Date.now();
|
|
3133
|
+
const fromLog = String(message.from ?? '');
|
|
3134
|
+
const midLog = String(message.message_id ?? '');
|
|
3135
|
+
this._clientLog.debug(`_decryptSingleMessage enter: from=${fromLog}, mid=${midLog}`);
|
|
3136
|
+
try {
|
|
3137
|
+
const payload = message.payload;
|
|
3138
|
+
if (!isJsonObject(payload)) {
|
|
3139
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
3140
|
+
return message;
|
|
3141
|
+
}
|
|
3142
|
+
const payloadObj = payload;
|
|
3143
|
+
if (payloadObj.type !== 'e2ee.encrypted') {
|
|
3144
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms (not encrypted type)`);
|
|
3145
|
+
return message;
|
|
3146
|
+
}
|
|
3147
|
+
if (message.encrypted === false) {
|
|
3148
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms (encrypted=false)`);
|
|
3149
|
+
return message;
|
|
3150
|
+
}
|
|
3151
|
+
// 确保发送方证书已缓存到 keystore
|
|
3152
|
+
const fromAid = String(message.from ?? '');
|
|
3153
|
+
const senderCertFingerprint = String(payloadObj.sender_cert_fingerprint ?? payloadObj.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
3154
|
+
if (fromAid) {
|
|
3155
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
3156
|
+
if (!certReady) {
|
|
3157
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping decrypt`);
|
|
3158
|
+
throw new Error(`发送方证书不可用: from=${fromAid}, mid=${message.message_id}`);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
// 密码学解密(E2EEManager.decryptMessage 内含本地防重放)
|
|
3162
|
+
const decrypted = this._e2ee.decryptMessage(message);
|
|
3163
|
+
this._schedulePrekeyReplenishIfConsumed(decrypted);
|
|
3164
|
+
// TS-015: 解密返回 null 表示失败(密文损坏/签名无效/重放等),
|
|
3165
|
+
// 不得回退到原始密文投递给应用层,应抛出错误触发 undecryptable 事件
|
|
3166
|
+
if (decrypted === null) {
|
|
3167
|
+
throw new Error(`E2EE 解密失败: from=${message.from}, mid=${message.message_id}`);
|
|
3168
|
+
}
|
|
3169
|
+
this._clientLog.debug(`_decryptSingleMessage exit: elapsed=${Date.now() - tStart}ms from=${fromAid}, mid=${midLog}`);
|
|
3170
|
+
return decrypted;
|
|
3171
|
+
}
|
|
3172
|
+
catch (err) {
|
|
3173
|
+
this._clientLog.debug(`_decryptSingleMessage exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3174
|
+
throw err;
|
|
3175
|
+
}
|
|
2932
3176
|
}
|
|
2933
3177
|
/** 批量解密 P2P 消息(用于 message.pull) */
|
|
2934
3178
|
async _decryptMessages(messages) {
|
|
@@ -2952,7 +3196,7 @@ export class AUNClient {
|
|
|
2952
3196
|
if (fromAid) {
|
|
2953
3197
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2954
3198
|
if (!certReady) {
|
|
2955
|
-
this._clientLog.warn(
|
|
3199
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping decrypt`);
|
|
2956
3200
|
continue;
|
|
2957
3201
|
}
|
|
2958
3202
|
}
|
|
@@ -2963,7 +3207,7 @@ export class AUNClient {
|
|
|
2963
3207
|
}
|
|
2964
3208
|
else {
|
|
2965
3209
|
// TS-015: 解密失败不回退到密文,跳过该消息并记录
|
|
2966
|
-
this._clientLog.warn(`pull
|
|
3210
|
+
this._clientLog.warn(`pull message decrypt failed, skipping: from=${msg.from} mid=${msg.message_id}`);
|
|
2967
3211
|
}
|
|
2968
3212
|
}
|
|
2969
3213
|
else {
|
|
@@ -3016,23 +3260,37 @@ export class AUNClient {
|
|
|
3016
3260
|
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
3017
3261
|
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
3018
3262
|
return;
|
|
3019
|
-
this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(
|
|
3263
|
+
this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(`group ${groupId} pending message retry failed: ${formatCaughtError(exc)}`));
|
|
3020
3264
|
}
|
|
3021
3265
|
async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
|
|
3022
|
-
const
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3266
|
+
const tStart = Date.now();
|
|
3267
|
+
this._clientLog.debug(`_recoverGroupEpochKey enter: group=${groupId}, epoch=${epoch}, sender=${senderAid || '-'}, timeout=${timeoutMs}ms`);
|
|
3268
|
+
try {
|
|
3269
|
+
const existing = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3270
|
+
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, existing)) {
|
|
3271
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3272
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group=${groupId} (already_ready)`);
|
|
3273
|
+
return true;
|
|
3274
|
+
}
|
|
3275
|
+
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
3276
|
+
const key = `${groupId}:${epoch}`;
|
|
3277
|
+
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
3278
|
+
if (inflight) {
|
|
3279
|
+
const result = await inflight;
|
|
3280
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group=${groupId} (joined_inflight) result=${result}`);
|
|
3281
|
+
return result;
|
|
3282
|
+
}
|
|
3283
|
+
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
3284
|
+
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
3285
|
+
this._groupEpochRecoveryInflight.set(key, promise);
|
|
3286
|
+
const result = await promise;
|
|
3287
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit: elapsed=${Date.now() - tStart}ms group=${groupId} result=${result}`);
|
|
3288
|
+
return result;
|
|
3289
|
+
}
|
|
3290
|
+
catch (err) {
|
|
3291
|
+
this._clientLog.debug(`_recoverGroupEpochKey exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3292
|
+
throw err;
|
|
3026
3293
|
}
|
|
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
3294
|
}
|
|
3037
3295
|
static _extractGroupJoinMode(payload) {
|
|
3038
3296
|
if (!isJsonObject(payload))
|
|
@@ -3125,13 +3383,13 @@ export class AUNClient {
|
|
|
3125
3383
|
const myAid = this._aid || '';
|
|
3126
3384
|
const keyPair = this._keystore.loadKeyPair(myAid);
|
|
3127
3385
|
if (!keyPair?.private_key_pem) {
|
|
3128
|
-
this._clientLog.warn(
|
|
3386
|
+
this._clientLog.warn(`cannot load AID private key for ECIES decrypt: aid=${myAid}`);
|
|
3129
3387
|
return false;
|
|
3130
3388
|
}
|
|
3131
3389
|
const { eciesDecrypt } = await import('./e2ee-group.js');
|
|
3132
3390
|
const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
|
|
3133
3391
|
if (!groupSecret || groupSecret.length !== 32) {
|
|
3134
|
-
this._clientLog.warn(
|
|
3392
|
+
this._clientLog.warn(`server epoch key ECIES decrypt result length abnormal: group=${groupId} epoch=${serverEpoch} len=${groupSecret?.length ?? 0}`);
|
|
3135
3393
|
return false;
|
|
3136
3394
|
}
|
|
3137
3395
|
// 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
|
|
@@ -3168,7 +3426,7 @@ export class AUNClient {
|
|
|
3168
3426
|
}
|
|
3169
3427
|
catch { /* best effort */ }
|
|
3170
3428
|
if (memberAids.length === 0) {
|
|
3171
|
-
this._clientLog.warn(
|
|
3429
|
+
this._clientLog.warn(`server epoch key recovery missing member snapshot: group=${groupId} epoch=${serverEpoch}`);
|
|
3172
3430
|
return false;
|
|
3173
3431
|
}
|
|
3174
3432
|
const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
|
|
@@ -3179,7 +3437,7 @@ export class AUNClient {
|
|
|
3179
3437
|
const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
|
|
3180
3438
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3181
3439
|
if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
|
|
3182
|
-
this._clientLog.warn(
|
|
3440
|
+
this._clientLog.warn(`server epoch key recovery commitment mismatch: group=${groupId} epoch=${serverEpoch}`);
|
|
3183
3441
|
return false;
|
|
3184
3442
|
}
|
|
3185
3443
|
if (epochChain && committedEpoch === serverEpoch) {
|
|
@@ -3195,7 +3453,7 @@ export class AUNClient {
|
|
|
3195
3453
|
const prevChain = String(prevData?.epoch_chain ?? '').trim();
|
|
3196
3454
|
if (prevChain && rotatorAid) {
|
|
3197
3455
|
if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
|
|
3198
|
-
this._clientLog.warn(
|
|
3456
|
+
this._clientLog.warn(`server epoch key recovery epoch_chain verification failed: group=${groupId} epoch=${serverEpoch} rotator=${rotatorAid}`);
|
|
3199
3457
|
return false;
|
|
3200
3458
|
}
|
|
3201
3459
|
epochChainUnverified = false;
|
|
@@ -3208,14 +3466,14 @@ export class AUNClient {
|
|
|
3208
3466
|
}
|
|
3209
3467
|
const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
|
|
3210
3468
|
if (!stored) {
|
|
3211
|
-
this._clientLog.warn(
|
|
3469
|
+
this._clientLog.warn(`server epoch key recovery store failed: group=${groupId} epoch=${serverEpoch}`);
|
|
3212
3470
|
return false;
|
|
3213
3471
|
}
|
|
3214
|
-
this._clientLog.info(
|
|
3472
|
+
this._clientLog.info(`recovered epoch key from server: group=${groupId} epoch=${serverEpoch}`);
|
|
3215
3473
|
return true;
|
|
3216
3474
|
}
|
|
3217
3475
|
catch (exc) {
|
|
3218
|
-
this._clientLog.debug(
|
|
3476
|
+
this._clientLog.debug(`recover epoch key from server failed: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3219
3477
|
return false;
|
|
3220
3478
|
}
|
|
3221
3479
|
}
|
|
@@ -3242,7 +3500,7 @@ export class AUNClient {
|
|
|
3242
3500
|
groupSecretBytes = loaded.secret;
|
|
3243
3501
|
}
|
|
3244
3502
|
else {
|
|
3245
|
-
this._clientLog.debug(
|
|
3503
|
+
this._clientLog.debug(`cannot get group_secret for ECIES encrypt: group=${groupId} epoch=${targetEpoch}`);
|
|
3246
3504
|
return {};
|
|
3247
3505
|
}
|
|
3248
3506
|
}
|
|
@@ -3263,21 +3521,23 @@ export class AUNClient {
|
|
|
3263
3521
|
encryptedKeys[aid] = ciphertext.toString('base64');
|
|
3264
3522
|
}
|
|
3265
3523
|
catch (exc) {
|
|
3266
|
-
this._clientLog.debug(
|
|
3524
|
+
this._clientLog.debug(`building ECIES epoch key for member ${aid} failed: ${formatCaughtError(exc)}`);
|
|
3267
3525
|
continue;
|
|
3268
3526
|
}
|
|
3269
3527
|
}
|
|
3270
3528
|
return encryptedKeys;
|
|
3271
3529
|
}
|
|
3272
3530
|
catch (exc) {
|
|
3273
|
-
this._clientLog.debug(
|
|
3531
|
+
this._clientLog.debug(`building encrypted_keys failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
3274
3532
|
return {};
|
|
3275
3533
|
}
|
|
3276
3534
|
}
|
|
3277
3535
|
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
3536
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} key recovery start: sender=${senderAid || '-'}, timeout=${timeoutMs}ms`);
|
|
3278
3537
|
// 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
|
|
3279
3538
|
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3280
3539
|
if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
|
|
3540
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} recovered from server`);
|
|
3281
3541
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3282
3542
|
return true;
|
|
3283
3543
|
}
|
|
@@ -3308,16 +3568,18 @@ export class AUNClient {
|
|
|
3308
3568
|
}
|
|
3309
3569
|
}
|
|
3310
3570
|
catch {
|
|
3311
|
-
this._clientLog.debug(
|
|
3571
|
+
this._clientLog.debug(`group ${groupId} query online members failed, falling back to all candidates`);
|
|
3312
3572
|
}
|
|
3313
3573
|
if (onlineAids !== null) {
|
|
3314
3574
|
if (onlineAids.length === 0) {
|
|
3315
|
-
this._clientLog.info(
|
|
3575
|
+
this._clientLog.info(`group ${groupId} epoch ${String(epoch)} recovery failed: no online members to request key`);
|
|
3316
3576
|
return false;
|
|
3317
3577
|
}
|
|
3578
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery: requesting from ${onlineAids.length} online members`);
|
|
3318
3579
|
await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
|
|
3319
3580
|
}
|
|
3320
3581
|
else {
|
|
3582
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery: requesting from all candidates`);
|
|
3321
3583
|
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
3322
3584
|
}
|
|
3323
3585
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -3325,14 +3587,20 @@ export class AUNClient {
|
|
|
3325
3587
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
3326
3588
|
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3327
3589
|
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret)) {
|
|
3590
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery success`);
|
|
3328
3591
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3329
3592
|
return true;
|
|
3330
3593
|
}
|
|
3331
3594
|
}
|
|
3332
3595
|
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3333
3596
|
const ready = await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret);
|
|
3334
|
-
if (ready)
|
|
3597
|
+
if (ready) {
|
|
3598
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery success (post-deadline check)`);
|
|
3335
3599
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3600
|
+
}
|
|
3601
|
+
else {
|
|
3602
|
+
this._clientLog.warn(`group ${groupId} epoch ${epoch} P2P key recovery timeout: timeout=${timeoutMs}ms`);
|
|
3603
|
+
}
|
|
3336
3604
|
return ready;
|
|
3337
3605
|
}
|
|
3338
3606
|
/** 只向在线成员发送密钥恢复请求 */
|
|
@@ -3382,48 +3650,70 @@ export class AUNClient {
|
|
|
3382
3650
|
}
|
|
3383
3651
|
}
|
|
3384
3652
|
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);
|
|
3653
|
+
const tStart = Date.now();
|
|
3654
|
+
const groupIdLog = String(message.group_id ?? '');
|
|
3655
|
+
const senderLog = String(message.from ?? message.sender_aid ?? '');
|
|
3656
|
+
this._clientLog.debug(`_decryptGroupMessage enter: group=${groupIdLog}, from=${senderLog}, mid=${String(message.message_id ?? '')}`);
|
|
3657
|
+
try {
|
|
3658
|
+
const payload = message.payload;
|
|
3659
|
+
if (!isJsonObject(payload)) {
|
|
3660
|
+
const r = this._attachGroupDispatchModeToPayload(message);
|
|
3661
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
3662
|
+
return r;
|
|
3663
|
+
}
|
|
3664
|
+
const payloadObj = payload;
|
|
3665
|
+
if (payloadObj.type !== 'e2ee.group_encrypted') {
|
|
3666
|
+
const r = this._attachGroupDispatchModeToPayload(message);
|
|
3667
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (not group_encrypted)`);
|
|
3668
|
+
return r;
|
|
3669
|
+
}
|
|
3670
|
+
// 确保发送方证书已缓存(签名验证需要)
|
|
3671
|
+
const senderAid = String(message.from ?? message.sender_aid ?? '');
|
|
3672
|
+
if (senderAid) {
|
|
3673
|
+
const certOk = await this._ensureSenderCertCached(senderAid);
|
|
3674
|
+
if (!certOk) {
|
|
3675
|
+
this._clientLog.warn(`group message decrypt skipped: sender ${senderAid} cert unavailable`);
|
|
3676
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (cert_unavailable)`);
|
|
3677
|
+
return message;
|
|
3420
3678
|
}
|
|
3421
3679
|
}
|
|
3422
|
-
|
|
3423
|
-
|
|
3680
|
+
// 先尝试直接解密
|
|
3681
|
+
const result = this._groupE2ee.decrypt(message, opts);
|
|
3682
|
+
if (result !== null && result.e2ee) {
|
|
3683
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (direct_ok)`);
|
|
3684
|
+
return this._attachGroupDispatchModeToPayload(result);
|
|
3685
|
+
}
|
|
3686
|
+
// replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
|
|
3687
|
+
// 不是解密失败,不应触发 recover
|
|
3688
|
+
if (result !== null) {
|
|
3689
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (replay_guard)`);
|
|
3690
|
+
return result;
|
|
3691
|
+
}
|
|
3692
|
+
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
3693
|
+
const groupId = String(message.group_id ?? '');
|
|
3694
|
+
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
3695
|
+
const epoch = Number(payloadObj.epoch ?? 0);
|
|
3696
|
+
if (epoch > 0 && groupId) {
|
|
3697
|
+
try {
|
|
3698
|
+
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
3699
|
+
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
3700
|
+
if (retry !== null && retry.e2ee) {
|
|
3701
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (recover_ok)`);
|
|
3702
|
+
return this._attachGroupDispatchModeToPayload(retry);
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
}
|
|
3706
|
+
catch (exc) {
|
|
3707
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} sync recovery failed: ${formatCaughtError(exc)}`);
|
|
3708
|
+
}
|
|
3424
3709
|
}
|
|
3710
|
+
this._clientLog.debug(`_decryptGroupMessage exit: elapsed=${Date.now() - tStart}ms (fallback_plain)`);
|
|
3711
|
+
return message;
|
|
3712
|
+
}
|
|
3713
|
+
catch (err) {
|
|
3714
|
+
this._clientLog.debug(`_decryptGroupMessage exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3715
|
+
throw err;
|
|
3425
3716
|
}
|
|
3426
|
-
return message;
|
|
3427
3717
|
}
|
|
3428
3718
|
_attachGroupDispatchModeToPayload(message) {
|
|
3429
3719
|
const payload = message.payload;
|
|
@@ -3541,7 +3831,7 @@ export class AUNClient {
|
|
|
3541
3831
|
if (fromAid) {
|
|
3542
3832
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
3543
3833
|
if (!certReady) {
|
|
3544
|
-
this._clientLog.warn(
|
|
3834
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping message.thought.get decrypt`);
|
|
3545
3835
|
decryptFailed = true;
|
|
3546
3836
|
}
|
|
3547
3837
|
}
|
|
@@ -3602,6 +3892,7 @@ export class AUNClient {
|
|
|
3602
3892
|
const failed = [];
|
|
3603
3893
|
let lastHeartbeat = Date.now();
|
|
3604
3894
|
const distributions = (Array.isArray(info.distributions) ? info.distributions : []);
|
|
3895
|
+
this._clientLog.debug(`epoch key distribution start: rotation=${rotationId}, target_members=${distributions.length}`);
|
|
3605
3896
|
for (const dist of distributions) {
|
|
3606
3897
|
if (!isJsonObject(dist) || !dist.to || !isJsonObject(dist.payload))
|
|
3607
3898
|
continue;
|
|
@@ -3626,7 +3917,7 @@ export class AUNClient {
|
|
|
3626
3917
|
}
|
|
3627
3918
|
else {
|
|
3628
3919
|
failed.push(String(dist.to));
|
|
3629
|
-
this._clientLog.warn(`epoch
|
|
3920
|
+
this._clientLog.warn(`epoch key distribution failed (to=${dist.to}): ${formatCaughtError(exc)}`);
|
|
3630
3921
|
}
|
|
3631
3922
|
}
|
|
3632
3923
|
}
|
|
@@ -3644,23 +3935,26 @@ export class AUNClient {
|
|
|
3644
3935
|
return isJsonObject(result) && result.success === true;
|
|
3645
3936
|
}
|
|
3646
3937
|
catch (exc) {
|
|
3647
|
-
this._clientLog.warn(
|
|
3938
|
+
this._clientLog.warn(`refresh epoch rotation lease failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3648
3939
|
return false;
|
|
3649
3940
|
}
|
|
3650
3941
|
}
|
|
3651
3942
|
async _ackGroupRotationKey(rotationId, keyCommitment, deviceId) {
|
|
3652
3943
|
if (!rotationId)
|
|
3653
3944
|
return false;
|
|
3945
|
+
this._clientLog.debug(`_ackGroupRotationKey enter: rotation=${rotationId}, commitment=${keyCommitment}, device=${deviceId ?? this._deviceId}`);
|
|
3654
3946
|
try {
|
|
3655
3947
|
const result = await this.call('group.e2ee.ack_rotation_key', {
|
|
3656
3948
|
rotation_id: rotationId,
|
|
3657
3949
|
key_commitment: keyCommitment,
|
|
3658
3950
|
device_id: deviceId ?? this._deviceId,
|
|
3659
3951
|
});
|
|
3660
|
-
|
|
3952
|
+
const success = isJsonObject(result) && result.success === true;
|
|
3953
|
+
this._clientLog.debug(`_ackGroupRotationKey done: rotation=${rotationId}, success=${success}`);
|
|
3954
|
+
return success;
|
|
3661
3955
|
}
|
|
3662
3956
|
catch (exc) {
|
|
3663
|
-
this._clientLog.warn(
|
|
3957
|
+
this._clientLog.warn(`submit epoch key ack failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3664
3958
|
return false;
|
|
3665
3959
|
}
|
|
3666
3960
|
}
|
|
@@ -3688,7 +3982,7 @@ export class AUNClient {
|
|
|
3688
3982
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3689
3983
|
: [];
|
|
3690
3984
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3691
|
-
this._clientLog.debug(
|
|
3985
|
+
this._clientLog.debug(`allowing group key distribution: new member recovery commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3692
3986
|
}
|
|
3693
3987
|
else {
|
|
3694
3988
|
return false;
|
|
@@ -3697,7 +3991,7 @@ export class AUNClient {
|
|
|
3697
3991
|
}
|
|
3698
3992
|
return true;
|
|
3699
3993
|
}
|
|
3700
|
-
this._clientLog.info(
|
|
3994
|
+
this._clientLog.info(`rejecting future epoch key distribution missing rotation_id: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3701
3995
|
return false;
|
|
3702
3996
|
}
|
|
3703
3997
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -3718,10 +4012,10 @@ export class AUNClient {
|
|
|
3718
4012
|
}
|
|
3719
4013
|
}
|
|
3720
4014
|
catch (exc) {
|
|
3721
|
-
this._clientLog.warn(
|
|
4015
|
+
this._clientLog.warn(`rejecting epoch key distribution: cannot verify active rotation: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3722
4016
|
return false;
|
|
3723
4017
|
}
|
|
3724
|
-
this._clientLog.info(
|
|
4018
|
+
this._clientLog.info(`rejecting epoch key distribution: rotation not in pending/committed state: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
|
|
3725
4019
|
return false;
|
|
3726
4020
|
}
|
|
3727
4021
|
async _discardGroupDistributionIfStale(payload) {
|
|
@@ -3736,10 +4030,10 @@ export class AUNClient {
|
|
|
3736
4030
|
return;
|
|
3737
4031
|
try {
|
|
3738
4032
|
this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
|
|
3739
|
-
this._clientLog.info(
|
|
4033
|
+
this._clientLog.info(`discarding stale group epoch key after verify: group=${groupId} epoch=${epoch} rotation=${rotationId}`);
|
|
3740
4034
|
}
|
|
3741
4035
|
catch (exc) {
|
|
3742
|
-
this._clientLog.debug(
|
|
4036
|
+
this._clientLog.debug(`cleanup stale group epoch key failed: group=${groupId} epoch=${epoch} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3743
4037
|
}
|
|
3744
4038
|
}
|
|
3745
4039
|
async _verifyGroupKeyResponseEpoch(payload) {
|
|
@@ -3756,7 +4050,7 @@ export class AUNClient {
|
|
|
3756
4050
|
return false;
|
|
3757
4051
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
3758
4052
|
if (epoch > committedEpoch) {
|
|
3759
|
-
this._clientLog.info(
|
|
4053
|
+
this._clientLog.info(`rejecting uncommitted epoch group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3760
4054
|
return false;
|
|
3761
4055
|
}
|
|
3762
4056
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
@@ -3767,7 +4061,7 @@ export class AUNClient {
|
|
|
3767
4061
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3768
4062
|
: [];
|
|
3769
4063
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3770
|
-
this._clientLog.debug(
|
|
4064
|
+
this._clientLog.debug(`allowing group key response: new member recovery commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3771
4065
|
}
|
|
3772
4066
|
else {
|
|
3773
4067
|
return false;
|
|
@@ -3777,7 +4071,7 @@ export class AUNClient {
|
|
|
3777
4071
|
return true;
|
|
3778
4072
|
}
|
|
3779
4073
|
catch (exc) {
|
|
3780
|
-
this._clientLog.warn(
|
|
4074
|
+
this._clientLog.warn(`rejecting group key response: cannot verify committed epoch: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3781
4075
|
return false;
|
|
3782
4076
|
}
|
|
3783
4077
|
}
|
|
@@ -3792,7 +4086,7 @@ export class AUNClient {
|
|
|
3792
4086
|
return isJsonObject(result) && result.success === true;
|
|
3793
4087
|
}
|
|
3794
4088
|
catch (exc) {
|
|
3795
|
-
this._clientLog.warn(
|
|
4089
|
+
this._clientLog.warn(`abort epoch rotation failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3796
4090
|
return false;
|
|
3797
4091
|
}
|
|
3798
4092
|
}
|
|
@@ -3899,11 +4193,11 @@ export class AUNClient {
|
|
|
3899
4193
|
catch (exc) {
|
|
3900
4194
|
if (attempt < maxRetries) {
|
|
3901
4195
|
const delay = 500 * Math.pow(2, attempt - 1);
|
|
3902
|
-
this._clientLog.warn(
|
|
4196
|
+
this._clientLog.warn(`sync epoch to server failed (group=${groupId}, attempt ${attempt}/${maxRetries}): ${formatCaughtError(exc)}, retrying in ${delay}ms`);
|
|
3903
4197
|
await new Promise(r => setTimeout(r, delay));
|
|
3904
4198
|
}
|
|
3905
4199
|
else {
|
|
3906
|
-
this._clientLog.error(
|
|
4200
|
+
this._clientLog.error(`sync epoch to server final failure (group=${groupId}, retried ${maxRetries} times): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3907
4201
|
}
|
|
3908
4202
|
}
|
|
3909
4203
|
}
|
|
@@ -3917,9 +4211,13 @@ export class AUNClient {
|
|
|
3917
4211
|
* 使用服务端两阶段 rotation,避免服务端先提交但密钥未分发。
|
|
3918
4212
|
*/
|
|
3919
4213
|
async _rotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
4214
|
+
const tStart = Date.now();
|
|
4215
|
+
this._clientLog.debug(`_rotateGroupEpoch enter: group=${groupId}, trigger=${triggerId || '-'}, expectedEpoch=${String(expectedEpoch)}`);
|
|
3920
4216
|
try {
|
|
3921
|
-
if (!this._aid)
|
|
4217
|
+
if (!this._aid) {
|
|
4218
|
+
this._clientLog.debug(`_rotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (no_aid)`);
|
|
3922
4219
|
return;
|
|
4220
|
+
}
|
|
3923
4221
|
const memberAids = await this._getGroupMemberAids(groupId);
|
|
3924
4222
|
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
3925
4223
|
return;
|
|
@@ -3965,7 +4263,7 @@ export class AUNClient {
|
|
|
3965
4263
|
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
3966
4264
|
if (rawChain) {
|
|
3967
4265
|
prevChainHint = rawChain;
|
|
3968
|
-
this._clientLog.info(
|
|
4266
|
+
this._clientLog.info(`new member rotation supplementing prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
|
|
3969
4267
|
}
|
|
3970
4268
|
}
|
|
3971
4269
|
}
|
|
@@ -3977,7 +4275,7 @@ export class AUNClient {
|
|
|
3977
4275
|
this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
|
|
3978
4276
|
}
|
|
3979
4277
|
catch (cleanupExc) {
|
|
3980
|
-
this._clientLog.debug(
|
|
4278
|
+
this._clientLog.debug(`cleanup local pending group key failed: group=${groupId} epoch=${targetEpoch} rotation=${rotationId} err=${formatCaughtError(cleanupExc)}`);
|
|
3981
4279
|
}
|
|
3982
4280
|
};
|
|
3983
4281
|
const rotateParams = {
|
|
@@ -4105,13 +4403,17 @@ export class AUNClient {
|
|
|
4105
4403
|
this._groupMembershipRotationDone = new Set(Array.from(this._groupMembershipRotationDone).slice(-1000));
|
|
4106
4404
|
}
|
|
4107
4405
|
}
|
|
4406
|
+
this._clientLog.debug(`_rotateGroupEpoch done: group=${groupId}, targetEpoch=${targetEpoch}, rotation=${activeRotationId}, trigger=${triggerId || '-'}`);
|
|
4407
|
+
this._clientLog.debug(`_rotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
4108
4408
|
}
|
|
4109
4409
|
catch (exc) {
|
|
4110
4410
|
this._logE2eeError('rotate_epoch', groupId, '', exc);
|
|
4411
|
+
this._clientLog.debug(`_rotateGroupEpoch exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${exc instanceof Error ? exc.message : String(exc)}`);
|
|
4111
4412
|
}
|
|
4112
4413
|
}
|
|
4113
4414
|
/** 将当前 group_secret 通过 P2P E2EE 分发给新成员 */
|
|
4114
4415
|
async _distributeKeyToNewMember(groupId, newMemberAid) {
|
|
4416
|
+
this._clientLog.debug(`_distributeKeyToNewMember enter: group=${groupId}, new_member=${newMemberAid}`);
|
|
4115
4417
|
try {
|
|
4116
4418
|
const secretData = this._groupE2ee.loadSecret(groupId);
|
|
4117
4419
|
if (secretData === null)
|
|
@@ -4142,16 +4444,19 @@ export class AUNClient {
|
|
|
4142
4444
|
// 重试 3 次,间隔递增(1s, 2s)
|
|
4143
4445
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4144
4446
|
try {
|
|
4447
|
+
this._clientLog.debug(`_distributeKeyToNewMember send attempt: group=${groupId}, new_member=${newMemberAid}, attempt=${attempt + 1}/3, epoch=${epoch}`);
|
|
4145
4448
|
await this.call('message.send', {
|
|
4146
4449
|
to: newMemberAid,
|
|
4147
4450
|
payload: distPayload,
|
|
4148
4451
|
encrypt: true,
|
|
4149
4452
|
persist_required: true,
|
|
4150
4453
|
});
|
|
4454
|
+
this._clientLog.debug(`_distributeKeyToNewMember success: group=${groupId}, new_member=${newMemberAid}, epoch=${epoch}`);
|
|
4151
4455
|
break; // 成功则跳出重试循环
|
|
4152
4456
|
}
|
|
4153
4457
|
catch (sendExc) {
|
|
4154
4458
|
if (attempt < 2) {
|
|
4459
|
+
this._clientLog.debug(`_distributeKeyToNewMember attempt failed, will retry: group=${groupId}, new_member=${newMemberAid}, attempt=${attempt + 1}/3, err=${formatCaughtError(sendExc)}`);
|
|
4155
4460
|
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
|
4156
4461
|
}
|
|
4157
4462
|
else {
|
|
@@ -4215,7 +4520,9 @@ export class AUNClient {
|
|
|
4215
4520
|
static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
|
|
4216
4521
|
/** open/invite_code 入群后延迟轮换。 */
|
|
4217
4522
|
async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
|
|
4218
|
-
|
|
4523
|
+
const delay = delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS;
|
|
4524
|
+
this._clientLog.debug(`_delayedRotateAfterJoin enter: group=${groupId}, trigger_id=${triggerId}, expected_epoch=${expectedEpoch}, allow_member=${allowMember}, delay_ms=${delay}`);
|
|
4525
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
4219
4526
|
await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
|
|
4220
4527
|
}
|
|
4221
4528
|
/** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
|
|
@@ -4224,8 +4531,11 @@ export class AUNClient {
|
|
|
4224
4531
|
.filter(aid => aid && aid !== this._aid);
|
|
4225
4532
|
if (!groupId || !this._aid || memberAids.length === 0)
|
|
4226
4533
|
return;
|
|
4227
|
-
if (!this._groupE2ee.hasSecret(groupId))
|
|
4534
|
+
if (!this._groupE2ee.hasSecret(groupId)) {
|
|
4535
|
+
this._clientLog.debug(`backfill skipped: group=${groupId}, no local epoch key`);
|
|
4228
4536
|
return;
|
|
4537
|
+
}
|
|
4538
|
+
this._clientLog.debug(`backfill key distribution start: group=${groupId}, target_members=${JSON.stringify(memberAids)}`);
|
|
4229
4539
|
for (const memberAid of memberAids) {
|
|
4230
4540
|
const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
|
|
4231
4541
|
if (this._groupMemberKeyBackfillDone.has(dedupeKey))
|
|
@@ -4308,7 +4618,7 @@ export class AUNClient {
|
|
|
4308
4618
|
}
|
|
4309
4619
|
}
|
|
4310
4620
|
catch (exc) {
|
|
4311
|
-
this._clientLog.warn(
|
|
4621
|
+
this._clientLog.warn(`restore SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
4312
4622
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
4313
4623
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4314
4624
|
phase: 'restore',
|
|
@@ -4348,7 +4658,7 @@ export class AUNClient {
|
|
|
4348
4658
|
delete newState[oldNs];
|
|
4349
4659
|
newState[newNs] = Math.max(oldVal, curVal);
|
|
4350
4660
|
}
|
|
4351
|
-
this._clientLog.info(`SeqTracker group_id
|
|
4661
|
+
this._clientLog.info(`SeqTracker group_id migration: ${Object.keys(renameMap).length} namespaces rewritten`);
|
|
4352
4662
|
// 落盘
|
|
4353
4663
|
const saver = this._keystore.saveSeq;
|
|
4354
4664
|
const deleter = this._keystore.deleteSeq;
|
|
@@ -4359,14 +4669,14 @@ export class AUNClient {
|
|
|
4359
4669
|
deleter.call(this._keystore, this._aid, this._deviceId, this._slotId, oldNs);
|
|
4360
4670
|
}
|
|
4361
4671
|
catch (e) {
|
|
4362
|
-
this._clientLog.debug(
|
|
4672
|
+
this._clientLog.debug(`delete old seq ns failed: ns=${oldNs} err=${formatCaughtError(e)}`);
|
|
4363
4673
|
}
|
|
4364
4674
|
}
|
|
4365
4675
|
try {
|
|
4366
4676
|
saver.call(this._keystore, this._aid, this._deviceId, this._slotId, newNs, newState[newNs]);
|
|
4367
4677
|
}
|
|
4368
4678
|
catch (e) {
|
|
4369
|
-
this._clientLog.debug(
|
|
4679
|
+
this._clientLog.debug(`write new seq ns failed: ns=${newNs} err=${formatCaughtError(e)}`);
|
|
4370
4680
|
}
|
|
4371
4681
|
}
|
|
4372
4682
|
}
|
|
@@ -4426,7 +4736,7 @@ export class AUNClient {
|
|
|
4426
4736
|
}
|
|
4427
4737
|
}
|
|
4428
4738
|
catch (exc) {
|
|
4429
|
-
this._clientLog.warn(
|
|
4739
|
+
this._clientLog.warn(`save SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
4430
4740
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
4431
4741
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4432
4742
|
phase: 'save',
|
|
@@ -4481,6 +4791,7 @@ export class AUNClient {
|
|
|
4481
4791
|
// ── 内部:连接 ────────────────────────────────────────────
|
|
4482
4792
|
/** 执行一次连接流程 */
|
|
4483
4793
|
async _connectOnce(params, allowReauth) {
|
|
4794
|
+
const tStart = Date.now();
|
|
4484
4795
|
const gatewayUrl = this._resolveGateway(params);
|
|
4485
4796
|
this._gatewayUrl = gatewayUrl;
|
|
4486
4797
|
this._slotId = String(params.slot_id ?? '');
|
|
@@ -4488,12 +4799,14 @@ export class AUNClient {
|
|
|
4488
4799
|
const prevState = this._state;
|
|
4489
4800
|
this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
|
|
4490
4801
|
this._state = 'connecting';
|
|
4802
|
+
this._clientLog.debug(`_connectOnce enter: gateway=${gatewayUrl}, allowReauth=${allowReauth}`);
|
|
4491
4803
|
// 前置 restore:在 transport.connect 启动 reader 之前完成,
|
|
4492
4804
|
// 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
|
|
4493
4805
|
this._refreshSeqTrackerContext();
|
|
4494
4806
|
this._restoreSeqTrackerState();
|
|
4495
4807
|
try {
|
|
4496
4808
|
const challenge = await this._transport.connect(gatewayUrl);
|
|
4809
|
+
this._clientLog.debug(`WebSocket connection established: gateway=${gatewayUrl}`);
|
|
4497
4810
|
this._state = 'authenticating';
|
|
4498
4811
|
if (allowReauth) {
|
|
4499
4812
|
const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
|
|
@@ -4525,6 +4838,7 @@ export class AUNClient {
|
|
|
4525
4838
|
this._syncIdentityAfterConnect(String(params.access_token));
|
|
4526
4839
|
}
|
|
4527
4840
|
this._state = 'connected';
|
|
4841
|
+
this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
|
|
4528
4842
|
await this._dispatcher.publish('connection.state', { state: this._state, gateway: gatewayUrl });
|
|
4529
4843
|
// auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
|
|
4530
4844
|
// 若 context 发生变化,重新 refresh + restore,保持 tracker 与真实身份一致。
|
|
@@ -4538,11 +4852,13 @@ export class AUNClient {
|
|
|
4538
4852
|
await this._uploadPrekey();
|
|
4539
4853
|
}
|
|
4540
4854
|
catch (exc) {
|
|
4541
|
-
this._clientLog.warn(`prekey
|
|
4855
|
+
this._clientLog.warn(`prekey upload failed: ${formatCaughtError(exc)}`);
|
|
4542
4856
|
}
|
|
4857
|
+
this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl}, aid=${this._aid ?? ''}`);
|
|
4543
4858
|
}
|
|
4544
4859
|
catch (err) {
|
|
4545
4860
|
this._state = prevState === 'connected' ? 'disconnected' : 'idle';
|
|
4861
|
+
this._clientLog.debug(`_connectOnce exit (error): elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl} err=${err instanceof Error ? err.message : String(err)}`);
|
|
4546
4862
|
throw err;
|
|
4547
4863
|
}
|
|
4548
4864
|
}
|
|
@@ -4708,10 +5024,10 @@ export class AUNClient {
|
|
|
4708
5024
|
consecutiveFailures = 0;
|
|
4709
5025
|
}).catch((exc) => {
|
|
4710
5026
|
consecutiveFailures++;
|
|
4711
|
-
this._clientLog.warn(
|
|
5027
|
+
this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
|
|
4712
5028
|
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) }).catch(() => { });
|
|
4713
5029
|
if (consecutiveFailures >= maxFailures) {
|
|
4714
|
-
this._clientLog.warn(
|
|
5030
|
+
this._clientLog.warn(`${maxFailures} consecutive heartbeat failures, triggering reconnect`);
|
|
4715
5031
|
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
4716
5032
|
}
|
|
4717
5033
|
});
|
|
@@ -4775,7 +5091,7 @@ export class AUNClient {
|
|
|
4775
5091
|
if (exc instanceof AuthError) {
|
|
4776
5092
|
this._tokenRefreshFailures++;
|
|
4777
5093
|
if (this._tokenRefreshFailures >= 3) {
|
|
4778
|
-
this._clientLog.warn(`token
|
|
5094
|
+
this._clientLog.warn(`token refresh failed ${this._tokenRefreshFailures} consecutive times, stopping refresh loop and triggering reconnect`);
|
|
4779
5095
|
await this._dispatcher.publish('token.refresh_exhausted', {
|
|
4780
5096
|
aid: this._identity?.aid ?? null,
|
|
4781
5097
|
consecutive_failures: this._tokenRefreshFailures,
|
|
@@ -4785,7 +5101,7 @@ export class AUNClient {
|
|
|
4785
5101
|
this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
|
|
4786
5102
|
return;
|
|
4787
5103
|
}
|
|
4788
|
-
this._clientLog.debug(`token
|
|
5104
|
+
this._clientLog.debug(`token refresh failed (${this._tokenRefreshFailures}/3), will retry: ${exc}`);
|
|
4789
5105
|
}
|
|
4790
5106
|
else {
|
|
4791
5107
|
await this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
@@ -4887,7 +5203,7 @@ export class AUNClient {
|
|
|
4887
5203
|
this._prekeyReplenished.add(prekeyId);
|
|
4888
5204
|
}
|
|
4889
5205
|
catch (exc) {
|
|
4890
|
-
this._clientLog.warn(
|
|
5206
|
+
this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${formatCaughtError(exc)}`);
|
|
4891
5207
|
}
|
|
4892
5208
|
finally {
|
|
4893
5209
|
this._prekeyReplenishInflight.delete(prekeyId);
|
|
@@ -4912,7 +5228,7 @@ export class AUNClient {
|
|
|
4912
5228
|
}
|
|
4913
5229
|
}
|
|
4914
5230
|
catch (exc) {
|
|
4915
|
-
this._clientLog.warn(`epoch
|
|
5231
|
+
this._clientLog.warn(`epoch cleanup failed: ${formatCaughtError(exc)}`);
|
|
4916
5232
|
}
|
|
4917
5233
|
}, 3600_000);
|
|
4918
5234
|
this._unrefTimer(this._groupEpochCleanupTimer);
|
|
@@ -4928,11 +5244,11 @@ export class AUNClient {
|
|
|
4928
5244
|
? this._keystore.listGroupSecretIds(this._aid)
|
|
4929
5245
|
: [];
|
|
4930
5246
|
for (const gid of groupIds) {
|
|
4931
|
-
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch
|
|
5247
|
+
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch rotation failed: ${formatCaughtError(exc)}`));
|
|
4932
5248
|
}
|
|
4933
5249
|
}
|
|
4934
5250
|
catch (exc) {
|
|
4935
|
-
this._clientLog.warn(`epoch
|
|
5251
|
+
this._clientLog.warn(`epoch rotation failed: ${formatCaughtError(exc)}`);
|
|
4936
5252
|
}
|
|
4937
5253
|
}, rotateInterval * 1000);
|
|
4938
5254
|
this._unrefTimer(this._groupEpochRotateTimer);
|
|
@@ -4980,7 +5296,7 @@ export class AUNClient {
|
|
|
4980
5296
|
_onGatewayDisconnect(data) {
|
|
4981
5297
|
const code = data?.code;
|
|
4982
5298
|
const reason = data?.reason ?? '';
|
|
4983
|
-
this._clientLog.warn(
|
|
5299
|
+
this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}`);
|
|
4984
5300
|
this._serverKicked = true;
|
|
4985
5301
|
}
|
|
4986
5302
|
/** 传输层断线回调 */
|
|
@@ -4990,6 +5306,7 @@ export class AUNClient {
|
|
|
4990
5306
|
// 已在重连中则跳过,避免心跳超时和 transport 断线回调重复触发
|
|
4991
5307
|
if (this._reconnectActive)
|
|
4992
5308
|
return;
|
|
5309
|
+
this._clientLog.warn(`transport disconnected: closeCode=${closeCode ?? 'none'}, error=${error ? formatCaughtError(error) : 'none'}`);
|
|
4993
5310
|
this._state = 'disconnected';
|
|
4994
5311
|
this._stopBackgroundTasks();
|
|
4995
5312
|
await this._dispatcher.publish('connection.state', { state: this._state, error });
|
|
@@ -5001,7 +5318,7 @@ export class AUNClient {
|
|
|
5001
5318
|
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
5002
5319
|
this._state = 'terminal_failed';
|
|
5003
5320
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
5004
|
-
this._clientLog.warn(
|
|
5321
|
+
this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
|
|
5005
5322
|
await this._dispatcher.publish('connection.state', {
|
|
5006
5323
|
state: this._state, error, reason,
|
|
5007
5324
|
});
|
|
@@ -5017,8 +5334,9 @@ export class AUNClient {
|
|
|
5017
5334
|
return;
|
|
5018
5335
|
this._reconnectActive = true;
|
|
5019
5336
|
this._reconnectAbort = new AbortController();
|
|
5337
|
+
this._clientLog.debug(`reconnect loop started: serverInitiated=${String(serverInitiated)}, aid=${this._aid ?? ''}`);
|
|
5020
5338
|
this._reconnectLoop(serverInitiated).catch((exc) => {
|
|
5021
|
-
this._clientLog.warn(
|
|
5339
|
+
this._clientLog.warn(`reconnect loop error: ${formatCaughtError(exc)}`);
|
|
5022
5340
|
});
|
|
5023
5341
|
}
|
|
5024
5342
|
/** 重连循环(for 循环 + AbortController,与 JS/Python 对齐) */
|
|
@@ -5065,6 +5383,7 @@ export class AUNClient {
|
|
|
5065
5383
|
}
|
|
5066
5384
|
await this._connectOnce(this._sessionParams, true);
|
|
5067
5385
|
// 重连成功,退出循环
|
|
5386
|
+
this._clientLog.debug(`reconnect success: attempt=${attempt}, aid=${this._aid ?? ''}`);
|
|
5068
5387
|
this._reconnectActive = false;
|
|
5069
5388
|
this._reconnectAbort = null;
|
|
5070
5389
|
return;
|
|
@@ -5110,62 +5429,80 @@ export class AUNClient {
|
|
|
5110
5429
|
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5111
5430
|
*/
|
|
5112
5431
|
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
|
-
|
|
5432
|
+
const tStart = Date.now();
|
|
5433
|
+
this._clientLog.debug(`createNamedGroup enter: groupName=${groupName}`);
|
|
5434
|
+
try {
|
|
5435
|
+
const cp = new CryptoProvider();
|
|
5436
|
+
const identity = cp.generateIdentity();
|
|
5437
|
+
const params = {};
|
|
5438
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
5439
|
+
params[k] = v;
|
|
5440
|
+
}
|
|
5441
|
+
params.group_name = groupName;
|
|
5442
|
+
params.public_key = identity.public_key_der_b64;
|
|
5443
|
+
params.curve = 'P-256';
|
|
5444
|
+
const result = await this.call('group.create', params);
|
|
5445
|
+
const groupInfo = result?.group;
|
|
5446
|
+
const aidCert = result?.aid_cert;
|
|
5447
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5448
|
+
if (groupAid && aidCert) {
|
|
5449
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5450
|
+
private_key_pem: identity.private_key_pem,
|
|
5451
|
+
public_key: identity.public_key_der_b64,
|
|
5452
|
+
curve: 'P-256',
|
|
5453
|
+
type: 'group_identity',
|
|
5454
|
+
});
|
|
5455
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5456
|
+
if (certPem) {
|
|
5457
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5458
|
+
}
|
|
5136
5459
|
}
|
|
5460
|
+
this._clientLog.debug(`createNamedGroup exit: elapsed=${Date.now() - tStart}ms groupAid=${groupAid}`);
|
|
5461
|
+
return result;
|
|
5462
|
+
}
|
|
5463
|
+
catch (err) {
|
|
5464
|
+
this._clientLog.debug(`createNamedGroup exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
5465
|
+
throw err;
|
|
5137
5466
|
}
|
|
5138
|
-
return result;
|
|
5139
5467
|
}
|
|
5140
5468
|
/**
|
|
5141
5469
|
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5142
5470
|
*/
|
|
5143
5471
|
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,
|
|
5472
|
+
const tStart = Date.now();
|
|
5473
|
+
this._clientLog.debug(`bindGroupAid enter: groupId=${groupId}, groupName=${groupName}`);
|
|
5474
|
+
try {
|
|
5475
|
+
const cp = new CryptoProvider();
|
|
5476
|
+
const identity = cp.generateIdentity();
|
|
5477
|
+
const params = {
|
|
5478
|
+
group_id: groupId,
|
|
5479
|
+
group_name: groupName,
|
|
5159
5480
|
public_key: identity.public_key_der_b64,
|
|
5160
5481
|
curve: 'P-256',
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
const
|
|
5164
|
-
|
|
5165
|
-
|
|
5482
|
+
};
|
|
5483
|
+
const result = await this.call('group.bind_aid', params);
|
|
5484
|
+
const groupInfo = result?.group;
|
|
5485
|
+
const aidCert = result?.aid_cert;
|
|
5486
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5487
|
+
if (groupAid && aidCert) {
|
|
5488
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5489
|
+
private_key_pem: identity.private_key_pem,
|
|
5490
|
+
public_key: identity.public_key_der_b64,
|
|
5491
|
+
curve: 'P-256',
|
|
5492
|
+
type: 'group_identity',
|
|
5493
|
+
});
|
|
5494
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5495
|
+
if (certPem) {
|
|
5496
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5497
|
+
}
|
|
5166
5498
|
}
|
|
5499
|
+
this._clientLog.debug(`bindGroupAid exit: elapsed=${Date.now() - tStart}ms groupAid=${groupAid}`);
|
|
5500
|
+
return result;
|
|
5501
|
+
}
|
|
5502
|
+
catch (err) {
|
|
5503
|
+
this._clientLog.debug(`bindGroupAid exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
5504
|
+
throw err;
|
|
5167
5505
|
}
|
|
5168
|
-
return result;
|
|
5169
5506
|
}
|
|
5170
5507
|
/** 判断是否应重试重连 */
|
|
5171
5508
|
static _shouldRetryReconnect(error) {
|