@agentunion/fastaun 0.2.16 → 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.d.ts +3 -0
- package/dist/auth.js +314 -217
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +7 -0
- package/dist/client.js +1137 -740
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/config.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 +7 -2
- package/dist/e2ee-group.js +216 -61
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +3 -0
- package/dist/e2ee.js +34 -11
- package/dist/e2ee.js.map +1 -1
- package/dist/events.d.ts +3 -0
- package/dist/events.js +11 -1
- package/dist/events.js.map +1 -1
- package/dist/group-id.d.ts +23 -0
- package/dist/group-id.js +94 -0
- package/dist/group-id.js.map +1 -0
- package/dist/keystore/aid-db.d.ts +8 -1
- package/dist/keystore/aid-db.js +14 -3
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +5 -0
- package/dist/keystore/file.js +19 -10
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +2 -0
- package/dist/keystore/sqlite-backup.d.ts +5 -1
- package/dist/keystore/sqlite-backup.js +9 -6
- package/dist/keystore/sqlite-backup.js.map +1 -1
- package/dist/logger.d.ts +28 -3
- package/dist/logger.js +170 -37
- 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.d.ts +4 -0
- package/dist/secret-store/file-store.js +7 -3
- package/dist/secret-store/file-store.js.map +1 -1
- package/dist/secret-store/index.d.ts +3 -0
- package/dist/secret-store/index.js +2 -2
- package/dist/secret-store/index.js.map +1 -1
- package/dist/transport.d.ts +3 -0
- package/dist/transport.js +91 -2
- package/dist/transport.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -25,6 +25,7 @@ import { EventDispatcher } from './events.js';
|
|
|
25
25
|
import { FileKeyStore } from './keystore/file.js';
|
|
26
26
|
import { AUNLogger } from './logger.js';
|
|
27
27
|
import { SQLiteBackup } from './keystore/sqlite-backup.js';
|
|
28
|
+
import { normalizeGroupId } from './group-id.js';
|
|
28
29
|
import { AuthNamespace } from './namespaces/auth.js';
|
|
29
30
|
import { CustodyNamespace } from './namespaces/custody.js';
|
|
30
31
|
import { MetaNamespace } from './namespaces/meta.js';
|
|
@@ -32,18 +33,6 @@ import { RPCTransport } from './transport.js';
|
|
|
32
33
|
import { AuthFlow } from './auth.js';
|
|
33
34
|
import { SeqTracker } from './seq-tracker.js';
|
|
34
35
|
import { isJsonObject, } from './types.js';
|
|
35
|
-
// ── 日志辅助 ──────────────────────────────────────────────────
|
|
36
|
-
/** 文件日志(模块级单例) */
|
|
37
|
-
let _debugLogger = null;
|
|
38
|
-
/** 简易日志:前缀 [aun_core.client] */
|
|
39
|
-
function _clientLog(level, msg, ...args) {
|
|
40
|
-
const ts = new Date().toISOString();
|
|
41
|
-
const formatted = args.reduce((s, a) => s.replace('%s', String(a)), msg);
|
|
42
|
-
// eslint-disable-next-line no-console
|
|
43
|
-
console.log(`[${ts}] [aun_core.client] ${level}: ${formatted}`);
|
|
44
|
-
if (_debugLogger)
|
|
45
|
-
_debugLogger.log(`${level}: ${formatted}`);
|
|
46
|
-
}
|
|
47
36
|
/**
|
|
48
37
|
* 递归排序键的 JSON 序列化(Canonical JSON for AUN)
|
|
49
38
|
* 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
|
@@ -386,57 +375,72 @@ export class AUNClient {
|
|
|
386
375
|
_reconnectActive = false;
|
|
387
376
|
_reconnectAbort = null;
|
|
388
377
|
_serverKicked = false;
|
|
378
|
+
_logger;
|
|
379
|
+
_clientLog;
|
|
389
380
|
constructor(config, debug = false) {
|
|
390
381
|
const rawConfig = { ...(config ?? {}) };
|
|
391
382
|
this._configModel = configFromMap(rawConfig);
|
|
383
|
+
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
392
384
|
this.config = {
|
|
393
385
|
aun_path: this._configModel.aunPath,
|
|
394
386
|
root_ca_path: this._configModel.rootCaPath,
|
|
395
387
|
seed_password: this._configModel.seedPassword,
|
|
396
388
|
};
|
|
397
|
-
|
|
389
|
+
// 初始化 Logger(per-client 单例,必须最早创建)
|
|
390
|
+
const debugFlag = this._configModel.debug || debug;
|
|
391
|
+
this._logger = new AUNLogger({
|
|
392
|
+
debug: debugFlag,
|
|
393
|
+
aunPath: this._configModel.aunPath,
|
|
394
|
+
});
|
|
395
|
+
this._clientLog = this._logger.for('aun_core.client');
|
|
396
|
+
if (debugFlag) {
|
|
397
|
+
this._clientLog.info(`AUNClient initialized (debug=true, aunPath=${this._configModel.aunPath})`);
|
|
398
|
+
}
|
|
399
|
+
this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
|
|
398
400
|
this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl });
|
|
399
|
-
const defaultSQLiteBackup = new SQLiteBackup(join(this._configModel.aunPath, '.aun_backup', 'aun_backup.db'));
|
|
401
|
+
const defaultSQLiteBackup = new SQLiteBackup(join(this._configModel.aunPath, '.aun_backup', 'aun_backup.db'), { logger: this._logger.for('aun_core.keystore') });
|
|
400
402
|
const keystore = new FileKeyStore(this._configModel.aunPath, {
|
|
401
403
|
encryptionSeed: this._configModel.seedPassword ?? undefined,
|
|
402
404
|
sqliteBackup: defaultSQLiteBackup,
|
|
405
|
+
logger: this._logger.for('aun_core.keystore'),
|
|
406
|
+
secretStoreLogger: this._logger.for('aun_core.secret-store'),
|
|
403
407
|
});
|
|
404
408
|
this._keystore = keystore;
|
|
405
409
|
this._deviceId = getDeviceId(this._configModel.aunPath);
|
|
406
|
-
// 初始化文件日志(仅 debug 模式)
|
|
407
|
-
if (debug) {
|
|
408
|
-
_debugLogger = new AUNLogger();
|
|
409
|
-
_clientLog('info', 'AUNClient 初始化完成 (debug=true, aunPath=%s)', this._configModel.aunPath);
|
|
410
|
-
}
|
|
411
410
|
this._slotId = '';
|
|
412
411
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
413
412
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
414
413
|
this._auth = new AuthFlow({
|
|
415
414
|
keystore,
|
|
416
415
|
crypto: new CryptoProvider(),
|
|
417
|
-
aid:
|
|
416
|
+
aid: initAid,
|
|
418
417
|
deviceId: this._deviceId,
|
|
419
418
|
slotId: this._slotId,
|
|
420
419
|
rootCaPath: this._configModel.rootCaPath ?? undefined,
|
|
421
420
|
verifySsl: this._configModel.verifySsl,
|
|
421
|
+
logger: this._logger.for('aun_core.auth'),
|
|
422
422
|
});
|
|
423
|
+
this._aid = initAid;
|
|
423
424
|
this._transport = new RPCTransport({
|
|
424
425
|
eventDispatcher: this._dispatcher,
|
|
425
426
|
timeout: 10_000,
|
|
426
427
|
onDisconnect: (err, closeCode) => this._handleTransportDisconnect(err, closeCode),
|
|
427
428
|
verifySsl: this._configModel.verifySsl,
|
|
429
|
+
logger: this._logger.for('aun_core.transport'),
|
|
428
430
|
});
|
|
429
431
|
this._e2ee = new E2EEManager({
|
|
430
432
|
identityFn: () => this._identity ?? {},
|
|
431
433
|
deviceIdFn: () => this._deviceId,
|
|
432
434
|
keystore,
|
|
433
435
|
replayWindowSeconds: this._configModel.replayWindowSeconds,
|
|
436
|
+
logger: this._logger.for('aun_core.e2ee'),
|
|
434
437
|
});
|
|
435
438
|
this._groupE2ee = new GroupE2EEManager({
|
|
436
439
|
identityFn: () => this._identity ?? {},
|
|
437
440
|
keystore,
|
|
438
441
|
senderCertResolver: (aid) => this._getVerifiedPeerCert(aid),
|
|
439
442
|
initiatorCertResolver: (aid) => this._getVerifiedPeerCert(aid),
|
|
443
|
+
logger: this._logger.for('aun_core.e2ee-group'),
|
|
440
444
|
});
|
|
441
445
|
this.auth = new AuthNamespace(this);
|
|
442
446
|
this.custody = new CustodyNamespace(this);
|
|
@@ -479,7 +483,17 @@ export class AUNClient {
|
|
|
479
483
|
}
|
|
480
484
|
/** 向 gatewayUrl 的 /health 端点发送 GET 请求,检查网关可用性 */
|
|
481
485
|
async checkGatewayHealth(gatewayUrl, timeout = 5_000) {
|
|
482
|
-
|
|
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
|
+
}
|
|
483
497
|
}
|
|
484
498
|
// ── 生命周期 ──────────────────────────────────────────────
|
|
485
499
|
/**
|
|
@@ -489,6 +503,7 @@ export class AUNClient {
|
|
|
489
503
|
* @param options - 会话选项(auto_reconnect、heartbeat_interval 等)
|
|
490
504
|
*/
|
|
491
505
|
async connect(auth, options) {
|
|
506
|
+
const tStart = Date.now();
|
|
492
507
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
493
508
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
494
509
|
}
|
|
@@ -502,77 +517,117 @@ export class AUNClient {
|
|
|
502
517
|
const callTimeoutSec = this._sessionOptions.timeouts.call;
|
|
503
518
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
|
|
504
519
|
this._closing = false;
|
|
520
|
+
this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
|
|
505
521
|
try {
|
|
506
522
|
await this._connectOnce(normalized, false);
|
|
523
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
507
524
|
}
|
|
508
525
|
catch (err) {
|
|
509
526
|
// 连接失败时回退状态,允许重试
|
|
510
527
|
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
511
528
|
this._state = 'disconnected';
|
|
512
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)}`);
|
|
513
532
|
throw err;
|
|
514
533
|
}
|
|
515
534
|
}
|
|
516
535
|
/** 关闭连接 */
|
|
517
536
|
async close() {
|
|
518
|
-
|
|
519
|
-
this.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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();
|
|
523
554
|
const closableKeyStore = this._keystore;
|
|
524
555
|
closableKeyStore.close?.();
|
|
525
556
|
this._state = 'closed';
|
|
557
|
+
this._logger.close();
|
|
558
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
526
559
|
this._resetSeqTrackingState();
|
|
527
|
-
|
|
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;
|
|
528
565
|
}
|
|
529
|
-
await this._transport.close();
|
|
530
|
-
const closableKeyStore = this._keystore;
|
|
531
|
-
closableKeyStore.close?.();
|
|
532
|
-
this._state = 'closed';
|
|
533
|
-
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
534
|
-
this._resetSeqTrackingState();
|
|
535
566
|
}
|
|
536
567
|
/**
|
|
537
568
|
* 断开连接但不关闭客户端(可重新 connect,对齐 Python disconnect)。
|
|
538
569
|
* disconnect 是可恢复的:停止心跳、关闭 WebSocket,但不清理 keystore 等状态。
|
|
539
570
|
*/
|
|
540
571
|
async disconnect() {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
}
|
|
552
596
|
}
|
|
553
597
|
/**
|
|
554
598
|
* 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID)。
|
|
555
599
|
*/
|
|
556
600
|
listIdentities() {
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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 [];
|
|
608
|
+
}
|
|
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);
|
|
572
623
|
}
|
|
573
|
-
|
|
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;
|
|
574
630
|
}
|
|
575
|
-
return summaries;
|
|
576
631
|
}
|
|
577
632
|
// ── RPC ───────────────────────────────────────────────────
|
|
578
633
|
/**
|
|
@@ -580,214 +635,257 @@ export class AUNClient {
|
|
|
580
635
|
* 自动处理内部方法限制、E2EE 加解密、客户端签名等。
|
|
581
636
|
*/
|
|
582
637
|
async call(method, params) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
+
}
|
|
615
673
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
if (!encrypt) {
|
|
623
|
-
throw new ValidationError('group.thought.put requires encrypt=true');
|
|
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
|
+
}
|
|
624
680
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
throw new ValidationError('message.thought.put requires encrypt=true');
|
|
632
|
-
}
|
|
633
|
-
return await this._putMessageThoughtEncrypted(p);
|
|
634
|
-
}
|
|
635
|
-
// 关键操作自动附加客户端签名
|
|
636
|
-
if (SIGNED_METHODS.has(method)) {
|
|
637
|
-
this._signClientOperation(method, p);
|
|
638
|
-
}
|
|
639
|
-
// P1-23: 非幂等方法使用更长超时
|
|
640
|
-
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
641
|
-
let result = callTimeout
|
|
642
|
-
? await this._transport.call(method, p, callTimeout)
|
|
643
|
-
: await this._transport.call(method, p);
|
|
644
|
-
// 自动解密:message.pull 返回的消息
|
|
645
|
-
if (method === 'message.pull' && isJsonObject(result)) {
|
|
646
|
-
const r = result;
|
|
647
|
-
const messages = r.messages;
|
|
648
|
-
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
649
|
-
if (rawMessages.length > 0) {
|
|
650
|
-
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
|
+
}
|
|
651
687
|
}
|
|
652
|
-
|
|
653
|
-
|
|
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`);
|
|
654
703
|
if (rawMessages.length > 0) {
|
|
655
|
-
this.
|
|
656
|
-
}
|
|
657
|
-
// ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
|
|
658
|
-
// 服务端在持久化/设备视图分支返回 server_ack_seq,客户端若 contiguous 落后必须 force 跳过
|
|
659
|
-
// retention window 外的空洞。与 S2 [1,seq-1] 历史 gap 配合;若去掉 force,首条消息建的 gap 会
|
|
660
|
-
// 永远悬挂触发无限 pull。临时消息淘汰走 ephemeral_earliest_available_seq(当前仅提示),与此互斥。
|
|
661
|
-
const serverAck = Number(r.server_ack_seq ?? 0);
|
|
662
|
-
if (serverAck > 0) {
|
|
663
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
664
|
-
if (contig < serverAck) {
|
|
665
|
-
_clientLog('info', 'message.pull retention-floor 推进: ns=%s contiguous=%d -> server_ack_seq=%d', ns, contig, serverAck);
|
|
666
|
-
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
667
|
-
}
|
|
704
|
+
r.messages = await this._decryptMessages(rawMessages);
|
|
668
705
|
}
|
|
669
|
-
this.
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
// 自动解密:group.pull 返回的群消息
|
|
682
|
-
if (method === 'group.pull' && isJsonObject(result)) {
|
|
683
|
-
const r = result;
|
|
684
|
-
const messages = r.messages;
|
|
685
|
-
const rawMessages = Array.isArray(messages) ? messages.filter(isJsonObject) : [];
|
|
686
|
-
if (rawMessages.length > 0) {
|
|
687
|
-
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
688
|
-
}
|
|
689
|
-
const gid = (p.group_id ?? '');
|
|
690
|
-
if (gid) {
|
|
691
|
-
const ns = `group:${gid}`;
|
|
692
|
-
if (rawMessages.length > 0) {
|
|
693
|
-
this._seqTracker.onPullResult(ns, rawMessages);
|
|
694
|
-
}
|
|
695
|
-
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
696
|
-
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
697
|
-
// 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
|
|
698
|
-
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
699
|
-
if (cursor) {
|
|
700
|
-
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);
|
|
701
716
|
if (serverAck > 0) {
|
|
702
717
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
703
718
|
if (contig < serverAck) {
|
|
704
|
-
_clientLog(
|
|
719
|
+
this._clientLog.info(`message.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAck}`);
|
|
705
720
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
706
721
|
}
|
|
707
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
|
+
}
|
|
708
733
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
// 同步到服务端:将服务端 epoch 从 0 推到 1;必须在 group.create 返回前完成,
|
|
739
|
-
// 否则调用方紧接着加成员时会让初始 rotation 因成员集变化而提交失败。
|
|
740
|
-
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
|
+
}
|
|
741
763
|
}
|
|
742
|
-
|
|
743
|
-
|
|
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)}`); });
|
|
744
775
|
}
|
|
745
776
|
}
|
|
746
777
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
+
}
|
|
766
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;
|
|
767
829
|
}
|
|
768
|
-
return result;
|
|
769
830
|
}
|
|
770
831
|
// ── 便利方法 ──────────────────────────────────────────────
|
|
771
832
|
/** 心跳检测 */
|
|
772
833
|
async ping(params) {
|
|
773
|
-
|
|
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
|
+
}
|
|
774
845
|
}
|
|
775
846
|
/** 获取服务端状态 */
|
|
776
847
|
async status(params) {
|
|
777
|
-
|
|
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
|
+
}
|
|
778
859
|
}
|
|
779
860
|
/** 获取信任根证书列表 */
|
|
780
861
|
async trustRoots(params) {
|
|
781
|
-
|
|
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
|
+
}
|
|
782
873
|
}
|
|
783
874
|
// ── 事件 ──────────────────────────────────────────────────
|
|
784
875
|
/** 订阅事件 */
|
|
785
876
|
on(event, handler) {
|
|
786
|
-
|
|
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;
|
|
787
882
|
}
|
|
788
883
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
789
884
|
off(event, handler) {
|
|
885
|
+
const tStart = Date.now();
|
|
886
|
+
this._clientLog.debug(`off enter: event=${event}`);
|
|
790
887
|
this._dispatcher.unsubscribe(event, handler);
|
|
888
|
+
this._clientLog.debug(`off exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
791
889
|
}
|
|
792
890
|
// ── E2EE 加密发送 ────────────────────────────────────────
|
|
793
891
|
_protectedHeadersFromParams(params) {
|
|
@@ -803,6 +901,7 @@ export class AUNClient {
|
|
|
803
901
|
}
|
|
804
902
|
/** 自动加密并发送 P2P 消息 */
|
|
805
903
|
async _sendEncrypted(params) {
|
|
904
|
+
const tStart = Date.now();
|
|
806
905
|
const toAid = String(params.to ?? '');
|
|
807
906
|
this._validateMessageRecipient(toAid);
|
|
808
907
|
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
@@ -813,6 +912,7 @@ export class AUNClient {
|
|
|
813
912
|
}
|
|
814
913
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
815
914
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
915
|
+
this._clientLog.debug(`_sendEncrypted enter: to=${toAid}, message_id=${messageId}`);
|
|
816
916
|
// 惰性同步:首次发送 P2P 消息时先 pull 一次
|
|
817
917
|
if (!this._p2pSynced) {
|
|
818
918
|
await this._lazySyncP2p();
|
|
@@ -876,18 +976,33 @@ export class AUNClient {
|
|
|
876
976
|
};
|
|
877
977
|
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
878
978
|
try {
|
|
879
|
-
|
|
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;
|
|
880
982
|
}
|
|
881
983
|
catch (exc) {
|
|
882
|
-
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)}`);
|
|
883
987
|
throw exc;
|
|
884
|
-
|
|
988
|
+
}
|
|
989
|
+
this._clientLog.warn(`peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
990
|
+
}
|
|
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;
|
|
885
999
|
}
|
|
886
|
-
return await sendAttempt(true);
|
|
887
1000
|
}
|
|
888
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}`);
|
|
889
1003
|
let prekey = opts.prekey ?? null;
|
|
890
1004
|
if (!prekey) {
|
|
1005
|
+
this._clientLog.debug(`_sendEncryptedSingle fetching peer prekey: to=${opts.toAid}`);
|
|
891
1006
|
prekey = await this._fetchPeerPrekey(opts.toAid);
|
|
892
1007
|
}
|
|
893
1008
|
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
@@ -902,6 +1017,7 @@ export class AUNClient {
|
|
|
902
1017
|
protectedHeaders: opts.protectedHeaders,
|
|
903
1018
|
});
|
|
904
1019
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1020
|
+
this._clientLog.debug(`_sendEncryptedSingle envelope built: to=${opts.toAid}, message_id=${opts.messageId}, scheme=${String(envelope?.scheme ?? '')}`);
|
|
905
1021
|
const sendParams = {
|
|
906
1022
|
to: opts.toAid,
|
|
907
1023
|
payload: envelope,
|
|
@@ -916,6 +1032,7 @@ export class AUNClient {
|
|
|
916
1032
|
return await this._transport.call('message.send', sendParams);
|
|
917
1033
|
}
|
|
918
1034
|
async _buildRecipientDeviceCopies(opts) {
|
|
1035
|
+
this._clientLog.debug(`_buildRecipientDeviceCopies enter: to=${opts.toAid}, message_id=${opts.messageId}, prekey_count=${opts.prekeys.length}`);
|
|
919
1036
|
const recipientCopies = [];
|
|
920
1037
|
const certCache = new Map();
|
|
921
1038
|
for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
|
|
@@ -945,6 +1062,7 @@ export class AUNClient {
|
|
|
945
1062
|
if (recipientCopies.length === 0) {
|
|
946
1063
|
throw new E2EEError(`no recipient device copies generated for ${opts.toAid}`);
|
|
947
1064
|
}
|
|
1065
|
+
this._clientLog.debug(`_buildRecipientDeviceCopies built: to=${opts.toAid}, message_id=${opts.messageId}, copies=${recipientCopies.length}`);
|
|
948
1066
|
return recipientCopies;
|
|
949
1067
|
}
|
|
950
1068
|
async _resolveSelfCopyPeerCert(certFingerprint) {
|
|
@@ -993,7 +1111,7 @@ export class AUNClient {
|
|
|
993
1111
|
}
|
|
994
1112
|
catch (e) {
|
|
995
1113
|
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
996
|
-
_clientLog(
|
|
1114
|
+
this._clientLog.warn(`self-sync skip device ${deviceId}: cert parse failed (${e}), possibly old prekey`);
|
|
997
1115
|
continue;
|
|
998
1116
|
}
|
|
999
1117
|
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
@@ -1031,16 +1149,27 @@ export class AUNClient {
|
|
|
1031
1149
|
mode: encryptResult.mode,
|
|
1032
1150
|
reason: encryptResult.degradation_reason,
|
|
1033
1151
|
}).catch((exc) => {
|
|
1034
|
-
_clientLog(
|
|
1152
|
+
this._clientLog.warn(`failed to publish e2ee.degraded event: ${formatCaughtError(exc)}`);
|
|
1035
1153
|
});
|
|
1036
1154
|
}
|
|
1037
1155
|
}
|
|
1038
1156
|
/** 自动加密并发送群组消息 */
|
|
1039
1157
|
async _sendGroupEncrypted(params) {
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
+
}
|
|
1044
1173
|
}
|
|
1045
1174
|
async _putGroupThoughtEncrypted(params) {
|
|
1046
1175
|
return await this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
@@ -1092,14 +1221,17 @@ export class AUNClient {
|
|
|
1092
1221
|
let { sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options);
|
|
1093
1222
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1094
1223
|
try {
|
|
1095
|
-
|
|
1224
|
+
const result = await this._transport.call(method, sendParams);
|
|
1225
|
+
this._clientLog.debug(`${method} send success: group_id=${groupId}`);
|
|
1226
|
+
return result;
|
|
1096
1227
|
}
|
|
1097
1228
|
catch (exc) {
|
|
1098
1229
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
1099
|
-
_clientLog(
|
|
1230
|
+
this._clientLog.warn(`group ${groupId} ${method} epoch stale, recovering key and retrying: ${formatCaughtError(exc)}`);
|
|
1100
1231
|
({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
|
|
1101
1232
|
continue;
|
|
1102
1233
|
}
|
|
1234
|
+
this._clientLog.error(`${method} send failed: group_id=${groupId}, err=${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
1103
1235
|
throw exc;
|
|
1104
1236
|
}
|
|
1105
1237
|
}
|
|
@@ -1114,6 +1246,7 @@ export class AUNClient {
|
|
|
1114
1246
|
if (payload === null) {
|
|
1115
1247
|
throw new ValidationError(`${method} payload must be an object when encrypt=true`);
|
|
1116
1248
|
}
|
|
1249
|
+
this._clientLog.debug(`${method} encrypt prepare: group_id=${groupId}, strictEpochReady=${String(strictEpochReady)}`);
|
|
1117
1250
|
if (!this._groupSynced.has(groupId)) {
|
|
1118
1251
|
await this._lazySyncGroup(groupId);
|
|
1119
1252
|
}
|
|
@@ -1182,11 +1315,11 @@ export class AUNClient {
|
|
|
1182
1315
|
}
|
|
1183
1316
|
if (messages.length > 0) {
|
|
1184
1317
|
this._saveSeqTrackerState();
|
|
1185
|
-
_clientLog(
|
|
1318
|
+
this._clientLog.info(`lazy sync group ${groupId}: pull ${messages.length} messages, after_seq=${afterSeq}`);
|
|
1186
1319
|
}
|
|
1187
1320
|
}
|
|
1188
1321
|
catch (exc) {
|
|
1189
|
-
_clientLog(
|
|
1322
|
+
this._clientLog.warn(`lazy sync group ${groupId} failed: ${formatCaughtError(exc)}`);
|
|
1190
1323
|
}
|
|
1191
1324
|
}
|
|
1192
1325
|
/** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
|
|
@@ -1209,11 +1342,11 @@ export class AUNClient {
|
|
|
1209
1342
|
}
|
|
1210
1343
|
if (messages.length > 0) {
|
|
1211
1344
|
this._saveSeqTrackerState();
|
|
1212
|
-
_clientLog(
|
|
1345
|
+
this._clientLog.info(`lazy sync P2P: pull ${messages.length} messages, after_seq=${afterSeq}`);
|
|
1213
1346
|
}
|
|
1214
1347
|
}
|
|
1215
1348
|
catch (exc) {
|
|
1216
|
-
_clientLog(
|
|
1349
|
+
this._clientLog.warn(`lazy sync P2P failed: ${formatCaughtError(exc)}`);
|
|
1217
1350
|
}
|
|
1218
1351
|
}
|
|
1219
1352
|
_isGroupEpochTooOldError(exc) {
|
|
@@ -1269,10 +1402,10 @@ export class AUNClient {
|
|
|
1269
1402
|
encrypt: true,
|
|
1270
1403
|
persist_required: true,
|
|
1271
1404
|
});
|
|
1272
|
-
_clientLog(
|
|
1405
|
+
this._clientLog.info(`requested group ${groupId} epoch ${epoch} key from ${targetAid}`);
|
|
1273
1406
|
}
|
|
1274
1407
|
catch (exc) {
|
|
1275
|
-
_clientLog(
|
|
1408
|
+
this._clientLog.warn(`requesting group ${groupId} key from ${targetAid} failed: ${formatCaughtError(exc)}`);
|
|
1276
1409
|
}
|
|
1277
1410
|
}
|
|
1278
1411
|
async _requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult) {
|
|
@@ -1287,7 +1420,7 @@ export class AUNClient {
|
|
|
1287
1420
|
const secretData = this._groupE2ee.loadSecret(groupId, 1);
|
|
1288
1421
|
if (!secretData || secretData.pending_rotation_id)
|
|
1289
1422
|
return epochResult;
|
|
1290
|
-
_clientLog(
|
|
1423
|
+
this._clientLog.warn(`group ${groupId} detected local epoch 1 exists but server epoch still 0, attempting initial epoch resync`);
|
|
1291
1424
|
await this._syncEpochToServer(groupId);
|
|
1292
1425
|
try {
|
|
1293
1426
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -1295,7 +1428,7 @@ export class AUNClient {
|
|
|
1295
1428
|
return refreshed;
|
|
1296
1429
|
}
|
|
1297
1430
|
catch (exc) {
|
|
1298
|
-
_clientLog(
|
|
1431
|
+
this._clientLog.warn(`group ${groupId} initial epoch resync refresh server epoch failed: ${formatCaughtError(exc)}`);
|
|
1299
1432
|
}
|
|
1300
1433
|
return epochResult;
|
|
1301
1434
|
}
|
|
@@ -1312,7 +1445,7 @@ export class AUNClient {
|
|
|
1312
1445
|
catch (exc) {
|
|
1313
1446
|
if (strict)
|
|
1314
1447
|
throw new StateError(`group ${groupId} failed to query server epoch before retry: ${formatCaughtError(exc)}`);
|
|
1315
|
-
_clientLog(
|
|
1448
|
+
this._clientLog.warn(`group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
|
|
1316
1449
|
return;
|
|
1317
1450
|
}
|
|
1318
1451
|
let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
@@ -1360,7 +1493,7 @@ export class AUNClient {
|
|
|
1360
1493
|
throw new StateError(`group ${groupId} epoch rotation has not completed`);
|
|
1361
1494
|
}
|
|
1362
1495
|
}
|
|
1363
|
-
_clientLog(
|
|
1496
|
+
this._clientLog.warn(`group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
|
|
1364
1497
|
await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
|
|
1365
1498
|
const deadline = Date.now() + 5000;
|
|
1366
1499
|
while (Date.now() < deadline) {
|
|
@@ -1384,7 +1517,7 @@ export class AUNClient {
|
|
|
1384
1517
|
members = isJsonObject(membersResult) ? membersResult.members : null;
|
|
1385
1518
|
}
|
|
1386
1519
|
catch (exc) {
|
|
1387
|
-
_clientLog(
|
|
1520
|
+
this._clientLog.debug(`group ${groupId} member epoch floor pre-check skipped: ${formatCaughtError(exc)}`);
|
|
1388
1521
|
return;
|
|
1389
1522
|
}
|
|
1390
1523
|
let maxMinReadEpoch = 0;
|
|
@@ -1399,7 +1532,7 @@ export class AUNClient {
|
|
|
1399
1532
|
}
|
|
1400
1533
|
if (maxMinReadEpoch <= committedEpoch)
|
|
1401
1534
|
return;
|
|
1402
|
-
_clientLog(
|
|
1535
|
+
this._clientLog.warn(`group ${groupId} member min_read_epoch higher than committed epoch, continuing with committed epoch: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
|
|
1403
1536
|
return;
|
|
1404
1537
|
}
|
|
1405
1538
|
}
|
|
@@ -1410,7 +1543,7 @@ export class AUNClient {
|
|
|
1410
1543
|
return epochResult;
|
|
1411
1544
|
}
|
|
1412
1545
|
catch (exc) {
|
|
1413
|
-
_clientLog(
|
|
1546
|
+
this._clientLog.warn(`group ${groupId} query committed epoch status failed, falling back to local epoch: ${formatCaughtError(exc)}`);
|
|
1414
1547
|
}
|
|
1415
1548
|
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1416
1549
|
return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
|
|
@@ -1438,7 +1571,7 @@ export class AUNClient {
|
|
|
1438
1571
|
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
1439
1572
|
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1440
1573
|
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
1441
|
-
_clientLog(
|
|
1574
|
+
this._clientLog.warn(`group ${groupId} committed epoch ${committedEpoch} member snapshot inconsistent with current members, triggering membership change rotation`);
|
|
1442
1575
|
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
1443
1576
|
const refreshed = await this._committedGroupEpochState(groupId);
|
|
1444
1577
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1455,7 +1588,7 @@ export class AUNClient {
|
|
|
1455
1588
|
return committedEpoch;
|
|
1456
1589
|
}
|
|
1457
1590
|
const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
|
|
1458
|
-
_clientLog(
|
|
1591
|
+
this._clientLog.warn(`group ${groupId} epoch ${committedEpoch} local pending key does not match server committed rotation, recovering key first: local_rotation=${pendingRotationId || '-'}`);
|
|
1459
1592
|
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
1460
1593
|
let refreshed = await this._committedGroupEpochState(groupId);
|
|
1461
1594
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1500,13 +1633,13 @@ export class AUNClient {
|
|
|
1500
1633
|
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
1501
1634
|
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
1502
1635
|
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
1503
|
-
_clientLog(
|
|
1636
|
+
this._clientLog.info(`group ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
|
|
1504
1637
|
return true;
|
|
1505
1638
|
}
|
|
1506
1639
|
return false;
|
|
1507
1640
|
}
|
|
1508
1641
|
catch (exc) {
|
|
1509
|
-
_clientLog(
|
|
1642
|
+
this._clientLog.debug(`query current members failed, cannot determine committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
1510
1643
|
return false;
|
|
1511
1644
|
}
|
|
1512
1645
|
}
|
|
@@ -1556,9 +1689,16 @@ export class AUNClient {
|
|
|
1556
1689
|
// ── 事件自动解密管线 ──────────────────────────────────────
|
|
1557
1690
|
/** 处理 transport 层推送的原始 P2P 消息 */
|
|
1558
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
|
+
}
|
|
1559
1699
|
// 异步处理,不阻塞事件调度
|
|
1560
1700
|
this._processAndPublishMessage(data).catch((exc) => {
|
|
1561
|
-
_clientLog(
|
|
1701
|
+
this._clientLog.warn(`P2P message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1562
1702
|
// H26: 不再投递原始密文 payload;改发 message.undecryptable 事件,仅携带安全 header
|
|
1563
1703
|
if (isJsonObject(data)) {
|
|
1564
1704
|
const safeEvent = {
|
|
@@ -1572,6 +1712,7 @@ export class AUNClient {
|
|
|
1572
1712
|
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1573
1713
|
}
|
|
1574
1714
|
});
|
|
1715
|
+
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1575
1716
|
}
|
|
1576
1717
|
/** 实际处理推送消息的异步任务 */
|
|
1577
1718
|
async _processAndPublishMessage(data) {
|
|
@@ -1594,7 +1735,8 @@ export class AUNClient {
|
|
|
1594
1735
|
const ns = `p2p:${this._aid}`;
|
|
1595
1736
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1596
1737
|
if (needPull) {
|
|
1597
|
-
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)}`));
|
|
1598
1740
|
}
|
|
1599
1741
|
// auto-ack contiguous_seq
|
|
1600
1742
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -1603,12 +1745,13 @@ export class AUNClient {
|
|
|
1603
1745
|
seq: contig,
|
|
1604
1746
|
device_id: this._deviceId,
|
|
1605
1747
|
slot_id: this._slotId,
|
|
1606
|
-
}).catch((e) => { _clientLog(
|
|
1748
|
+
}).catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1607
1749
|
}
|
|
1608
1750
|
// 即时持久化 cursor,异常断连后不回退
|
|
1609
1751
|
this._saveSeqTrackerState();
|
|
1610
1752
|
}
|
|
1611
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 ?? '')}`);
|
|
1612
1755
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1613
1756
|
const ns = `p2p:${this._aid}`;
|
|
1614
1757
|
await this._publishOrderedMessage('message.received', ns, seq, decrypted);
|
|
@@ -1619,8 +1762,15 @@ export class AUNClient {
|
|
|
1619
1762
|
}
|
|
1620
1763
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1621
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
|
+
}
|
|
1622
1772
|
this._processAndPublishGroupMessage(data).catch((exc) => {
|
|
1623
|
-
_clientLog(
|
|
1773
|
+
this._clientLog.warn(`group message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1624
1774
|
// H26: 不再投递原始密文 payload;改发 group.message_undecryptable 事件
|
|
1625
1775
|
if (isJsonObject(data)) {
|
|
1626
1776
|
const safeEvent = {
|
|
@@ -1634,6 +1784,7 @@ export class AUNClient {
|
|
|
1634
1784
|
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1635
1785
|
}
|
|
1636
1786
|
});
|
|
1787
|
+
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1637
1788
|
}
|
|
1638
1789
|
/**
|
|
1639
1790
|
* 处理群组推送消息的异步任务。
|
|
@@ -1660,12 +1811,14 @@ export class AUNClient {
|
|
|
1660
1811
|
return;
|
|
1661
1812
|
}
|
|
1662
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)}`);
|
|
1663
1815
|
// 只有带 payload 的真实消息,在同步解密/恢复尝试结束后才推进游标。
|
|
1664
1816
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1665
1817
|
const ns = `group:${groupId}`;
|
|
1666
1818
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1667
1819
|
if (needPull) {
|
|
1668
|
-
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)}`));
|
|
1669
1822
|
}
|
|
1670
1823
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1671
1824
|
if (contig > 0) {
|
|
@@ -1674,7 +1827,7 @@ export class AUNClient {
|
|
|
1674
1827
|
msg_seq: contig,
|
|
1675
1828
|
device_id: this._deviceId,
|
|
1676
1829
|
slot_id: this._slotId,
|
|
1677
|
-
}).catch((e) => { _clientLog(
|
|
1830
|
+
}).catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1678
1831
|
}
|
|
1679
1832
|
this._saveSeqTrackerState();
|
|
1680
1833
|
}
|
|
@@ -1742,7 +1895,7 @@ export class AUNClient {
|
|
|
1742
1895
|
}
|
|
1743
1896
|
}
|
|
1744
1897
|
catch (exc) {
|
|
1745
|
-
_clientLog(
|
|
1898
|
+
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1746
1899
|
}
|
|
1747
1900
|
await this._publishAppEvent('group.message_created', notification);
|
|
1748
1901
|
}
|
|
@@ -1755,6 +1908,7 @@ export class AUNClient {
|
|
|
1755
1908
|
if (this._gapFillDone.has(dedupKey))
|
|
1756
1909
|
return;
|
|
1757
1910
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1911
|
+
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1758
1912
|
try {
|
|
1759
1913
|
const result = await this.call('group.pull', {
|
|
1760
1914
|
group_id: groupId,
|
|
@@ -1762,6 +1916,7 @@ export class AUNClient {
|
|
|
1762
1916
|
device_id: this._deviceId,
|
|
1763
1917
|
limit: 50,
|
|
1764
1918
|
});
|
|
1919
|
+
let filled = 0;
|
|
1765
1920
|
if (isJsonObject(result)) {
|
|
1766
1921
|
const messages = result.messages;
|
|
1767
1922
|
if (Array.isArray(messages)) {
|
|
@@ -1778,14 +1933,16 @@ export class AUNClient {
|
|
|
1778
1933
|
else {
|
|
1779
1934
|
await this._publishAppEvent('group.message_created', msg);
|
|
1780
1935
|
}
|
|
1936
|
+
filled += 1;
|
|
1781
1937
|
}
|
|
1782
1938
|
}
|
|
1783
1939
|
this._prunePushedSeqs(ns);
|
|
1784
1940
|
}
|
|
1785
1941
|
}
|
|
1942
|
+
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1786
1943
|
}
|
|
1787
1944
|
catch (exc) {
|
|
1788
|
-
_clientLog(
|
|
1945
|
+
this._clientLog.warn(`group message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1789
1946
|
}
|
|
1790
1947
|
finally {
|
|
1791
1948
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1802,11 +1959,13 @@ export class AUNClient {
|
|
|
1802
1959
|
if (this._gapFillDone.has(dedupKey))
|
|
1803
1960
|
return;
|
|
1804
1961
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1962
|
+
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1805
1963
|
try {
|
|
1806
1964
|
const result = await this.call('message.pull', {
|
|
1807
1965
|
after_seq: afterSeq,
|
|
1808
1966
|
limit: 50,
|
|
1809
1967
|
});
|
|
1968
|
+
let filled = 0;
|
|
1810
1969
|
if (isJsonObject(result)) {
|
|
1811
1970
|
const messages = result.messages;
|
|
1812
1971
|
if (Array.isArray(messages)) {
|
|
@@ -1823,14 +1982,16 @@ export class AUNClient {
|
|
|
1823
1982
|
else {
|
|
1824
1983
|
await this._publishAppEvent('message.received', msg);
|
|
1825
1984
|
}
|
|
1985
|
+
filled += 1;
|
|
1826
1986
|
}
|
|
1827
1987
|
}
|
|
1828
1988
|
this._prunePushedSeqs(ns);
|
|
1829
1989
|
}
|
|
1830
1990
|
}
|
|
1991
|
+
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1831
1992
|
}
|
|
1832
1993
|
catch (exc) {
|
|
1833
|
-
_clientLog(
|
|
1994
|
+
this._clientLog.warn(`P2P message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1834
1995
|
}
|
|
1835
1996
|
finally {
|
|
1836
1997
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1971,6 +2132,7 @@ export class AUNClient {
|
|
|
1971
2132
|
if (this._gapFillDone.has(dedupKey))
|
|
1972
2133
|
return;
|
|
1973
2134
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
2135
|
+
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1974
2136
|
try {
|
|
1975
2137
|
const result = await this.call('group.pull_events', {
|
|
1976
2138
|
group_id: groupId,
|
|
@@ -1978,6 +2140,7 @@ export class AUNClient {
|
|
|
1978
2140
|
device_id: this._deviceId,
|
|
1979
2141
|
limit: 50,
|
|
1980
2142
|
});
|
|
2143
|
+
let filled = 0;
|
|
1981
2144
|
if (isJsonObject(result)) {
|
|
1982
2145
|
const events = result.events;
|
|
1983
2146
|
if (Array.isArray(events)) {
|
|
@@ -1987,7 +2150,7 @@ export class AUNClient {
|
|
|
1987
2150
|
if (serverAck > 0) {
|
|
1988
2151
|
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1989
2152
|
if (contigBefore < serverAck) {
|
|
1990
|
-
_clientLog(
|
|
2153
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBefore} -> cursor.current_seq=${serverAck}`);
|
|
1991
2154
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1992
2155
|
}
|
|
1993
2156
|
}
|
|
@@ -2000,7 +2163,7 @@ export class AUNClient {
|
|
|
2000
2163
|
event_seq: contig,
|
|
2001
2164
|
device_id: this._deviceId,
|
|
2002
2165
|
slot_id: this._slotId,
|
|
2003
|
-
}).catch((e) => { _clientLog(
|
|
2166
|
+
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2004
2167
|
}
|
|
2005
2168
|
for (const evt of events) {
|
|
2006
2169
|
if (isJsonObject(evt)) {
|
|
@@ -2016,13 +2179,15 @@ export class AUNClient {
|
|
|
2016
2179
|
}
|
|
2017
2180
|
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2018
2181
|
await this._dispatcher.publish('group.changed', evt);
|
|
2182
|
+
filled += 1;
|
|
2019
2183
|
}
|
|
2020
2184
|
}
|
|
2021
2185
|
}
|
|
2022
2186
|
}
|
|
2187
|
+
this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
2023
2188
|
}
|
|
2024
2189
|
catch (exc) {
|
|
2025
|
-
_clientLog(
|
|
2190
|
+
this._clientLog.warn(`group event gap fill failed: ${formatCaughtError(exc)}`);
|
|
2026
2191
|
}
|
|
2027
2192
|
finally {
|
|
2028
2193
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -2152,103 +2317,113 @@ export class AUNClient {
|
|
|
2152
2317
|
return member ? String(member.group_id ?? '') : '';
|
|
2153
2318
|
}
|
|
2154
2319
|
async _onRawGroupChanged(data) {
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
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
|
-
this._fillGroupEventGap(groupId).catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
|
|
2188
|
-
}
|
|
2189
|
-
// 成员退出或被踢 → 剩余 admin/owner 自动补位轮换
|
|
2190
|
-
// H21: 避免 epoch 轮换风暴——所有剩余 admin 同时收到事件不能都发起轮换,
|
|
2191
|
-
// 否则 CAS 冲突激增。策略:本地 AID 为"排序最小 admin"时才发起,其他 admin
|
|
2192
|
-
// 叠加随机 jitter 作为超时兜底(本地最小 admin 失败时由下一位顶上)。
|
|
2193
|
-
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
2194
|
-
if (groupId) {
|
|
2195
|
-
{
|
|
2196
|
-
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2197
|
-
if (expectedEpoch === null) {
|
|
2198
|
-
_clientLog('debug', 'membership event without old_epoch skipped for epoch rotation: aid=%s group=%s action=%s event_seq=%s', this._aid ?? '', groupId, String(d.action ?? ''), String(d.event_seq ?? ''));
|
|
2199
|
-
}
|
|
2200
|
-
else {
|
|
2201
|
-
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2202
|
-
}
|
|
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)}`); });
|
|
2203
2352
|
}
|
|
2204
2353
|
}
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2220
|
-
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2221
|
-
if (!isSelfJoining) {
|
|
2222
|
-
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
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 ?? '')}`);
|
|
2223
2368
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
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));
|
|
2227
2371
|
}
|
|
2228
2372
|
}
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2232
2389
|
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2233
|
-
|
|
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
|
+
}
|
|
2234
2397
|
}
|
|
2235
2398
|
else {
|
|
2236
|
-
|
|
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
|
+
}
|
|
2237
2407
|
}
|
|
2238
2408
|
}
|
|
2239
2409
|
}
|
|
2240
2410
|
}
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2411
|
+
// 群组解散 → 清理本地 epoch key、seq_tracker、补洞去重缓存
|
|
2412
|
+
if (d.action === 'dissolved') {
|
|
2413
|
+
if (groupId) {
|
|
2414
|
+
this._cleanupDissolvedGroup(groupId);
|
|
2415
|
+
}
|
|
2246
2416
|
}
|
|
2247
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`);
|
|
2248
2423
|
}
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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;
|
|
2252
2427
|
}
|
|
2253
2428
|
}
|
|
2254
2429
|
/**
|
|
@@ -2256,82 +2431,95 @@ export class AUNClient {
|
|
|
2256
2431
|
* 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
|
|
2257
2432
|
*/
|
|
2258
2433
|
async _onGroupStateCommitted(data) {
|
|
2259
|
-
|
|
2434
|
+
const tStart = Date.now();
|
|
2435
|
+
if (!isJsonObject(data)) {
|
|
2436
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
2260
2437
|
return;
|
|
2438
|
+
}
|
|
2261
2439
|
const d = data;
|
|
2262
2440
|
const groupId = String(d.group_id ?? '').trim();
|
|
2263
|
-
if (!groupId)
|
|
2441
|
+
if (!groupId) {
|
|
2442
|
+
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (no group_id)`);
|
|
2264
2443
|
return;
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
const
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
const
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
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);
|
|
2307
2494
|
}
|
|
2308
2495
|
}
|
|
2309
|
-
const saveFn = this._keystore.saveGroupState;
|
|
2310
|
-
if (saveFn) {
|
|
2311
|
-
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2312
|
-
}
|
|
2313
2496
|
}
|
|
2497
|
+
catch (exc) {
|
|
2498
|
+
this._clientLog.warn(`state backfill failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
2499
|
+
}
|
|
2500
|
+
return;
|
|
2314
2501
|
}
|
|
2315
|
-
|
|
2316
|
-
|
|
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;
|
|
2317
2512
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
groupId, stateVersion, keyEpoch,
|
|
2325
|
-
members, policy, prevStateHash,
|
|
2326
|
-
});
|
|
2327
|
-
if (computed !== stateHash) {
|
|
2328
|
-
_clientLog('warn', 'state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
|
|
2329
|
-
return;
|
|
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}`);
|
|
2330
2519
|
}
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
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;
|
|
2335
2523
|
}
|
|
2336
2524
|
}
|
|
2337
2525
|
/**
|
|
@@ -2339,23 +2527,34 @@ export class AUNClient {
|
|
|
2339
2527
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
2340
2528
|
*/
|
|
2341
2529
|
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
2530
|
+
const tStart = Date.now();
|
|
2342
2531
|
const myAid = this._aid;
|
|
2343
|
-
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'}`);
|
|
2344
2534
|
return;
|
|
2535
|
+
}
|
|
2536
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch enter: group=${groupId}, trigger=${triggerId || '-'}, expectedEpoch=${String(expectedEpoch)}, allowMember=${String(allowMember)}`);
|
|
2345
2537
|
const started = Date.now();
|
|
2346
2538
|
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
2347
|
-
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)`);
|
|
2348
2541
|
return;
|
|
2349
|
-
|
|
2542
|
+
}
|
|
2543
|
+
if (this._closing || this._state !== 'connected') {
|
|
2544
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (closing/disconnected)`);
|
|
2350
2545
|
return;
|
|
2546
|
+
}
|
|
2351
2547
|
if (Date.now() - started > 20000) {
|
|
2352
|
-
_clientLog(
|
|
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)`);
|
|
2353
2550
|
return;
|
|
2354
2551
|
}
|
|
2355
2552
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2356
2553
|
}
|
|
2357
|
-
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)`);
|
|
2358
2556
|
return;
|
|
2557
|
+
}
|
|
2359
2558
|
this._groupEpochRotationInflight.add(groupId);
|
|
2360
2559
|
try {
|
|
2361
2560
|
if (this._closing || this._state !== 'connected')
|
|
@@ -2403,11 +2602,14 @@ export class AUNClient {
|
|
|
2403
2602
|
const leader = candidates[0];
|
|
2404
2603
|
if (leader === myAid) {
|
|
2405
2604
|
// 我是 leader,直接发起
|
|
2605
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch leader election: group=${groupId}, I am leader, initiating rotation`);
|
|
2406
2606
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2407
2607
|
return;
|
|
2408
2608
|
}
|
|
2409
|
-
if (!candidates.includes(myAid))
|
|
2609
|
+
if (!candidates.includes(myAid)) {
|
|
2610
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch skipped: group=${groupId}, not in candidate list`);
|
|
2410
2611
|
return;
|
|
2612
|
+
}
|
|
2411
2613
|
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
2412
2614
|
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
2413
2615
|
let beforeEpoch = 0;
|
|
@@ -2444,14 +2646,15 @@ export class AUNClient {
|
|
|
2444
2646
|
});
|
|
2445
2647
|
return;
|
|
2446
2648
|
}
|
|
2447
|
-
_clientLog(
|
|
2649
|
+
this._clientLog.info(`[H21] leader did not complete epoch rotation, non-leader fallback: group=${groupId} myAid=${myAid}`);
|
|
2448
2650
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2449
2651
|
}
|
|
2450
2652
|
catch (exc) {
|
|
2451
|
-
_clientLog(
|
|
2653
|
+
this._clientLog.warn(`_maybeLeadRotateGroupEpoch failed: ${formatCaughtError(exc)}`);
|
|
2452
2654
|
}
|
|
2453
2655
|
finally {
|
|
2454
2656
|
this._groupEpochRotationInflight.delete(groupId);
|
|
2657
|
+
this._clientLog.debug(`_maybeLeadRotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
2455
2658
|
}
|
|
2456
2659
|
}
|
|
2457
2660
|
/**
|
|
@@ -2466,7 +2669,7 @@ export class AUNClient {
|
|
|
2466
2669
|
this._groupE2ee.removeGroup(groupId);
|
|
2467
2670
|
}
|
|
2468
2671
|
catch (exc) {
|
|
2469
|
-
_clientLog(
|
|
2672
|
+
this._clientLog.warn(`cleanup disbanded group ${groupId} epoch key failed: ${formatCaughtError(exc)}`);
|
|
2470
2673
|
}
|
|
2471
2674
|
// 2. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
2472
2675
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -2483,7 +2686,7 @@ export class AUNClient {
|
|
|
2483
2686
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
2484
2687
|
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
2485
2688
|
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
2486
|
-
_clientLog(
|
|
2689
|
+
this._clientLog.info(`cleaned up disbanded group ${groupId} local state`);
|
|
2487
2690
|
}
|
|
2488
2691
|
/** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
|
|
2489
2692
|
/**
|
|
@@ -2518,7 +2721,7 @@ export class AUNClient {
|
|
|
2518
2721
|
if (expectedFP) {
|
|
2519
2722
|
const actualFP = 'sha256:' + certObj.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2520
2723
|
if (actualFP !== expectedFP) {
|
|
2521
|
-
_clientLog(
|
|
2724
|
+
this._clientLog.warn(`signature verification failed: cert fingerprint mismatch aid=${sigAid}`);
|
|
2522
2725
|
return false;
|
|
2523
2726
|
}
|
|
2524
2727
|
}
|
|
@@ -2529,7 +2732,7 @@ export class AUNClient {
|
|
|
2529
2732
|
const pubKey = certObj.publicKey;
|
|
2530
2733
|
const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
|
|
2531
2734
|
if (!ok) {
|
|
2532
|
-
_clientLog(
|
|
2735
|
+
this._clientLog.warn(`group event signature verification failed aid=${sigAid} method=${method}`);
|
|
2533
2736
|
// P1-16: 签名失败统一发布事件
|
|
2534
2737
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2535
2738
|
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
@@ -2538,7 +2741,7 @@ export class AUNClient {
|
|
|
2538
2741
|
return ok;
|
|
2539
2742
|
}
|
|
2540
2743
|
catch (exc) {
|
|
2541
|
-
_clientLog(
|
|
2744
|
+
this._clientLog.warn(`group event signature verification error: ${formatCaughtError(exc)}`);
|
|
2542
2745
|
// P1-16: 签名失败统一发布事件
|
|
2543
2746
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2544
2747
|
aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
|
|
@@ -2596,6 +2799,7 @@ export class AUNClient {
|
|
|
2596
2799
|
result = this._groupE2ee.handleIncoming(actualPayload);
|
|
2597
2800
|
if (result === 'distribution') {
|
|
2598
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 ?? '')}`);
|
|
2599
2803
|
// 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
|
|
2600
2804
|
const distGroupId = actualPayload.group_id;
|
|
2601
2805
|
if (distGroupId && !this._groupSynced.has(distGroupId)) {
|
|
@@ -2609,6 +2813,7 @@ export class AUNClient {
|
|
|
2609
2813
|
// 处理密钥请求并回复
|
|
2610
2814
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2611
2815
|
const requester = String(actualPayload.requester_aid ?? '');
|
|
2816
|
+
this._clientLog.debug(`group key request received: group_id=${groupId}, requester=${requester}, epoch=${String(actualPayload.epoch ?? '')}`);
|
|
2612
2817
|
let members = this._groupE2ee.getMemberAids(groupId);
|
|
2613
2818
|
// 请求者不在本地成员列表时,回源查询服务端最新成员列表,
|
|
2614
2819
|
// 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
|
|
@@ -2622,7 +2827,7 @@ export class AUNClient {
|
|
|
2622
2827
|
members = memberList.map((m) => String(m.aid));
|
|
2623
2828
|
}
|
|
2624
2829
|
catch (exc) {
|
|
2625
|
-
_clientLog(
|
|
2830
|
+
this._clientLog.warn(`group ${groupId} member list backfill failed: ${formatCaughtError(exc)}`);
|
|
2626
2831
|
}
|
|
2627
2832
|
}
|
|
2628
2833
|
const response = this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
|
|
@@ -2636,7 +2841,7 @@ export class AUNClient {
|
|
|
2636
2841
|
});
|
|
2637
2842
|
}
|
|
2638
2843
|
catch (exc) {
|
|
2639
|
-
_clientLog(
|
|
2844
|
+
this._clientLog.warn(`replying group key to ${requester} failed: ${formatCaughtError(exc)}`);
|
|
2640
2845
|
}
|
|
2641
2846
|
}
|
|
2642
2847
|
}
|
|
@@ -2645,9 +2850,10 @@ export class AUNClient {
|
|
|
2645
2850
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2646
2851
|
const rotationId = String(actualPayload.rotation_id ?? '');
|
|
2647
2852
|
const keyCommitment = String(actualPayload.commitment ?? '');
|
|
2853
|
+
this._clientLog.debug(`group key ${result} handled: group_id=${groupId}, epoch=${String(actualPayload.epoch ?? '')}, rotation=${rotationId}`);
|
|
2648
2854
|
if (rotationId && keyCommitment) {
|
|
2649
2855
|
this._ackGroupRotationKey(rotationId, keyCommitment)
|
|
2650
|
-
.catch((exc) => _clientLog(
|
|
2856
|
+
.catch((exc) => this._clientLog.warn(`submit epoch key ack failed: ${formatCaughtError(exc)}`));
|
|
2651
2857
|
}
|
|
2652
2858
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2653
2859
|
}
|
|
@@ -2659,133 +2865,158 @@ export class AUNClient {
|
|
|
2659
2865
|
* 跨域时自动路由到 peer 所在域的 Gateway。
|
|
2660
2866
|
*/
|
|
2661
2867
|
async _fetchPeerCert(aid, certFingerprint) {
|
|
2662
|
-
const
|
|
2663
|
-
|
|
2664
|
-
const now = Date.now() / 1000;
|
|
2665
|
-
if (cached && now < cached.refreshAfter) {
|
|
2666
|
-
return cached.certPem;
|
|
2667
|
-
}
|
|
2668
|
-
const gatewayUrl = this._gatewayUrl;
|
|
2669
|
-
if (!gatewayUrl) {
|
|
2670
|
-
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2671
|
-
}
|
|
2672
|
-
// 跨域时用 peer 所在域的 Gateway URL
|
|
2673
|
-
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2674
|
-
let certPem;
|
|
2868
|
+
const tStart = Date.now();
|
|
2869
|
+
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
2675
2870
|
try {
|
|
2676
|
-
const
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
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);
|
|
2682
2888
|
}
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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 验证
|
|
2696
2918
|
try {
|
|
2697
|
-
|
|
2698
|
-
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
2919
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
2699
2920
|
}
|
|
2700
|
-
catch {
|
|
2701
|
-
|
|
2921
|
+
catch (exc) {
|
|
2922
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2702
2923
|
}
|
|
2703
|
-
|
|
2704
|
-
|
|
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 });
|
|
2705
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;
|
|
2706
2939
|
}
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
}
|
|
2711
|
-
catch (exc) {
|
|
2712
|
-
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2713
|
-
}
|
|
2714
|
-
const nowSec = Date.now() / 1000;
|
|
2715
|
-
this._certCache.set(cacheKey, {
|
|
2716
|
-
certPem,
|
|
2717
|
-
validatedAt: nowSec,
|
|
2718
|
-
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
2719
|
-
});
|
|
2720
|
-
try {
|
|
2721
|
-
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
2722
|
-
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2723
|
-
}
|
|
2724
|
-
catch (exc) {
|
|
2725
|
-
_clientLog('error', '写入证书到 keystore 失败 (aid=%s, fp=%s): %s', aid, certFingerprint ?? '', formatCaughtError(exc));
|
|
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;
|
|
2726
2943
|
}
|
|
2727
|
-
return certPem;
|
|
2728
2944
|
}
|
|
2729
2945
|
/** 获取对方所有设备的 prekey 列表。 */
|
|
2730
2946
|
async _fetchPeerPrekeys(peerAid) {
|
|
2731
|
-
const
|
|
2732
|
-
|
|
2733
|
-
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2734
|
-
if (normalized.length > 0) {
|
|
2735
|
-
return normalized.map((item) => ({ ...item }));
|
|
2736
|
-
}
|
|
2737
|
-
}
|
|
2738
|
-
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2739
|
-
if (cached !== null) {
|
|
2740
|
-
const normalized = normalizePeerPrekeys([cached]);
|
|
2741
|
-
if (normalized.length > 0)
|
|
2742
|
-
return normalized.map((item) => ({ ...item }));
|
|
2743
|
-
}
|
|
2947
|
+
const tStart = Date.now();
|
|
2948
|
+
this._clientLog.debug(`_fetchPeerPrekeys enter: aid=${peerAid}`);
|
|
2744
2949
|
try {
|
|
2745
|
-
const
|
|
2746
|
-
if (
|
|
2747
|
-
|
|
2748
|
-
}
|
|
2749
|
-
if (result.found === false) {
|
|
2750
|
-
return [];
|
|
2751
|
-
}
|
|
2752
|
-
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2753
|
-
if (devicePrekeys) {
|
|
2754
|
-
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);
|
|
2755
2953
|
if (normalized.length > 0) {
|
|
2756
|
-
this.
|
|
2757
|
-
|
|
2758
|
-
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2759
|
-
});
|
|
2760
|
-
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2761
|
-
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 }));
|
|
2762
2956
|
}
|
|
2763
2957
|
}
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
const prekey = result.prekey;
|
|
2768
|
-
if (prekey) {
|
|
2769
|
-
const normalized = normalizePeerPrekeys([prekey]);
|
|
2958
|
+
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2959
|
+
if (cached !== null) {
|
|
2960
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
2770
2961
|
if (normalized.length > 0) {
|
|
2771
|
-
this.
|
|
2772
|
-
items: normalized.map((item) => ({ ...item })),
|
|
2773
|
-
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2774
|
-
});
|
|
2775
|
-
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2962
|
+
this._clientLog.debug(`_fetchPeerPrekeys exit: elapsed=${Date.now() - tStart}ms aid=${peerAid} (e2ee_cache_hit)`);
|
|
2776
2963
|
return normalized.map((item) => ({ ...item }));
|
|
2777
2964
|
}
|
|
2778
2965
|
}
|
|
2779
|
-
|
|
2780
|
-
|
|
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 [];
|
|
2781
3009
|
}
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
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)}`);
|
|
2787
3015
|
}
|
|
2788
|
-
|
|
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;
|
|
2789
3020
|
}
|
|
2790
3021
|
}
|
|
2791
3022
|
/** 获取对方的单个 prekey(兼容接口,优先返回第一条 device prekey)。 */
|
|
@@ -2873,10 +3104,10 @@ export class AUNClient {
|
|
|
2873
3104
|
catch (exc) {
|
|
2874
3105
|
// 刷新失败时:若内存缓存有 PKI 验证过的证书(未过期 x2 倍 TTL)则继续用
|
|
2875
3106
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2876
|
-
_clientLog(
|
|
3107
|
+
this._clientLog.debug(`refresh sender ${aid} cert failed, continuing with verified memory cache: ${formatCaughtError(exc)}`);
|
|
2877
3108
|
return true;
|
|
2878
3109
|
}
|
|
2879
|
-
_clientLog(
|
|
3110
|
+
this._clientLog.warn(`failed to get sender ${aid} cert and no verified cache, rejecting trust: ${formatCaughtError(exc)}`);
|
|
2880
3111
|
return false;
|
|
2881
3112
|
}
|
|
2882
3113
|
}
|
|
@@ -2898,33 +3129,50 @@ export class AUNClient {
|
|
|
2898
3129
|
}
|
|
2899
3130
|
/** 解密单条 P2P 消息 */
|
|
2900
3131
|
async _decryptSingleMessage(message) {
|
|
2901
|
-
const
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
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
|
+
}
|
|
2928
3176
|
}
|
|
2929
3177
|
/** 批量解密 P2P 消息(用于 message.pull) */
|
|
2930
3178
|
async _decryptMessages(messages) {
|
|
@@ -2948,7 +3196,7 @@ export class AUNClient {
|
|
|
2948
3196
|
if (fromAid) {
|
|
2949
3197
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2950
3198
|
if (!certReady) {
|
|
2951
|
-
_clientLog(
|
|
3199
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping decrypt`);
|
|
2952
3200
|
continue;
|
|
2953
3201
|
}
|
|
2954
3202
|
}
|
|
@@ -2959,7 +3207,7 @@ export class AUNClient {
|
|
|
2959
3207
|
}
|
|
2960
3208
|
else {
|
|
2961
3209
|
// TS-015: 解密失败不回退到密文,跳过该消息并记录
|
|
2962
|
-
_clientLog(
|
|
3210
|
+
this._clientLog.warn(`pull message decrypt failed, skipping: from=${msg.from} mid=${msg.message_id}`);
|
|
2963
3211
|
}
|
|
2964
3212
|
}
|
|
2965
3213
|
else {
|
|
@@ -3012,23 +3260,37 @@ export class AUNClient {
|
|
|
3012
3260
|
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
3013
3261
|
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
3014
3262
|
return;
|
|
3015
|
-
this._retryPendingDecryptMsgs(groupId).catch((exc) => _clientLog(
|
|
3263
|
+
this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(`group ${groupId} pending message retry failed: ${formatCaughtError(exc)}`));
|
|
3016
3264
|
}
|
|
3017
3265
|
async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
|
|
3018
|
-
const
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
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;
|
|
3022
3293
|
}
|
|
3023
|
-
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
3024
|
-
const key = `${groupId}:${epoch}`;
|
|
3025
|
-
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
3026
|
-
if (inflight)
|
|
3027
|
-
return inflight;
|
|
3028
|
-
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
3029
|
-
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
3030
|
-
this._groupEpochRecoveryInflight.set(key, promise);
|
|
3031
|
-
return promise;
|
|
3032
3294
|
}
|
|
3033
3295
|
static _extractGroupJoinMode(payload) {
|
|
3034
3296
|
if (!isJsonObject(payload))
|
|
@@ -3121,13 +3383,13 @@ export class AUNClient {
|
|
|
3121
3383
|
const myAid = this._aid || '';
|
|
3122
3384
|
const keyPair = this._keystore.loadKeyPair(myAid);
|
|
3123
3385
|
if (!keyPair?.private_key_pem) {
|
|
3124
|
-
_clientLog(
|
|
3386
|
+
this._clientLog.warn(`cannot load AID private key for ECIES decrypt: aid=${myAid}`);
|
|
3125
3387
|
return false;
|
|
3126
3388
|
}
|
|
3127
3389
|
const { eciesDecrypt } = await import('./e2ee-group.js');
|
|
3128
3390
|
const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
|
|
3129
3391
|
if (!groupSecret || groupSecret.length !== 32) {
|
|
3130
|
-
_clientLog(
|
|
3392
|
+
this._clientLog.warn(`server epoch key ECIES decrypt result length abnormal: group=${groupId} epoch=${serverEpoch} len=${groupSecret?.length ?? 0}`);
|
|
3131
3393
|
return false;
|
|
3132
3394
|
}
|
|
3133
3395
|
// 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
|
|
@@ -3164,7 +3426,7 @@ export class AUNClient {
|
|
|
3164
3426
|
}
|
|
3165
3427
|
catch { /* best effort */ }
|
|
3166
3428
|
if (memberAids.length === 0) {
|
|
3167
|
-
_clientLog(
|
|
3429
|
+
this._clientLog.warn(`server epoch key recovery missing member snapshot: group=${groupId} epoch=${serverEpoch}`);
|
|
3168
3430
|
return false;
|
|
3169
3431
|
}
|
|
3170
3432
|
const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
|
|
@@ -3175,7 +3437,7 @@ export class AUNClient {
|
|
|
3175
3437
|
const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
|
|
3176
3438
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3177
3439
|
if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
|
|
3178
|
-
_clientLog(
|
|
3440
|
+
this._clientLog.warn(`server epoch key recovery commitment mismatch: group=${groupId} epoch=${serverEpoch}`);
|
|
3179
3441
|
return false;
|
|
3180
3442
|
}
|
|
3181
3443
|
if (epochChain && committedEpoch === serverEpoch) {
|
|
@@ -3191,7 +3453,7 @@ export class AUNClient {
|
|
|
3191
3453
|
const prevChain = String(prevData?.epoch_chain ?? '').trim();
|
|
3192
3454
|
if (prevChain && rotatorAid) {
|
|
3193
3455
|
if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
|
|
3194
|
-
_clientLog(
|
|
3456
|
+
this._clientLog.warn(`server epoch key recovery epoch_chain verification failed: group=${groupId} epoch=${serverEpoch} rotator=${rotatorAid}`);
|
|
3195
3457
|
return false;
|
|
3196
3458
|
}
|
|
3197
3459
|
epochChainUnverified = false;
|
|
@@ -3204,14 +3466,14 @@ export class AUNClient {
|
|
|
3204
3466
|
}
|
|
3205
3467
|
const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
|
|
3206
3468
|
if (!stored) {
|
|
3207
|
-
_clientLog(
|
|
3469
|
+
this._clientLog.warn(`server epoch key recovery store failed: group=${groupId} epoch=${serverEpoch}`);
|
|
3208
3470
|
return false;
|
|
3209
3471
|
}
|
|
3210
|
-
_clientLog(
|
|
3472
|
+
this._clientLog.info(`recovered epoch key from server: group=${groupId} epoch=${serverEpoch}`);
|
|
3211
3473
|
return true;
|
|
3212
3474
|
}
|
|
3213
3475
|
catch (exc) {
|
|
3214
|
-
_clientLog(
|
|
3476
|
+
this._clientLog.debug(`recover epoch key from server failed: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3215
3477
|
return false;
|
|
3216
3478
|
}
|
|
3217
3479
|
}
|
|
@@ -3238,7 +3500,7 @@ export class AUNClient {
|
|
|
3238
3500
|
groupSecretBytes = loaded.secret;
|
|
3239
3501
|
}
|
|
3240
3502
|
else {
|
|
3241
|
-
_clientLog(
|
|
3503
|
+
this._clientLog.debug(`cannot get group_secret for ECIES encrypt: group=${groupId} epoch=${targetEpoch}`);
|
|
3242
3504
|
return {};
|
|
3243
3505
|
}
|
|
3244
3506
|
}
|
|
@@ -3259,21 +3521,23 @@ export class AUNClient {
|
|
|
3259
3521
|
encryptedKeys[aid] = ciphertext.toString('base64');
|
|
3260
3522
|
}
|
|
3261
3523
|
catch (exc) {
|
|
3262
|
-
_clientLog(
|
|
3524
|
+
this._clientLog.debug(`building ECIES epoch key for member ${aid} failed: ${formatCaughtError(exc)}`);
|
|
3263
3525
|
continue;
|
|
3264
3526
|
}
|
|
3265
3527
|
}
|
|
3266
3528
|
return encryptedKeys;
|
|
3267
3529
|
}
|
|
3268
3530
|
catch (exc) {
|
|
3269
|
-
_clientLog(
|
|
3531
|
+
this._clientLog.debug(`building encrypted_keys failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
3270
3532
|
return {};
|
|
3271
3533
|
}
|
|
3272
3534
|
}
|
|
3273
3535
|
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
3536
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} key recovery start: sender=${senderAid || '-'}, timeout=${timeoutMs}ms`);
|
|
3274
3537
|
// 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
|
|
3275
3538
|
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3276
3539
|
if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
|
|
3540
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} recovered from server`);
|
|
3277
3541
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3278
3542
|
return true;
|
|
3279
3543
|
}
|
|
@@ -3304,16 +3568,18 @@ export class AUNClient {
|
|
|
3304
3568
|
}
|
|
3305
3569
|
}
|
|
3306
3570
|
catch {
|
|
3307
|
-
_clientLog(
|
|
3571
|
+
this._clientLog.debug(`group ${groupId} query online members failed, falling back to all candidates`);
|
|
3308
3572
|
}
|
|
3309
3573
|
if (onlineAids !== null) {
|
|
3310
3574
|
if (onlineAids.length === 0) {
|
|
3311
|
-
_clientLog(
|
|
3575
|
+
this._clientLog.info(`group ${groupId} epoch ${String(epoch)} recovery failed: no online members to request key`);
|
|
3312
3576
|
return false;
|
|
3313
3577
|
}
|
|
3578
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery: requesting from ${onlineAids.length} online members`);
|
|
3314
3579
|
await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
|
|
3315
3580
|
}
|
|
3316
3581
|
else {
|
|
3582
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery: requesting from all candidates`);
|
|
3317
3583
|
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
3318
3584
|
}
|
|
3319
3585
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -3321,14 +3587,20 @@ export class AUNClient {
|
|
|
3321
3587
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
3322
3588
|
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3323
3589
|
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret)) {
|
|
3590
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery success`);
|
|
3324
3591
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3325
3592
|
return true;
|
|
3326
3593
|
}
|
|
3327
3594
|
}
|
|
3328
3595
|
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
3329
3596
|
const ready = await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret);
|
|
3330
|
-
if (ready)
|
|
3597
|
+
if (ready) {
|
|
3598
|
+
this._clientLog.debug(`group ${groupId} epoch ${epoch} P2P key recovery success (post-deadline check)`);
|
|
3331
3599
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3600
|
+
}
|
|
3601
|
+
else {
|
|
3602
|
+
this._clientLog.warn(`group ${groupId} epoch ${epoch} P2P key recovery timeout: timeout=${timeoutMs}ms`);
|
|
3603
|
+
}
|
|
3332
3604
|
return ready;
|
|
3333
3605
|
}
|
|
3334
3606
|
/** 只向在线成员发送密钥恢复请求 */
|
|
@@ -3378,48 +3650,70 @@ export class AUNClient {
|
|
|
3378
3650
|
}
|
|
3379
3651
|
}
|
|
3380
3652
|
async _decryptGroupMessage(message, opts) {
|
|
3381
|
-
const
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
3407
|
-
const groupId = String(message.group_id ?? '');
|
|
3408
|
-
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
3409
|
-
const epoch = Number(payloadObj.epoch ?? 0);
|
|
3410
|
-
if (epoch > 0 && groupId) {
|
|
3411
|
-
try {
|
|
3412
|
-
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
3413
|
-
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
3414
|
-
if (retry !== null && retry.e2ee)
|
|
3415
|
-
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;
|
|
3416
3678
|
}
|
|
3417
3679
|
}
|
|
3418
|
-
|
|
3419
|
-
|
|
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
|
+
}
|
|
3420
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;
|
|
3421
3716
|
}
|
|
3422
|
-
return message;
|
|
3423
3717
|
}
|
|
3424
3718
|
_attachGroupDispatchModeToPayload(message) {
|
|
3425
3719
|
const payload = message.payload;
|
|
@@ -3537,7 +3831,7 @@ export class AUNClient {
|
|
|
3537
3831
|
if (fromAid) {
|
|
3538
3832
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
3539
3833
|
if (!certReady) {
|
|
3540
|
-
_clientLog(
|
|
3834
|
+
this._clientLog.warn(`cannot get sender ${fromAid} cert, skipping message.thought.get decrypt`);
|
|
3541
3835
|
decryptFailed = true;
|
|
3542
3836
|
}
|
|
3543
3837
|
}
|
|
@@ -3598,6 +3892,7 @@ export class AUNClient {
|
|
|
3598
3892
|
const failed = [];
|
|
3599
3893
|
let lastHeartbeat = Date.now();
|
|
3600
3894
|
const distributions = (Array.isArray(info.distributions) ? info.distributions : []);
|
|
3895
|
+
this._clientLog.debug(`epoch key distribution start: rotation=${rotationId}, target_members=${distributions.length}`);
|
|
3601
3896
|
for (const dist of distributions) {
|
|
3602
3897
|
if (!isJsonObject(dist) || !dist.to || !isJsonObject(dist.payload))
|
|
3603
3898
|
continue;
|
|
@@ -3622,7 +3917,7 @@ export class AUNClient {
|
|
|
3622
3917
|
}
|
|
3623
3918
|
else {
|
|
3624
3919
|
failed.push(String(dist.to));
|
|
3625
|
-
_clientLog(
|
|
3920
|
+
this._clientLog.warn(`epoch key distribution failed (to=${dist.to}): ${formatCaughtError(exc)}`);
|
|
3626
3921
|
}
|
|
3627
3922
|
}
|
|
3628
3923
|
}
|
|
@@ -3640,23 +3935,26 @@ export class AUNClient {
|
|
|
3640
3935
|
return isJsonObject(result) && result.success === true;
|
|
3641
3936
|
}
|
|
3642
3937
|
catch (exc) {
|
|
3643
|
-
_clientLog(
|
|
3938
|
+
this._clientLog.warn(`refresh epoch rotation lease failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3644
3939
|
return false;
|
|
3645
3940
|
}
|
|
3646
3941
|
}
|
|
3647
3942
|
async _ackGroupRotationKey(rotationId, keyCommitment, deviceId) {
|
|
3648
3943
|
if (!rotationId)
|
|
3649
3944
|
return false;
|
|
3945
|
+
this._clientLog.debug(`_ackGroupRotationKey enter: rotation=${rotationId}, commitment=${keyCommitment}, device=${deviceId ?? this._deviceId}`);
|
|
3650
3946
|
try {
|
|
3651
3947
|
const result = await this.call('group.e2ee.ack_rotation_key', {
|
|
3652
3948
|
rotation_id: rotationId,
|
|
3653
3949
|
key_commitment: keyCommitment,
|
|
3654
3950
|
device_id: deviceId ?? this._deviceId,
|
|
3655
3951
|
});
|
|
3656
|
-
|
|
3952
|
+
const success = isJsonObject(result) && result.success === true;
|
|
3953
|
+
this._clientLog.debug(`_ackGroupRotationKey done: rotation=${rotationId}, success=${success}`);
|
|
3954
|
+
return success;
|
|
3657
3955
|
}
|
|
3658
3956
|
catch (exc) {
|
|
3659
|
-
_clientLog(
|
|
3957
|
+
this._clientLog.warn(`submit epoch key ack failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3660
3958
|
return false;
|
|
3661
3959
|
}
|
|
3662
3960
|
}
|
|
@@ -3684,7 +3982,7 @@ export class AUNClient {
|
|
|
3684
3982
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3685
3983
|
: [];
|
|
3686
3984
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3687
|
-
_clientLog(
|
|
3985
|
+
this._clientLog.debug(`allowing group key distribution: new member recovery commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3688
3986
|
}
|
|
3689
3987
|
else {
|
|
3690
3988
|
return false;
|
|
@@ -3693,7 +3991,7 @@ export class AUNClient {
|
|
|
3693
3991
|
}
|
|
3694
3992
|
return true;
|
|
3695
3993
|
}
|
|
3696
|
-
_clientLog(
|
|
3994
|
+
this._clientLog.info(`rejecting future epoch key distribution missing rotation_id: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3697
3995
|
return false;
|
|
3698
3996
|
}
|
|
3699
3997
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -3714,10 +4012,10 @@ export class AUNClient {
|
|
|
3714
4012
|
}
|
|
3715
4013
|
}
|
|
3716
4014
|
catch (exc) {
|
|
3717
|
-
_clientLog(
|
|
4015
|
+
this._clientLog.warn(`rejecting epoch key distribution: cannot verify active rotation: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3718
4016
|
return false;
|
|
3719
4017
|
}
|
|
3720
|
-
_clientLog(
|
|
4018
|
+
this._clientLog.info(`rejecting epoch key distribution: rotation not in pending/committed state: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
|
|
3721
4019
|
return false;
|
|
3722
4020
|
}
|
|
3723
4021
|
async _discardGroupDistributionIfStale(payload) {
|
|
@@ -3732,10 +4030,10 @@ export class AUNClient {
|
|
|
3732
4030
|
return;
|
|
3733
4031
|
try {
|
|
3734
4032
|
this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
|
|
3735
|
-
_clientLog(
|
|
4033
|
+
this._clientLog.info(`discarding stale group epoch key after verify: group=${groupId} epoch=${epoch} rotation=${rotationId}`);
|
|
3736
4034
|
}
|
|
3737
4035
|
catch (exc) {
|
|
3738
|
-
_clientLog(
|
|
4036
|
+
this._clientLog.debug(`cleanup stale group epoch key failed: group=${groupId} epoch=${epoch} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3739
4037
|
}
|
|
3740
4038
|
}
|
|
3741
4039
|
async _verifyGroupKeyResponseEpoch(payload) {
|
|
@@ -3752,7 +4050,7 @@ export class AUNClient {
|
|
|
3752
4050
|
return false;
|
|
3753
4051
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
3754
4052
|
if (epoch > committedEpoch) {
|
|
3755
|
-
_clientLog(
|
|
4053
|
+
this._clientLog.info(`rejecting uncommitted epoch group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3756
4054
|
return false;
|
|
3757
4055
|
}
|
|
3758
4056
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
@@ -3763,7 +4061,7 @@ export class AUNClient {
|
|
|
3763
4061
|
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3764
4062
|
: [];
|
|
3765
4063
|
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3766
|
-
_clientLog(
|
|
4064
|
+
this._clientLog.debug(`allowing group key response: new member recovery commitment mismatch is normal group=${groupId} epoch=${epoch}`);
|
|
3767
4065
|
}
|
|
3768
4066
|
else {
|
|
3769
4067
|
return false;
|
|
@@ -3773,7 +4071,7 @@ export class AUNClient {
|
|
|
3773
4071
|
return true;
|
|
3774
4072
|
}
|
|
3775
4073
|
catch (exc) {
|
|
3776
|
-
_clientLog(
|
|
4074
|
+
this._clientLog.warn(`rejecting group key response: cannot verify committed epoch: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3777
4075
|
return false;
|
|
3778
4076
|
}
|
|
3779
4077
|
}
|
|
@@ -3788,7 +4086,7 @@ export class AUNClient {
|
|
|
3788
4086
|
return isJsonObject(result) && result.success === true;
|
|
3789
4087
|
}
|
|
3790
4088
|
catch (exc) {
|
|
3791
|
-
_clientLog(
|
|
4089
|
+
this._clientLog.warn(`abort epoch rotation failed: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3792
4090
|
return false;
|
|
3793
4091
|
}
|
|
3794
4092
|
}
|
|
@@ -3822,7 +4120,7 @@ export class AUNClient {
|
|
|
3822
4120
|
if (this._closing || this._state !== 'connected')
|
|
3823
4121
|
return;
|
|
3824
4122
|
this._maybeLeadRotateGroupEpoch(groupId, opts.triggerId, opts.expectedEpoch)
|
|
3825
|
-
.catch((exc) => _clientLog(
|
|
4123
|
+
.catch((exc) => this._clientLog.warn(`group epoch rotation retry failed: ${formatCaughtError(exc)}`));
|
|
3826
4124
|
}, this._rotationRetryDelayMs(opts.pending));
|
|
3827
4125
|
this._groupEpochRotationRetryTimers.set(retryKey, timer);
|
|
3828
4126
|
this._unrefTimer(timer);
|
|
@@ -3834,7 +4132,7 @@ export class AUNClient {
|
|
|
3834
4132
|
if (this._closing || this._state !== 'connected')
|
|
3835
4133
|
return;
|
|
3836
4134
|
if (Date.now() - started > 20000) {
|
|
3837
|
-
_clientLog(
|
|
4135
|
+
this._clientLog.warn(`group epoch create sync still in-flight; skip duplicate sync (group=${groupId})`);
|
|
3838
4136
|
return;
|
|
3839
4137
|
}
|
|
3840
4138
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
@@ -3867,12 +4165,12 @@ export class AUNClient {
|
|
|
3867
4165
|
const beginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
|
|
3868
4166
|
const rotation = isJsonObject(beginResult) && isJsonObject(beginResult.rotation) ? beginResult.rotation : null;
|
|
3869
4167
|
if (!isJsonObject(beginResult) || beginResult.success !== true || !rotation) {
|
|
3870
|
-
_clientLog(
|
|
4168
|
+
this._clientLog.warn(`group epoch begin failed; stop key distribution (group=${groupId}, returned=${JSON.stringify(beginResult)})`);
|
|
3871
4169
|
return;
|
|
3872
4170
|
}
|
|
3873
4171
|
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
3874
4172
|
if (!await this._ackGroupRotationKey(activeRotationId, secretData.commitment)) {
|
|
3875
|
-
_clientLog(
|
|
4173
|
+
this._clientLog.warn(`group epoch self ack failed (group=${groupId}, rotation=${activeRotationId})`);
|
|
3876
4174
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3877
4175
|
return;
|
|
3878
4176
|
}
|
|
@@ -3889,17 +4187,17 @@ export class AUNClient {
|
|
|
3889
4187
|
storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
|
|
3890
4188
|
return;
|
|
3891
4189
|
}
|
|
3892
|
-
_clientLog(
|
|
4190
|
+
this._clientLog.warn(`group epoch commit failed (group=${groupId}, returned=${JSON.stringify(commitResult)})`);
|
|
3893
4191
|
return;
|
|
3894
4192
|
}
|
|
3895
4193
|
catch (exc) {
|
|
3896
4194
|
if (attempt < maxRetries) {
|
|
3897
4195
|
const delay = 500 * Math.pow(2, attempt - 1);
|
|
3898
|
-
_clientLog(
|
|
4196
|
+
this._clientLog.warn(`sync epoch to server failed (group=${groupId}, attempt ${attempt}/${maxRetries}): ${formatCaughtError(exc)}, retrying in ${delay}ms`);
|
|
3899
4197
|
await new Promise(r => setTimeout(r, delay));
|
|
3900
4198
|
}
|
|
3901
4199
|
else {
|
|
3902
|
-
_clientLog(
|
|
4200
|
+
this._clientLog.error(`sync epoch to server final failure (group=${groupId}, retried ${maxRetries} times): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3903
4201
|
}
|
|
3904
4202
|
}
|
|
3905
4203
|
}
|
|
@@ -3913,9 +4211,13 @@ export class AUNClient {
|
|
|
3913
4211
|
* 使用服务端两阶段 rotation,避免服务端先提交但密钥未分发。
|
|
3914
4212
|
*/
|
|
3915
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)}`);
|
|
3916
4216
|
try {
|
|
3917
|
-
if (!this._aid)
|
|
4217
|
+
if (!this._aid) {
|
|
4218
|
+
this._clientLog.debug(`_rotateGroupEpoch exit: elapsed=${Date.now() - tStart}ms group=${groupId} (no_aid)`);
|
|
3918
4219
|
return;
|
|
4220
|
+
}
|
|
3919
4221
|
const memberAids = await this._getGroupMemberAids(groupId);
|
|
3920
4222
|
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
3921
4223
|
return;
|
|
@@ -3930,7 +4232,7 @@ export class AUNClient {
|
|
|
3930
4232
|
&& serverEpoch === expectedEpoch
|
|
3931
4233
|
&& this._rotationExpectedMembersStale(pendingRotation, memberAids));
|
|
3932
4234
|
if (stalePending && await this._abortGroupRotation(pendingRotationId, 'membership_changed_during_rotation')) {
|
|
3933
|
-
_clientLog(
|
|
4235
|
+
this._clientLog.info(`aborted stale pending group epoch rotation: group=${groupId} rotation=${pendingRotationId || '-'}`);
|
|
3934
4236
|
}
|
|
3935
4237
|
else {
|
|
3936
4238
|
this._scheduleGroupRotationRetry(groupId, {
|
|
@@ -3945,7 +4247,7 @@ export class AUNClient {
|
|
|
3945
4247
|
if (expectedEpoch !== null && serverEpoch !== expectedEpoch) {
|
|
3946
4248
|
if (triggerId)
|
|
3947
4249
|
this._groupMembershipRotationDone.add(triggerId);
|
|
3948
|
-
_clientLog(
|
|
4250
|
+
this._clientLog.info(`skip membership epoch rotation: group=${groupId} expected_epoch=${expectedEpoch} server_epoch=${serverEpoch} trigger=${triggerId || '-'}`);
|
|
3949
4251
|
return;
|
|
3950
4252
|
}
|
|
3951
4253
|
const currentEpoch = expectedEpoch ?? serverEpoch;
|
|
@@ -3961,7 +4263,7 @@ export class AUNClient {
|
|
|
3961
4263
|
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
3962
4264
|
if (rawChain) {
|
|
3963
4265
|
prevChainHint = rawChain;
|
|
3964
|
-
_clientLog(
|
|
4266
|
+
this._clientLog.info(`new member rotation supplementing prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
|
|
3965
4267
|
}
|
|
3966
4268
|
}
|
|
3967
4269
|
}
|
|
@@ -3973,7 +4275,7 @@ export class AUNClient {
|
|
|
3973
4275
|
this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
|
|
3974
4276
|
}
|
|
3975
4277
|
catch (cleanupExc) {
|
|
3976
|
-
_clientLog(
|
|
4278
|
+
this._clientLog.debug(`cleanup local pending group key failed: group=${groupId} epoch=${targetEpoch} rotation=${rotationId} err=${formatCaughtError(cleanupExc)}`);
|
|
3977
4279
|
}
|
|
3978
4280
|
};
|
|
3979
4281
|
const rotateParams = {
|
|
@@ -4027,14 +4329,14 @@ export class AUNClient {
|
|
|
4027
4329
|
pending: null,
|
|
4028
4330
|
});
|
|
4029
4331
|
}
|
|
4030
|
-
_clientLog(
|
|
4332
|
+
this._clientLog.warn(`group epoch begin failed; stop key distribution (group=${groupId}, current_epoch=${currentEpoch}, returned=${JSON.stringify(beginResult)})`);
|
|
4031
4333
|
discardGeneratedPending();
|
|
4032
4334
|
return;
|
|
4033
4335
|
}
|
|
4034
4336
|
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
4035
4337
|
const distributeResult = await this._distributeGroupEpochKey(info, activeRotationId);
|
|
4036
4338
|
if (distributeResult.failed.length > 0) {
|
|
4037
|
-
_clientLog(
|
|
4339
|
+
this._clientLog.warn(`group epoch key distribution incomplete; abort rotation before retry (group=${groupId} rotation=${activeRotationId} failed=${distributeResult.failed.join(',')})`);
|
|
4038
4340
|
await this._abortGroupRotation(activeRotationId, 'distribution_failed');
|
|
4039
4341
|
this._scheduleGroupRotationRetry(groupId, {
|
|
4040
4342
|
reason: 'membership_changed',
|
|
@@ -4047,7 +4349,7 @@ export class AUNClient {
|
|
|
4047
4349
|
}
|
|
4048
4350
|
await this._heartbeatGroupRotation(activeRotationId);
|
|
4049
4351
|
if (!await this._ackGroupRotationKey(activeRotationId, String(info.commitment ?? ''))) {
|
|
4050
|
-
_clientLog(
|
|
4352
|
+
this._clientLog.warn(`group epoch self ack failed; abort rotation before retry (group=${groupId} rotation=${activeRotationId})`);
|
|
4051
4353
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
4052
4354
|
this._scheduleGroupRotationRetry(groupId, {
|
|
4053
4355
|
reason: 'membership_changed',
|
|
@@ -4068,7 +4370,7 @@ export class AUNClient {
|
|
|
4068
4370
|
}
|
|
4069
4371
|
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
|
|
4070
4372
|
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
4071
|
-
_clientLog(
|
|
4373
|
+
this._clientLog.warn(`group epoch commit failed (group=${groupId}, rotation=${activeRotationId}, returned=${JSON.stringify(commitResult)})`);
|
|
4072
4374
|
this._scheduleGroupRotationRetry(groupId, {
|
|
4073
4375
|
reason: 'membership_changed',
|
|
4074
4376
|
triggerId,
|
|
@@ -4092,7 +4394,7 @@ export class AUNClient {
|
|
|
4092
4394
|
storeGroupSecret(this._keystore, this._aid, groupId, targetEpoch, committedSecret.secret, committedSecret.commitment, committedSecret.member_aids.length > 0 ? committedSecret.member_aids : memberAids, committedSecret.epoch_chain);
|
|
4093
4395
|
}
|
|
4094
4396
|
else {
|
|
4095
|
-
_clientLog(
|
|
4397
|
+
this._clientLog.warn(`group epoch commit succeeded but local target key does not match committed rotation; keep pending blocked (group=${groupId} rotation=${activeRotationId} epoch=${targetEpoch})`);
|
|
4096
4398
|
}
|
|
4097
4399
|
}
|
|
4098
4400
|
if (triggerId) {
|
|
@@ -4101,13 +4403,17 @@ export class AUNClient {
|
|
|
4101
4403
|
this._groupMembershipRotationDone = new Set(Array.from(this._groupMembershipRotationDone).slice(-1000));
|
|
4102
4404
|
}
|
|
4103
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}`);
|
|
4104
4408
|
}
|
|
4105
4409
|
catch (exc) {
|
|
4106
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)}`);
|
|
4107
4412
|
}
|
|
4108
4413
|
}
|
|
4109
4414
|
/** 将当前 group_secret 通过 P2P E2EE 分发给新成员 */
|
|
4110
4415
|
async _distributeKeyToNewMember(groupId, newMemberAid) {
|
|
4416
|
+
this._clientLog.debug(`_distributeKeyToNewMember enter: group=${groupId}, new_member=${newMemberAid}`);
|
|
4111
4417
|
try {
|
|
4112
4418
|
const secretData = this._groupE2ee.loadSecret(groupId);
|
|
4113
4419
|
if (secretData === null)
|
|
@@ -4138,16 +4444,19 @@ export class AUNClient {
|
|
|
4138
4444
|
// 重试 3 次,间隔递增(1s, 2s)
|
|
4139
4445
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4140
4446
|
try {
|
|
4447
|
+
this._clientLog.debug(`_distributeKeyToNewMember send attempt: group=${groupId}, new_member=${newMemberAid}, attempt=${attempt + 1}/3, epoch=${epoch}`);
|
|
4141
4448
|
await this.call('message.send', {
|
|
4142
4449
|
to: newMemberAid,
|
|
4143
4450
|
payload: distPayload,
|
|
4144
4451
|
encrypt: true,
|
|
4145
4452
|
persist_required: true,
|
|
4146
4453
|
});
|
|
4454
|
+
this._clientLog.debug(`_distributeKeyToNewMember success: group=${groupId}, new_member=${newMemberAid}, epoch=${epoch}`);
|
|
4147
4455
|
break; // 成功则跳出重试循环
|
|
4148
4456
|
}
|
|
4149
4457
|
catch (sendExc) {
|
|
4150
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)}`);
|
|
4151
4460
|
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
|
4152
4461
|
}
|
|
4153
4462
|
else {
|
|
@@ -4211,7 +4520,9 @@ export class AUNClient {
|
|
|
4211
4520
|
static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
|
|
4212
4521
|
/** open/invite_code 入群后延迟轮换。 */
|
|
4213
4522
|
async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
|
|
4214
|
-
|
|
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));
|
|
4215
4526
|
await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
|
|
4216
4527
|
}
|
|
4217
4528
|
/** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
|
|
@@ -4220,8 +4531,11 @@ export class AUNClient {
|
|
|
4220
4531
|
.filter(aid => aid && aid !== this._aid);
|
|
4221
4532
|
if (!groupId || !this._aid || memberAids.length === 0)
|
|
4222
4533
|
return;
|
|
4223
|
-
if (!this._groupE2ee.hasSecret(groupId))
|
|
4534
|
+
if (!this._groupE2ee.hasSecret(groupId)) {
|
|
4535
|
+
this._clientLog.debug(`backfill skipped: group=${groupId}, no local epoch key`);
|
|
4224
4536
|
return;
|
|
4537
|
+
}
|
|
4538
|
+
this._clientLog.debug(`backfill key distribution start: group=${groupId}, target_members=${JSON.stringify(memberAids)}`);
|
|
4225
4539
|
for (const memberAid of memberAids) {
|
|
4226
4540
|
const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
|
|
4227
4541
|
if (this._groupMemberKeyBackfillDone.has(dedupeKey))
|
|
@@ -4285,8 +4599,9 @@ export class AUNClient {
|
|
|
4285
4599
|
// 优先从 seq_tracker 表按行读取
|
|
4286
4600
|
const loadAll = this._keystore.loadAllSeqs;
|
|
4287
4601
|
if (typeof loadAll === 'function') {
|
|
4288
|
-
|
|
4602
|
+
let state = loadAll.call(this._keystore, this._aid, this._deviceId, this._slotId);
|
|
4289
4603
|
if (state && Object.keys(state).length > 0) {
|
|
4604
|
+
state = this._migrateSeqStateGroupIds(state);
|
|
4290
4605
|
this._seqTracker.restoreState(state);
|
|
4291
4606
|
return;
|
|
4292
4607
|
}
|
|
@@ -4296,12 +4611,14 @@ export class AUNClient {
|
|
|
4296
4611
|
if (typeof loader === 'function') {
|
|
4297
4612
|
const instanceState = loader.call(this._keystore, this._aid, this._deviceId, this._slotId);
|
|
4298
4613
|
if (instanceState && typeof instanceState.seq_tracker_state === 'object') {
|
|
4299
|
-
|
|
4614
|
+
let state = instanceState.seq_tracker_state;
|
|
4615
|
+
state = this._migrateSeqStateGroupIds(state);
|
|
4616
|
+
this._seqTracker.restoreState(state);
|
|
4300
4617
|
}
|
|
4301
4618
|
}
|
|
4302
4619
|
}
|
|
4303
4620
|
catch (exc) {
|
|
4304
|
-
_clientLog(
|
|
4621
|
+
this._clientLog.warn(`restore SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
4305
4622
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
4306
4623
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4307
4624
|
phase: 'restore',
|
|
@@ -4312,6 +4629,59 @@ export class AUNClient {
|
|
|
4312
4629
|
}).catch(() => { });
|
|
4313
4630
|
}
|
|
4314
4631
|
}
|
|
4632
|
+
/**
|
|
4633
|
+
* 把 seq_tracker state 里 group_event:/group_msg: 前缀的老/污染 group_id 归一化为 canonical。
|
|
4634
|
+
* 冲突取 max。同时落盘删除老 ns、写入新 ns,避免下次启动重复迁移。
|
|
4635
|
+
*/
|
|
4636
|
+
_migrateSeqStateGroupIds(state) {
|
|
4637
|
+
if (!state || Object.keys(state).length === 0)
|
|
4638
|
+
return state;
|
|
4639
|
+
const renameMap = {};
|
|
4640
|
+
for (const ns of Object.keys(state)) {
|
|
4641
|
+
for (const prefix of ['group_event:', 'group_msg:']) {
|
|
4642
|
+
if (ns.startsWith(prefix)) {
|
|
4643
|
+
const oldGid = ns.slice(prefix.length);
|
|
4644
|
+
const newGid = normalizeGroupId(oldGid);
|
|
4645
|
+
if (newGid && newGid !== oldGid) {
|
|
4646
|
+
renameMap[ns] = `${prefix}${newGid}`;
|
|
4647
|
+
}
|
|
4648
|
+
break;
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
}
|
|
4652
|
+
if (Object.keys(renameMap).length === 0)
|
|
4653
|
+
return state;
|
|
4654
|
+
const newState = { ...state };
|
|
4655
|
+
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
4656
|
+
const oldVal = Number(newState[oldNs] ?? 0);
|
|
4657
|
+
const curVal = Number(newState[newNs] ?? 0);
|
|
4658
|
+
delete newState[oldNs];
|
|
4659
|
+
newState[newNs] = Math.max(oldVal, curVal);
|
|
4660
|
+
}
|
|
4661
|
+
this._clientLog.info(`SeqTracker group_id migration: ${Object.keys(renameMap).length} namespaces rewritten`);
|
|
4662
|
+
// 落盘
|
|
4663
|
+
const saver = this._keystore.saveSeq;
|
|
4664
|
+
const deleter = this._keystore.deleteSeq;
|
|
4665
|
+
if (typeof saver === 'function' && this._aid) {
|
|
4666
|
+
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
4667
|
+
if (typeof deleter === 'function') {
|
|
4668
|
+
try {
|
|
4669
|
+
deleter.call(this._keystore, this._aid, this._deviceId, this._slotId, oldNs);
|
|
4670
|
+
}
|
|
4671
|
+
catch (e) {
|
|
4672
|
+
this._clientLog.debug(`delete old seq ns failed: ns=${oldNs} err=${formatCaughtError(e)}`);
|
|
4673
|
+
}
|
|
4674
|
+
}
|
|
4675
|
+
try {
|
|
4676
|
+
saver.call(this._keystore, this._aid, this._deviceId, this._slotId, newNs, newState[newNs]);
|
|
4677
|
+
}
|
|
4678
|
+
catch (e) {
|
|
4679
|
+
this._clientLog.debug(`write new seq ns failed: ns=${newNs} err=${formatCaughtError(e)}`);
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
return newState;
|
|
4684
|
+
}
|
|
4315
4685
|
_currentSeqTrackerContext() {
|
|
4316
4686
|
if (!this._aid)
|
|
4317
4687
|
return null;
|
|
@@ -4366,7 +4736,7 @@ export class AUNClient {
|
|
|
4366
4736
|
}
|
|
4367
4737
|
}
|
|
4368
4738
|
catch (exc) {
|
|
4369
|
-
_clientLog(
|
|
4739
|
+
this._clientLog.warn(`save SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
4370
4740
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
4371
4741
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4372
4742
|
phase: 'save',
|
|
@@ -4421,6 +4791,7 @@ export class AUNClient {
|
|
|
4421
4791
|
// ── 内部:连接 ────────────────────────────────────────────
|
|
4422
4792
|
/** 执行一次连接流程 */
|
|
4423
4793
|
async _connectOnce(params, allowReauth) {
|
|
4794
|
+
const tStart = Date.now();
|
|
4424
4795
|
const gatewayUrl = this._resolveGateway(params);
|
|
4425
4796
|
this._gatewayUrl = gatewayUrl;
|
|
4426
4797
|
this._slotId = String(params.slot_id ?? '');
|
|
@@ -4428,12 +4799,14 @@ export class AUNClient {
|
|
|
4428
4799
|
const prevState = this._state;
|
|
4429
4800
|
this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
|
|
4430
4801
|
this._state = 'connecting';
|
|
4802
|
+
this._clientLog.debug(`_connectOnce enter: gateway=${gatewayUrl}, allowReauth=${allowReauth}`);
|
|
4431
4803
|
// 前置 restore:在 transport.connect 启动 reader 之前完成,
|
|
4432
4804
|
// 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
|
|
4433
4805
|
this._refreshSeqTrackerContext();
|
|
4434
4806
|
this._restoreSeqTrackerState();
|
|
4435
4807
|
try {
|
|
4436
4808
|
const challenge = await this._transport.connect(gatewayUrl);
|
|
4809
|
+
this._clientLog.debug(`WebSocket connection established: gateway=${gatewayUrl}`);
|
|
4437
4810
|
this._state = 'authenticating';
|
|
4438
4811
|
if (allowReauth) {
|
|
4439
4812
|
const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
|
|
@@ -4448,8 +4821,8 @@ export class AUNClient {
|
|
|
4448
4821
|
if (identity && isJsonObject(identity)) {
|
|
4449
4822
|
this._identity = identity;
|
|
4450
4823
|
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
4451
|
-
if (
|
|
4452
|
-
|
|
4824
|
+
if (this._aid)
|
|
4825
|
+
this._logger.bindAid(this._aid);
|
|
4453
4826
|
if (this._sessionParams !== null) {
|
|
4454
4827
|
this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
|
|
4455
4828
|
}
|
|
@@ -4465,6 +4838,7 @@ export class AUNClient {
|
|
|
4465
4838
|
this._syncIdentityAfterConnect(String(params.access_token));
|
|
4466
4839
|
}
|
|
4467
4840
|
this._state = 'connected';
|
|
4841
|
+
this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
|
|
4468
4842
|
await this._dispatcher.publish('connection.state', { state: this._state, gateway: gatewayUrl });
|
|
4469
4843
|
// auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
|
|
4470
4844
|
// 若 context 发生变化,重新 refresh + restore,保持 tracker 与真实身份一致。
|
|
@@ -4478,11 +4852,13 @@ export class AUNClient {
|
|
|
4478
4852
|
await this._uploadPrekey();
|
|
4479
4853
|
}
|
|
4480
4854
|
catch (exc) {
|
|
4481
|
-
_clientLog(
|
|
4855
|
+
this._clientLog.warn(`prekey upload failed: ${formatCaughtError(exc)}`);
|
|
4482
4856
|
}
|
|
4857
|
+
this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl}, aid=${this._aid ?? ''}`);
|
|
4483
4858
|
}
|
|
4484
4859
|
catch (err) {
|
|
4485
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)}`);
|
|
4486
4862
|
throw err;
|
|
4487
4863
|
}
|
|
4488
4864
|
}
|
|
@@ -4515,8 +4891,8 @@ export class AUNClient {
|
|
|
4515
4891
|
identity.access_token = accessToken;
|
|
4516
4892
|
this._identity = identity;
|
|
4517
4893
|
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
4518
|
-
if (
|
|
4519
|
-
|
|
4894
|
+
if (this._aid)
|
|
4895
|
+
this._logger.bindAid(this._aid);
|
|
4520
4896
|
const persistIdentity = this._auth._persistIdentity;
|
|
4521
4897
|
if (typeof persistIdentity === 'function') {
|
|
4522
4898
|
persistIdentity.call(this._auth, identity);
|
|
@@ -4648,10 +5024,10 @@ export class AUNClient {
|
|
|
4648
5024
|
consecutiveFailures = 0;
|
|
4649
5025
|
}).catch((exc) => {
|
|
4650
5026
|
consecutiveFailures++;
|
|
4651
|
-
_clientLog(
|
|
5027
|
+
this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
|
|
4652
5028
|
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) }).catch(() => { });
|
|
4653
5029
|
if (consecutiveFailures >= maxFailures) {
|
|
4654
|
-
_clientLog(
|
|
5030
|
+
this._clientLog.warn(`${maxFailures} consecutive heartbeat failures, triggering reconnect`);
|
|
4655
5031
|
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
4656
5032
|
}
|
|
4657
5033
|
});
|
|
@@ -4715,7 +5091,7 @@ export class AUNClient {
|
|
|
4715
5091
|
if (exc instanceof AuthError) {
|
|
4716
5092
|
this._tokenRefreshFailures++;
|
|
4717
5093
|
if (this._tokenRefreshFailures >= 3) {
|
|
4718
|
-
_clientLog(
|
|
5094
|
+
this._clientLog.warn(`token refresh failed ${this._tokenRefreshFailures} consecutive times, stopping refresh loop and triggering reconnect`);
|
|
4719
5095
|
await this._dispatcher.publish('token.refresh_exhausted', {
|
|
4720
5096
|
aid: this._identity?.aid ?? null,
|
|
4721
5097
|
consecutive_failures: this._tokenRefreshFailures,
|
|
@@ -4725,7 +5101,7 @@ export class AUNClient {
|
|
|
4725
5101
|
this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
|
|
4726
5102
|
return;
|
|
4727
5103
|
}
|
|
4728
|
-
_clientLog(
|
|
5104
|
+
this._clientLog.debug(`token refresh failed (${this._tokenRefreshFailures}/3), will retry: ${exc}`);
|
|
4729
5105
|
}
|
|
4730
5106
|
else {
|
|
4731
5107
|
await this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
@@ -4827,7 +5203,7 @@ export class AUNClient {
|
|
|
4827
5203
|
this._prekeyReplenished.add(prekeyId);
|
|
4828
5204
|
}
|
|
4829
5205
|
catch (exc) {
|
|
4830
|
-
_clientLog(
|
|
5206
|
+
this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${formatCaughtError(exc)}`);
|
|
4831
5207
|
}
|
|
4832
5208
|
finally {
|
|
4833
5209
|
this._prekeyReplenishInflight.delete(prekeyId);
|
|
@@ -4852,7 +5228,7 @@ export class AUNClient {
|
|
|
4852
5228
|
}
|
|
4853
5229
|
}
|
|
4854
5230
|
catch (exc) {
|
|
4855
|
-
_clientLog(
|
|
5231
|
+
this._clientLog.warn(`epoch cleanup failed: ${formatCaughtError(exc)}`);
|
|
4856
5232
|
}
|
|
4857
5233
|
}, 3600_000);
|
|
4858
5234
|
this._unrefTimer(this._groupEpochCleanupTimer);
|
|
@@ -4868,11 +5244,11 @@ export class AUNClient {
|
|
|
4868
5244
|
? this._keystore.listGroupSecretIds(this._aid)
|
|
4869
5245
|
: [];
|
|
4870
5246
|
for (const gid of groupIds) {
|
|
4871
|
-
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => _clientLog(
|
|
5247
|
+
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch rotation failed: ${formatCaughtError(exc)}`));
|
|
4872
5248
|
}
|
|
4873
5249
|
}
|
|
4874
5250
|
catch (exc) {
|
|
4875
|
-
_clientLog(
|
|
5251
|
+
this._clientLog.warn(`epoch rotation failed: ${formatCaughtError(exc)}`);
|
|
4876
5252
|
}
|
|
4877
5253
|
}, rotateInterval * 1000);
|
|
4878
5254
|
this._unrefTimer(this._groupEpochRotateTimer);
|
|
@@ -4920,7 +5296,7 @@ export class AUNClient {
|
|
|
4920
5296
|
_onGatewayDisconnect(data) {
|
|
4921
5297
|
const code = data?.code;
|
|
4922
5298
|
const reason = data?.reason ?? '';
|
|
4923
|
-
_clientLog(
|
|
5299
|
+
this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}`);
|
|
4924
5300
|
this._serverKicked = true;
|
|
4925
5301
|
}
|
|
4926
5302
|
/** 传输层断线回调 */
|
|
@@ -4930,6 +5306,7 @@ export class AUNClient {
|
|
|
4930
5306
|
// 已在重连中则跳过,避免心跳超时和 transport 断线回调重复触发
|
|
4931
5307
|
if (this._reconnectActive)
|
|
4932
5308
|
return;
|
|
5309
|
+
this._clientLog.warn(`transport disconnected: closeCode=${closeCode ?? 'none'}, error=${error ? formatCaughtError(error) : 'none'}`);
|
|
4933
5310
|
this._state = 'disconnected';
|
|
4934
5311
|
this._stopBackgroundTasks();
|
|
4935
5312
|
await this._dispatcher.publish('connection.state', { state: this._state, error });
|
|
@@ -4941,7 +5318,7 @@ export class AUNClient {
|
|
|
4941
5318
|
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
4942
5319
|
this._state = 'terminal_failed';
|
|
4943
5320
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
4944
|
-
_clientLog(
|
|
5321
|
+
this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
|
|
4945
5322
|
await this._dispatcher.publish('connection.state', {
|
|
4946
5323
|
state: this._state, error, reason,
|
|
4947
5324
|
});
|
|
@@ -4957,8 +5334,9 @@ export class AUNClient {
|
|
|
4957
5334
|
return;
|
|
4958
5335
|
this._reconnectActive = true;
|
|
4959
5336
|
this._reconnectAbort = new AbortController();
|
|
5337
|
+
this._clientLog.debug(`reconnect loop started: serverInitiated=${String(serverInitiated)}, aid=${this._aid ?? ''}`);
|
|
4960
5338
|
this._reconnectLoop(serverInitiated).catch((exc) => {
|
|
4961
|
-
_clientLog(
|
|
5339
|
+
this._clientLog.warn(`reconnect loop error: ${formatCaughtError(exc)}`);
|
|
4962
5340
|
});
|
|
4963
5341
|
}
|
|
4964
5342
|
/** 重连循环(for 循环 + AbortController,与 JS/Python 对齐) */
|
|
@@ -5005,6 +5383,7 @@ export class AUNClient {
|
|
|
5005
5383
|
}
|
|
5006
5384
|
await this._connectOnce(this._sessionParams, true);
|
|
5007
5385
|
// 重连成功,退出循环
|
|
5386
|
+
this._clientLog.debug(`reconnect success: attempt=${attempt}, aid=${this._aid ?? ''}`);
|
|
5008
5387
|
this._reconnectActive = false;
|
|
5009
5388
|
this._reconnectAbort = null;
|
|
5010
5389
|
return;
|
|
@@ -5050,62 +5429,80 @@ export class AUNClient {
|
|
|
5050
5429
|
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5051
5430
|
*/
|
|
5052
5431
|
async createNamedGroup(groupName, opts = {}) {
|
|
5053
|
-
const
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
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
|
+
}
|
|
5076
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;
|
|
5077
5466
|
}
|
|
5078
|
-
return result;
|
|
5079
5467
|
}
|
|
5080
5468
|
/**
|
|
5081
5469
|
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5082
5470
|
*/
|
|
5083
5471
|
async bindGroupAid(groupId, groupName) {
|
|
5084
|
-
const
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
const result = await this.call('group.bind_aid', params);
|
|
5093
|
-
const groupInfo = result?.group;
|
|
5094
|
-
const aidCert = result?.aid_cert;
|
|
5095
|
-
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5096
|
-
if (groupAid && aidCert) {
|
|
5097
|
-
this._keystore.saveIdentity(groupAid, {
|
|
5098
|
-
private_key_pem: identity.private_key_pem,
|
|
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,
|
|
5099
5480
|
public_key: identity.public_key_der_b64,
|
|
5100
5481
|
curve: 'P-256',
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
const
|
|
5104
|
-
|
|
5105
|
-
|
|
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
|
+
}
|
|
5106
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;
|
|
5107
5505
|
}
|
|
5108
|
-
return result;
|
|
5109
5506
|
}
|
|
5110
5507
|
/** 判断是否应重试重连 */
|
|
5111
5508
|
static _shouldRetryReconnect(error) {
|