@agentunion/fastaun 0.2.15 → 0.2.17
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 +28 -25
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +48 -7
- package/dist/client.js +1079 -280
- 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/e2ee-group.d.ts +36 -1
- package/dist/e2ee-group.js +134 -23
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +3 -0
- package/dist/e2ee.js +12 -4
- 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 +11 -0
- package/dist/keystore/aid-db.js +49 -2
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +5 -0
- package/dist/keystore/file.js +12 -6
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +14 -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 +26 -3
- package/dist/logger.js +117 -40
- package/dist/logger.js.map +1 -1
- package/dist/secret-store/file-store.d.ts +4 -0
- package/dist/secret-store/file-store.js +5 -2
- 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 +9 -1
- package/dist/transport.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -19,12 +19,13 @@ import { configFromMap, getDeviceId, normalizeInstanceId } from './config.js';
|
|
|
19
19
|
import { CryptoProvider } from './crypto.js';
|
|
20
20
|
import { GatewayDiscovery } from './discovery.js';
|
|
21
21
|
import { E2EEManager } from './e2ee.js';
|
|
22
|
-
import { GroupE2EEManager, computeMembershipCommitment, storeGroupSecret, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, } from './e2ee-group.js';
|
|
22
|
+
import { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, } from './e2ee-group.js';
|
|
23
23
|
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
|
|
24
24
|
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)
|
|
@@ -83,7 +72,7 @@ const INTERNAL_ONLY_METHODS = new Set([
|
|
|
83
72
|
const DEFAULT_SESSION_OPTIONS = {
|
|
84
73
|
auto_reconnect: true,
|
|
85
74
|
heartbeat_interval: 30.0,
|
|
86
|
-
token_refresh_before:
|
|
75
|
+
token_refresh_before: 1800.0,
|
|
87
76
|
retry: {
|
|
88
77
|
initial_delay: 1.0,
|
|
89
78
|
max_delay: 64.0,
|
|
@@ -98,6 +87,7 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
98
87
|
};
|
|
99
88
|
const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
|
|
100
89
|
const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
|
|
90
|
+
const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
|
|
101
91
|
const GROUP_ROTATION_LEASE_MS = 120_000;
|
|
102
92
|
const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
103
93
|
const PENDING_DECRYPT_LIMIT = 100;
|
|
@@ -139,9 +129,15 @@ const SIGNED_METHODS = new Set([
|
|
|
139
129
|
'group.resources.delete', 'group.resources.request_add',
|
|
140
130
|
'group.resources.direct_add', 'group.resources.approve_request',
|
|
141
131
|
'group.resources.reject_request',
|
|
132
|
+
'group.commit_state',
|
|
133
|
+
'group.e2ee.begin_rotation', 'group.e2ee.commit_rotation',
|
|
134
|
+
'group.e2ee.abort_rotation',
|
|
135
|
+
'group.ban', 'group.unban',
|
|
136
|
+
'group.dissolve', 'group.suspend', 'group.resume',
|
|
142
137
|
]);
|
|
143
|
-
/** peer 证书缓存 TTL(
|
|
144
|
-
const PEER_CERT_CACHE_TTL =
|
|
138
|
+
/** peer 证书缓存 TTL(1 小时) */
|
|
139
|
+
const PEER_CERT_CACHE_TTL = 3600;
|
|
140
|
+
const PEER_PREKEYS_CACHE_TTL = 3600;
|
|
145
141
|
const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
|
|
146
142
|
function isGroupServiceAid(value) {
|
|
147
143
|
const text = String(value ?? '').trim();
|
|
@@ -213,6 +209,19 @@ function normalizePeerPrekeys(prekeys) {
|
|
|
213
209
|
}
|
|
214
210
|
return filtered;
|
|
215
211
|
}
|
|
212
|
+
/** 判断加密失败是否由过期的对端证书或 prekey 引起,可通过刷新缓存重试 */
|
|
213
|
+
function isRetryablePeerMaterialError(error) {
|
|
214
|
+
const localCode = String(error?.localCode ?? error?.code ?? '').trim();
|
|
215
|
+
if (localCode === 'PEER_CERT_FINGERPRINT_MISMATCH'
|
|
216
|
+
|| localCode === 'PREKEY_CERT_FINGERPRINT_MISMATCH'
|
|
217
|
+
|| localCode === 'PREKEY_SIGNATURE_VERIFY_FAILED') {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
221
|
+
return message.includes('peer cert fingerprint mismatch for ')
|
|
222
|
+
|| message.includes('prekey cert fingerprint mismatch')
|
|
223
|
+
|| message.includes('prekey 签名验证失败');
|
|
224
|
+
}
|
|
216
225
|
function formatCaughtError(error) {
|
|
217
226
|
return error instanceof Error ? error : String(error);
|
|
218
227
|
}
|
|
@@ -352,6 +361,8 @@ export class AUNClient {
|
|
|
352
361
|
_groupEpochRotationInflight = new Set();
|
|
353
362
|
_groupEpochRecoveryInflight = new Map();
|
|
354
363
|
_groupMembershipRotationDone = new Set();
|
|
364
|
+
/** 群密钥 backfill 去重:已完成/进行中的 key 集合,防止重复分发 */
|
|
365
|
+
_groupMemberKeyBackfillDone = new Set();
|
|
355
366
|
_groupEpochRotationRetryTimers = new Map();
|
|
356
367
|
// ── 后台任务定时器 ──────────────────────────────────────────
|
|
357
368
|
_heartbeatTimer = null;
|
|
@@ -364,6 +375,8 @@ export class AUNClient {
|
|
|
364
375
|
_reconnectActive = false;
|
|
365
376
|
_reconnectAbort = null;
|
|
366
377
|
_serverKicked = false;
|
|
378
|
+
_logger;
|
|
379
|
+
_clientLog;
|
|
367
380
|
constructor(config, debug = false) {
|
|
368
381
|
const rawConfig = { ...(config ?? {}) };
|
|
369
382
|
this._configModel = configFromMap(rawConfig);
|
|
@@ -372,20 +385,27 @@ export class AUNClient {
|
|
|
372
385
|
root_ca_path: this._configModel.rootCaPath,
|
|
373
386
|
seed_password: this._configModel.seedPassword,
|
|
374
387
|
};
|
|
375
|
-
|
|
388
|
+
// 初始化 Logger(per-client 单例,必须最早创建)
|
|
389
|
+
const debugFlag = this._configModel.debug || debug;
|
|
390
|
+
this._logger = new AUNLogger({
|
|
391
|
+
debug: debugFlag,
|
|
392
|
+
aunPath: this._configModel.aunPath,
|
|
393
|
+
});
|
|
394
|
+
this._clientLog = this._logger.for('aun_core.client');
|
|
395
|
+
if (debugFlag) {
|
|
396
|
+
this._clientLog.info(`AUNClient 初始化完成 (debug=true, aunPath=${this._configModel.aunPath})`);
|
|
397
|
+
}
|
|
398
|
+
this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
|
|
376
399
|
this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl });
|
|
377
|
-
const defaultSQLiteBackup = new SQLiteBackup(join(this._configModel.aunPath, '.aun_backup', 'aun_backup.db'));
|
|
400
|
+
const defaultSQLiteBackup = new SQLiteBackup(join(this._configModel.aunPath, '.aun_backup', 'aun_backup.db'), { logger: this._logger.for('aun_core.keystore') });
|
|
378
401
|
const keystore = new FileKeyStore(this._configModel.aunPath, {
|
|
379
402
|
encryptionSeed: this._configModel.seedPassword ?? undefined,
|
|
380
403
|
sqliteBackup: defaultSQLiteBackup,
|
|
404
|
+
logger: this._logger.for('aun_core.keystore'),
|
|
405
|
+
secretStoreLogger: this._logger.for('aun_core.secret-store'),
|
|
381
406
|
});
|
|
382
407
|
this._keystore = keystore;
|
|
383
408
|
this._deviceId = getDeviceId(this._configModel.aunPath);
|
|
384
|
-
// 初始化文件日志(仅 debug 模式)
|
|
385
|
-
if (debug) {
|
|
386
|
-
_debugLogger = new AUNLogger();
|
|
387
|
-
_clientLog('info', 'AUNClient 初始化完成 (debug=true, aunPath=%s)', this._configModel.aunPath);
|
|
388
|
-
}
|
|
389
409
|
this._slotId = '';
|
|
390
410
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
391
411
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -397,24 +417,28 @@ export class AUNClient {
|
|
|
397
417
|
slotId: this._slotId,
|
|
398
418
|
rootCaPath: this._configModel.rootCaPath ?? undefined,
|
|
399
419
|
verifySsl: this._configModel.verifySsl,
|
|
420
|
+
logger: this._logger.for('aun_core.auth'),
|
|
400
421
|
});
|
|
401
422
|
this._transport = new RPCTransport({
|
|
402
423
|
eventDispatcher: this._dispatcher,
|
|
403
424
|
timeout: 10_000,
|
|
404
425
|
onDisconnect: (err, closeCode) => this._handleTransportDisconnect(err, closeCode),
|
|
405
426
|
verifySsl: this._configModel.verifySsl,
|
|
427
|
+
logger: this._logger.for('aun_core.transport'),
|
|
406
428
|
});
|
|
407
429
|
this._e2ee = new E2EEManager({
|
|
408
430
|
identityFn: () => this._identity ?? {},
|
|
409
431
|
deviceIdFn: () => this._deviceId,
|
|
410
432
|
keystore,
|
|
411
433
|
replayWindowSeconds: this._configModel.replayWindowSeconds,
|
|
434
|
+
logger: this._logger.for('aun_core.e2ee'),
|
|
412
435
|
});
|
|
413
436
|
this._groupE2ee = new GroupE2EEManager({
|
|
414
437
|
identityFn: () => this._identity ?? {},
|
|
415
438
|
keystore,
|
|
416
439
|
senderCertResolver: (aid) => this._getVerifiedPeerCert(aid),
|
|
417
440
|
initiatorCertResolver: (aid) => this._getVerifiedPeerCert(aid),
|
|
441
|
+
logger: this._logger.for('aun_core.e2ee-group'),
|
|
418
442
|
});
|
|
419
443
|
this.auth = new AuthNamespace(this);
|
|
420
444
|
this.custody = new CustodyNamespace(this);
|
|
@@ -425,6 +449,8 @@ export class AUNClient {
|
|
|
425
449
|
this._dispatcher.subscribe('_raw.group.message_created', (data) => this._onRawGroupMessageCreated(data));
|
|
426
450
|
// 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
|
|
427
451
|
this._dispatcher.subscribe('_raw.group.changed', (data) => this._onRawGroupChanged(data));
|
|
452
|
+
// 群组状态提交事件:验证 state_hash 链并更新本地存储
|
|
453
|
+
this._dispatcher.subscribe('_raw.group.state_committed', (data) => this._onGroupStateCommitted(data));
|
|
428
454
|
// 其他事件直接透传
|
|
429
455
|
for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
|
|
430
456
|
this._dispatcher.subscribe(`_raw.${evt}`, (data) => this._dispatcher.publish(evt, data));
|
|
@@ -468,6 +494,7 @@ export class AUNClient {
|
|
|
468
494
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
469
495
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
470
496
|
}
|
|
497
|
+
this._state = 'connecting';
|
|
471
498
|
const params = { ...auth };
|
|
472
499
|
if (options)
|
|
473
500
|
Object.assign(params, options);
|
|
@@ -477,7 +504,16 @@ export class AUNClient {
|
|
|
477
504
|
const callTimeoutSec = this._sessionOptions.timeouts.call;
|
|
478
505
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
|
|
479
506
|
this._closing = false;
|
|
480
|
-
|
|
507
|
+
try {
|
|
508
|
+
await this._connectOnce(normalized, false);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
// 连接失败时回退状态,允许重试
|
|
512
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
513
|
+
this._state = 'disconnected';
|
|
514
|
+
}
|
|
515
|
+
throw err;
|
|
516
|
+
}
|
|
481
517
|
}
|
|
482
518
|
/** 关闭连接 */
|
|
483
519
|
async close() {
|
|
@@ -489,6 +525,7 @@ export class AUNClient {
|
|
|
489
525
|
const closableKeyStore = this._keystore;
|
|
490
526
|
closableKeyStore.close?.();
|
|
491
527
|
this._state = 'closed';
|
|
528
|
+
this._logger.close();
|
|
492
529
|
this._resetSeqTrackingState();
|
|
493
530
|
return;
|
|
494
531
|
}
|
|
@@ -496,6 +533,7 @@ export class AUNClient {
|
|
|
496
533
|
const closableKeyStore = this._keystore;
|
|
497
534
|
closableKeyStore.close?.();
|
|
498
535
|
this._state = 'closed';
|
|
536
|
+
this._logger.close();
|
|
499
537
|
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
500
538
|
this._resetSeqTrackingState();
|
|
501
539
|
}
|
|
@@ -555,6 +593,11 @@ export class AUNClient {
|
|
|
555
593
|
const p = { ...(params ?? {}) };
|
|
556
594
|
this._validateOutboundCall(method, p);
|
|
557
595
|
this._injectMessageCursorContext(method, p);
|
|
596
|
+
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
597
|
+
// 不对无域输入补本域——保持与历史行为兼容,交给服务端归一化
|
|
598
|
+
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null && p.group_id !== '') {
|
|
599
|
+
p.group_id = normalizeGroupId(p.group_id);
|
|
600
|
+
}
|
|
558
601
|
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
559
602
|
if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
|
|
560
603
|
p.device_id = this._deviceId;
|
|
@@ -569,8 +612,7 @@ export class AUNClient {
|
|
|
569
612
|
if (encrypt) {
|
|
570
613
|
return await this._sendEncrypted(p);
|
|
571
614
|
}
|
|
572
|
-
|
|
573
|
-
delete p.headers;
|
|
615
|
+
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
574
616
|
}
|
|
575
617
|
// 自动加密:group.send 默认加密(encrypt 默认 True)
|
|
576
618
|
if (method === 'group.send') {
|
|
@@ -579,24 +621,20 @@ export class AUNClient {
|
|
|
579
621
|
if (encrypt) {
|
|
580
622
|
return await this._sendGroupEncrypted(p);
|
|
581
623
|
}
|
|
582
|
-
delete p.protected_headers;
|
|
583
|
-
delete p.headers;
|
|
584
624
|
}
|
|
585
625
|
if (method === 'group.thought.put') {
|
|
586
626
|
const encrypt = p.encrypt ?? true;
|
|
587
627
|
delete p.encrypt;
|
|
588
|
-
if (
|
|
589
|
-
|
|
628
|
+
if (encrypt) {
|
|
629
|
+
return await this._putGroupThoughtEncrypted(p);
|
|
590
630
|
}
|
|
591
|
-
return await this._putGroupThoughtEncrypted(p);
|
|
592
631
|
}
|
|
593
632
|
if (method === 'message.thought.put') {
|
|
594
633
|
const encrypt = p.encrypt ?? true;
|
|
595
634
|
delete p.encrypt;
|
|
596
|
-
if (
|
|
597
|
-
|
|
635
|
+
if (encrypt) {
|
|
636
|
+
return await this._putMessageThoughtEncrypted(p);
|
|
598
637
|
}
|
|
599
|
-
return await this._putMessageThoughtEncrypted(p);
|
|
600
638
|
}
|
|
601
639
|
// 关键操作自动附加客户端签名
|
|
602
640
|
if (SIGNED_METHODS.has(method)) {
|
|
@@ -628,7 +666,7 @@ export class AUNClient {
|
|
|
628
666
|
if (serverAck > 0) {
|
|
629
667
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
630
668
|
if (contig < serverAck) {
|
|
631
|
-
_clientLog(
|
|
669
|
+
this._clientLog.info(`message.pull retention-floor 推进: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAck}`);
|
|
632
670
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
633
671
|
}
|
|
634
672
|
}
|
|
@@ -640,7 +678,7 @@ export class AUNClient {
|
|
|
640
678
|
seq: contig,
|
|
641
679
|
device_id: this._deviceId,
|
|
642
680
|
slot_id: this._slotId,
|
|
643
|
-
}).catch((e) => { _clientLog(
|
|
681
|
+
}).catch((e) => { this._clientLog.debug(`message.pull auto-ack 失败: ${formatCaughtError(e)}`); });
|
|
644
682
|
}
|
|
645
683
|
}
|
|
646
684
|
}
|
|
@@ -667,7 +705,7 @@ export class AUNClient {
|
|
|
667
705
|
if (serverAck > 0) {
|
|
668
706
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
669
707
|
if (contig < serverAck) {
|
|
670
|
-
_clientLog(
|
|
708
|
+
this._clientLog.info(`group.pull retention-floor 推进: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAck}`);
|
|
671
709
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
672
710
|
}
|
|
673
711
|
}
|
|
@@ -682,7 +720,7 @@ export class AUNClient {
|
|
|
682
720
|
msg_seq: contig,
|
|
683
721
|
device_id: this._deviceId,
|
|
684
722
|
slot_id: this._slotId,
|
|
685
|
-
}).catch((e) => { _clientLog(
|
|
723
|
+
}).catch((e) => { this._clientLog.debug(`group.pull auto-ack 失败: group=${gid} ${formatCaughtError(e)}`); });
|
|
686
724
|
}
|
|
687
725
|
}
|
|
688
726
|
}
|
|
@@ -722,10 +760,13 @@ export class AUNClient {
|
|
|
722
760
|
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
723
761
|
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
724
762
|
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
763
|
+
// 自加入方法(request_join/use_invite_code)需要 allowMember=true,
|
|
764
|
+
// 因为新成员角色是 member,必须允许 member 参与 leader 选举。
|
|
765
|
+
const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
|
|
725
766
|
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
726
|
-
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
|
|
767
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
|
|
727
768
|
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
728
|
-
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => _clientLog(
|
|
769
|
+
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => this._clientLog.warn(`membership RPC epoch rotation fallback failed: ${formatCaughtError(exc)}`));
|
|
729
770
|
}
|
|
730
771
|
}
|
|
731
772
|
return result;
|
|
@@ -780,50 +821,73 @@ export class AUNClient {
|
|
|
780
821
|
if (!this._p2pSynced) {
|
|
781
822
|
await this._lazySyncP2p();
|
|
782
823
|
}
|
|
783
|
-
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
824
|
+
// 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
|
|
825
|
+
const sendAttempt = async (refreshPeerMaterial = false) => {
|
|
826
|
+
const recipientPrekeys = refreshPeerMaterial
|
|
827
|
+
? await this._refreshPeerPrekeys(toAid)
|
|
828
|
+
: await this._fetchPeerPrekeys(toAid);
|
|
829
|
+
const selfSyncCopies = await this._buildSelfSyncCopies({
|
|
830
|
+
logicalToAid: toAid,
|
|
831
|
+
payload,
|
|
832
|
+
messageId,
|
|
833
|
+
timestamp,
|
|
834
|
+
protectedHeaders,
|
|
835
|
+
});
|
|
836
|
+
// 多设备过滤:只保留有有效 device_id 的可路由 prekey,
|
|
837
|
+
// 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
|
|
838
|
+
const routablePrekeys = recipientPrekeys.filter(pk => {
|
|
839
|
+
const did = String(pk.device_id ?? '').trim();
|
|
840
|
+
return did && did !== PREKEY_FALLBACK_DEVICE_ID;
|
|
841
|
+
});
|
|
842
|
+
const canUseMultiDevice = routablePrekeys.length > 0
|
|
843
|
+
&& (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
|
|
844
|
+
if (!canUseMultiDevice) {
|
|
845
|
+
return await this._sendEncryptedSingle({
|
|
846
|
+
toAid,
|
|
847
|
+
payload,
|
|
848
|
+
messageId,
|
|
849
|
+
timestamp,
|
|
850
|
+
prekey: routablePrekeys[0] ?? recipientPrekeys[0],
|
|
851
|
+
persistRequired,
|
|
852
|
+
protectedHeaders,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
793
856
|
toAid,
|
|
794
857
|
payload,
|
|
795
858
|
messageId,
|
|
796
859
|
timestamp,
|
|
797
|
-
|
|
798
|
-
persistRequired,
|
|
860
|
+
prekeys: routablePrekeys,
|
|
799
861
|
protectedHeaders,
|
|
800
862
|
});
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
});
|
|
810
|
-
const sendParams = {
|
|
811
|
-
to: toAid,
|
|
812
|
-
payload: {
|
|
863
|
+
const sendParams = {
|
|
864
|
+
to: toAid,
|
|
865
|
+
payload: {
|
|
866
|
+
type: 'e2ee.multi_device',
|
|
867
|
+
logical_message_id: messageId,
|
|
868
|
+
recipient_copies: recipientCopies,
|
|
869
|
+
self_copies: selfSyncCopies,
|
|
870
|
+
},
|
|
813
871
|
type: 'e2ee.multi_device',
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
872
|
+
encrypted: true,
|
|
873
|
+
message_id: messageId,
|
|
874
|
+
timestamp,
|
|
875
|
+
};
|
|
876
|
+
if (persistRequired) {
|
|
877
|
+
sendParams.persist_required = true;
|
|
878
|
+
}
|
|
879
|
+
return await this._transport.call('message.send', sendParams);
|
|
822
880
|
};
|
|
823
|
-
|
|
824
|
-
|
|
881
|
+
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
882
|
+
try {
|
|
883
|
+
return await sendAttempt(false);
|
|
825
884
|
}
|
|
826
|
-
|
|
885
|
+
catch (exc) {
|
|
886
|
+
if (!isRetryablePeerMaterialError(exc))
|
|
887
|
+
throw exc;
|
|
888
|
+
this._clientLog.warn(`peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
889
|
+
}
|
|
890
|
+
return await sendAttempt(true);
|
|
827
891
|
}
|
|
828
892
|
async _sendEncryptedSingle(opts) {
|
|
829
893
|
let prekey = opts.prekey ?? null;
|
|
@@ -927,7 +991,15 @@ export class AUNClient {
|
|
|
927
991
|
if (deviceId === this._deviceId) {
|
|
928
992
|
continue;
|
|
929
993
|
}
|
|
930
|
-
|
|
994
|
+
let peerCertPem;
|
|
995
|
+
try {
|
|
996
|
+
peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
|
|
997
|
+
}
|
|
998
|
+
catch (e) {
|
|
999
|
+
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
1000
|
+
this._clientLog.warn(`self-sync 跳过设备 ${deviceId}: 证书解析失败 (${e}),可能是旧 prekey`);
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
931
1003
|
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
932
1004
|
logicalToAid: opts.logicalToAid,
|
|
933
1005
|
payload: opts.payload,
|
|
@@ -963,7 +1035,7 @@ export class AUNClient {
|
|
|
963
1035
|
mode: encryptResult.mode,
|
|
964
1036
|
reason: encryptResult.degradation_reason,
|
|
965
1037
|
}).catch((exc) => {
|
|
966
|
-
_clientLog(
|
|
1038
|
+
this._clientLog.warn(`发布 e2ee.degraded 事件失败: ${formatCaughtError(exc)}`);
|
|
967
1039
|
});
|
|
968
1040
|
}
|
|
969
1041
|
}
|
|
@@ -1028,7 +1100,7 @@ export class AUNClient {
|
|
|
1028
1100
|
}
|
|
1029
1101
|
catch (exc) {
|
|
1030
1102
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
1031
|
-
_clientLog(
|
|
1103
|
+
this._clientLog.warn(`群 ${groupId} 调用 ${method} 时 epoch 已过旧,恢复密钥后重加密重试一次: ${formatCaughtError(exc)}`);
|
|
1032
1104
|
({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
|
|
1033
1105
|
continue;
|
|
1034
1106
|
}
|
|
@@ -1114,11 +1186,11 @@ export class AUNClient {
|
|
|
1114
1186
|
}
|
|
1115
1187
|
if (messages.length > 0) {
|
|
1116
1188
|
this._saveSeqTrackerState();
|
|
1117
|
-
_clientLog(
|
|
1189
|
+
this._clientLog.info(`惰性同步群 ${groupId}: pull ${messages.length} 条消息, after_seq=${afterSeq}`);
|
|
1118
1190
|
}
|
|
1119
1191
|
}
|
|
1120
1192
|
catch (exc) {
|
|
1121
|
-
_clientLog(
|
|
1193
|
+
this._clientLog.warn(`惰性同步群 ${groupId} 失败: ${formatCaughtError(exc)}`);
|
|
1122
1194
|
}
|
|
1123
1195
|
}
|
|
1124
1196
|
/** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
|
|
@@ -1141,11 +1213,11 @@ export class AUNClient {
|
|
|
1141
1213
|
}
|
|
1142
1214
|
if (messages.length > 0) {
|
|
1143
1215
|
this._saveSeqTrackerState();
|
|
1144
|
-
_clientLog(
|
|
1216
|
+
this._clientLog.info(`惰性同步 P2P: pull ${messages.length} 条消息, after_seq=${afterSeq}`);
|
|
1145
1217
|
}
|
|
1146
1218
|
}
|
|
1147
1219
|
catch (exc) {
|
|
1148
|
-
_clientLog(
|
|
1220
|
+
this._clientLog.warn(`惰性同步 P2P 失败: ${formatCaughtError(exc)}`);
|
|
1149
1221
|
}
|
|
1150
1222
|
}
|
|
1151
1223
|
_isGroupEpochTooOldError(exc) {
|
|
@@ -1201,10 +1273,10 @@ export class AUNClient {
|
|
|
1201
1273
|
encrypt: true,
|
|
1202
1274
|
persist_required: true,
|
|
1203
1275
|
});
|
|
1204
|
-
_clientLog(
|
|
1276
|
+
this._clientLog.info(`已向 ${targetAid} 请求群 ${groupId} 的 epoch ${epoch} 密钥`);
|
|
1205
1277
|
}
|
|
1206
1278
|
catch (exc) {
|
|
1207
|
-
_clientLog(
|
|
1279
|
+
this._clientLog.warn(`向 ${targetAid} 请求群 ${groupId} 密钥失败: ${formatCaughtError(exc)}`);
|
|
1208
1280
|
}
|
|
1209
1281
|
}
|
|
1210
1282
|
async _requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult) {
|
|
@@ -1219,7 +1291,7 @@ export class AUNClient {
|
|
|
1219
1291
|
const secretData = this._groupE2ee.loadSecret(groupId, 1);
|
|
1220
1292
|
if (!secretData || secretData.pending_rotation_id)
|
|
1221
1293
|
return epochResult;
|
|
1222
|
-
_clientLog(
|
|
1294
|
+
this._clientLog.warn(`群 ${groupId} 检测到本地 epoch 1 已存在但服务端 epoch 仍为 0,尝试补同步初始 epoch`);
|
|
1223
1295
|
await this._syncEpochToServer(groupId);
|
|
1224
1296
|
try {
|
|
1225
1297
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -1227,7 +1299,7 @@ export class AUNClient {
|
|
|
1227
1299
|
return refreshed;
|
|
1228
1300
|
}
|
|
1229
1301
|
catch (exc) {
|
|
1230
|
-
_clientLog(
|
|
1302
|
+
this._clientLog.warn(`群 ${groupId} 初始 epoch 补同步后刷新服务端 epoch 失败: ${formatCaughtError(exc)}`);
|
|
1231
1303
|
}
|
|
1232
1304
|
return epochResult;
|
|
1233
1305
|
}
|
|
@@ -1244,10 +1316,10 @@ export class AUNClient {
|
|
|
1244
1316
|
catch (exc) {
|
|
1245
1317
|
if (strict)
|
|
1246
1318
|
throw new StateError(`group ${groupId} failed to query server epoch before retry: ${formatCaughtError(exc)}`);
|
|
1247
|
-
_clientLog(
|
|
1319
|
+
this._clientLog.warn(`group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
|
|
1248
1320
|
return;
|
|
1249
1321
|
}
|
|
1250
|
-
let serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1322
|
+
let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1251
1323
|
if (!Number.isFinite(serverEpoch))
|
|
1252
1324
|
return;
|
|
1253
1325
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -1263,7 +1335,7 @@ export class AUNClient {
|
|
|
1263
1335
|
let effectiveLocalEpoch = initialLocalEpoch;
|
|
1264
1336
|
if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
|
|
1265
1337
|
epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
|
|
1266
|
-
serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1338
|
+
serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1267
1339
|
if (serverEpoch === 0) {
|
|
1268
1340
|
throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
|
|
1269
1341
|
}
|
|
@@ -1275,7 +1347,9 @@ export class AUNClient {
|
|
|
1275
1347
|
while (Date.now() < waitDeadline) {
|
|
1276
1348
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
1277
1349
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1278
|
-
const refreshedEpoch = isJsonObject(refreshed)
|
|
1350
|
+
const refreshedEpoch = isJsonObject(refreshed)
|
|
1351
|
+
? Number(refreshed.committed_epoch ?? refreshed.epoch ?? 0)
|
|
1352
|
+
: 0;
|
|
1279
1353
|
const currentLocal = await this._groupE2ee.currentEpoch(groupId);
|
|
1280
1354
|
if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
|
|
1281
1355
|
epochResult = refreshed;
|
|
@@ -1290,8 +1364,8 @@ export class AUNClient {
|
|
|
1290
1364
|
throw new StateError(`group ${groupId} epoch rotation has not completed`);
|
|
1291
1365
|
}
|
|
1292
1366
|
}
|
|
1293
|
-
_clientLog(
|
|
1294
|
-
await this.
|
|
1367
|
+
this._clientLog.warn(`group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
|
|
1368
|
+
await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
|
|
1295
1369
|
const deadline = Date.now() + 5000;
|
|
1296
1370
|
while (Date.now() < deadline) {
|
|
1297
1371
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
@@ -1314,7 +1388,7 @@ export class AUNClient {
|
|
|
1314
1388
|
members = isJsonObject(membersResult) ? membersResult.members : null;
|
|
1315
1389
|
}
|
|
1316
1390
|
catch (exc) {
|
|
1317
|
-
_clientLog(
|
|
1391
|
+
this._clientLog.debug(`群 ${groupId} 成员 epoch floor 预检跳过: ${formatCaughtError(exc)}`);
|
|
1318
1392
|
return;
|
|
1319
1393
|
}
|
|
1320
1394
|
let maxMinReadEpoch = 0;
|
|
@@ -1329,7 +1403,7 @@ export class AUNClient {
|
|
|
1329
1403
|
}
|
|
1330
1404
|
if (maxMinReadEpoch <= committedEpoch)
|
|
1331
1405
|
return;
|
|
1332
|
-
_clientLog(
|
|
1406
|
+
this._clientLog.warn(`群 ${groupId} 成员 min_read_epoch 高于 committed epoch,按 committed epoch 继续发送: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
|
|
1333
1407
|
return;
|
|
1334
1408
|
}
|
|
1335
1409
|
}
|
|
@@ -1340,7 +1414,7 @@ export class AUNClient {
|
|
|
1340
1414
|
return epochResult;
|
|
1341
1415
|
}
|
|
1342
1416
|
catch (exc) {
|
|
1343
|
-
_clientLog(
|
|
1417
|
+
this._clientLog.warn(`群 ${groupId} 查询 committed epoch 状态失败,回退本地 epoch: ${formatCaughtError(exc)}`);
|
|
1344
1418
|
}
|
|
1345
1419
|
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1346
1420
|
return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
|
|
@@ -1364,13 +1438,28 @@ export class AUNClient {
|
|
|
1364
1438
|
async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
|
|
1365
1439
|
if (committedEpoch <= 0)
|
|
1366
1440
|
return committedEpoch;
|
|
1367
|
-
|
|
1368
|
-
|
|
1441
|
+
let secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
1442
|
+
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
1443
|
+
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1444
|
+
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
1445
|
+
this._clientLog.warn(`群 ${groupId} committed epoch ${committedEpoch} 的成员快照与当前成员不一致,触发成员变更轮换修复`);
|
|
1446
|
+
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
1447
|
+
const refreshed = await this._committedGroupEpochState(groupId);
|
|
1448
|
+
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
1449
|
+
if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
|
|
1450
|
+
committedEpoch = refreshedCommittedEpoch;
|
|
1451
|
+
committedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
|
|
1452
|
+
secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
1453
|
+
}
|
|
1454
|
+
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1455
|
+
throw new StateError(`group ${groupId} committed membership is stale at epoch ${committedEpoch}; key rotation repair has not completed`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1369
1458
|
if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
|
|
1370
1459
|
return committedEpoch;
|
|
1371
1460
|
}
|
|
1372
1461
|
const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
|
|
1373
|
-
_clientLog(
|
|
1462
|
+
this._clientLog.warn(`群 ${groupId} epoch ${committedEpoch} 本地 pending key 未匹配服务端 committed rotation,先恢复密钥: local_rotation=${pendingRotationId || '-'}`);
|
|
1374
1463
|
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
1375
1464
|
let refreshed = await this._committedGroupEpochState(groupId);
|
|
1376
1465
|
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
@@ -1386,6 +1475,45 @@ export class AUNClient {
|
|
|
1386
1475
|
}
|
|
1387
1476
|
return committedEpoch;
|
|
1388
1477
|
}
|
|
1478
|
+
async _committedRotationMembershipGap(groupId, committedEpoch, committedRotation) {
|
|
1479
|
+
if (!this._aid || committedEpoch <= 0 || !committedRotation)
|
|
1480
|
+
return false;
|
|
1481
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
1482
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
|
|
1483
|
+
: [];
|
|
1484
|
+
if (expectedMembers.length === 0)
|
|
1485
|
+
return false;
|
|
1486
|
+
try {
|
|
1487
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
1488
|
+
const rawMembers = isJsonObject(membersResult)
|
|
1489
|
+
? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
|
|
1490
|
+
: [];
|
|
1491
|
+
if (!Array.isArray(rawMembers))
|
|
1492
|
+
return false;
|
|
1493
|
+
const activeMembers = rawMembers
|
|
1494
|
+
.filter((item) => isJsonObject(item))
|
|
1495
|
+
.map((item) => ({
|
|
1496
|
+
aid: String(item.aid ?? '').trim(),
|
|
1497
|
+
status: String(item.status ?? 'active').trim().toLowerCase(),
|
|
1498
|
+
}))
|
|
1499
|
+
.filter((item) => item.aid && ['', 'active'].includes(item.status))
|
|
1500
|
+
.map((item) => item.aid)
|
|
1501
|
+
.sort();
|
|
1502
|
+
if (!activeMembers.includes(this._aid) || activeMembers.length === 0)
|
|
1503
|
+
return false;
|
|
1504
|
+
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
1505
|
+
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
1506
|
+
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
1507
|
+
this._clientLog.info(`群 ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
catch (exc) {
|
|
1513
|
+
this._clientLog.debug(`查询当前成员失败,无法判断 committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1389
1517
|
// ── 客户端签名 ────────────────────────────────────────────
|
|
1390
1518
|
/**
|
|
1391
1519
|
* 为关键操作附加客户端 ECDSA 签名(client_signature 字段)。
|
|
@@ -1434,7 +1562,7 @@ export class AUNClient {
|
|
|
1434
1562
|
async _onRawMessageReceived(data) {
|
|
1435
1563
|
// 异步处理,不阻塞事件调度
|
|
1436
1564
|
this._processAndPublishMessage(data).catch((exc) => {
|
|
1437
|
-
_clientLog(
|
|
1565
|
+
this._clientLog.warn(`P2P 消息解密失败: ${formatCaughtError(exc)}`);
|
|
1438
1566
|
// H26: 不再投递原始密文 payload;改发 message.undecryptable 事件,仅携带安全 header
|
|
1439
1567
|
if (isJsonObject(data)) {
|
|
1440
1568
|
const safeEvent = {
|
|
@@ -1470,7 +1598,7 @@ export class AUNClient {
|
|
|
1470
1598
|
const ns = `p2p:${this._aid}`;
|
|
1471
1599
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1472
1600
|
if (needPull) {
|
|
1473
|
-
this._fillP2pGap().catch(exc => _clientLog(
|
|
1601
|
+
this._fillP2pGap().catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
|
|
1474
1602
|
}
|
|
1475
1603
|
// auto-ack contiguous_seq
|
|
1476
1604
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -1479,7 +1607,7 @@ export class AUNClient {
|
|
|
1479
1607
|
seq: contig,
|
|
1480
1608
|
device_id: this._deviceId,
|
|
1481
1609
|
slot_id: this._slotId,
|
|
1482
|
-
}).catch((e) => { _clientLog(
|
|
1610
|
+
}).catch((e) => { this._clientLog.debug(`P2P auto-ack 失败: ${formatCaughtError(e)}`); });
|
|
1483
1611
|
}
|
|
1484
1612
|
// 即时持久化 cursor,异常断连后不回退
|
|
1485
1613
|
this._saveSeqTrackerState();
|
|
@@ -1496,7 +1624,7 @@ export class AUNClient {
|
|
|
1496
1624
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1497
1625
|
async _onRawGroupMessageCreated(data) {
|
|
1498
1626
|
this._processAndPublishGroupMessage(data).catch((exc) => {
|
|
1499
|
-
_clientLog(
|
|
1627
|
+
this._clientLog.warn(`群消息解密失败: ${formatCaughtError(exc)}`);
|
|
1500
1628
|
// H26: 不再投递原始密文 payload;改发 group.message_undecryptable 事件
|
|
1501
1629
|
if (isJsonObject(data)) {
|
|
1502
1630
|
const safeEvent = {
|
|
@@ -1541,7 +1669,7 @@ export class AUNClient {
|
|
|
1541
1669
|
const ns = `group:${groupId}`;
|
|
1542
1670
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1543
1671
|
if (needPull) {
|
|
1544
|
-
this._fillGroupGap(groupId).catch(exc => _clientLog(
|
|
1672
|
+
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
|
|
1545
1673
|
}
|
|
1546
1674
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1547
1675
|
if (contig > 0) {
|
|
@@ -1550,7 +1678,7 @@ export class AUNClient {
|
|
|
1550
1678
|
msg_seq: contig,
|
|
1551
1679
|
device_id: this._deviceId,
|
|
1552
1680
|
slot_id: this._slotId,
|
|
1553
|
-
}).catch((e) => { _clientLog(
|
|
1681
|
+
}).catch((e) => { this._clientLog.debug(`群消息 auto-ack 失败: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1554
1682
|
}
|
|
1555
1683
|
this._saveSeqTrackerState();
|
|
1556
1684
|
}
|
|
@@ -1618,7 +1746,7 @@ export class AUNClient {
|
|
|
1618
1746
|
}
|
|
1619
1747
|
}
|
|
1620
1748
|
catch (exc) {
|
|
1621
|
-
_clientLog(
|
|
1749
|
+
this._clientLog.debug(`自动 pull 群消息失败: ${formatCaughtError(exc)}`);
|
|
1622
1750
|
}
|
|
1623
1751
|
await this._publishAppEvent('group.message_created', notification);
|
|
1624
1752
|
}
|
|
@@ -1661,7 +1789,7 @@ export class AUNClient {
|
|
|
1661
1789
|
}
|
|
1662
1790
|
}
|
|
1663
1791
|
catch (exc) {
|
|
1664
|
-
_clientLog(
|
|
1792
|
+
this._clientLog.warn(`群消息补洞失败: ${formatCaughtError(exc)}`);
|
|
1665
1793
|
}
|
|
1666
1794
|
finally {
|
|
1667
1795
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1706,7 +1834,7 @@ export class AUNClient {
|
|
|
1706
1834
|
}
|
|
1707
1835
|
}
|
|
1708
1836
|
catch (exc) {
|
|
1709
|
-
_clientLog(
|
|
1837
|
+
this._clientLog.warn(`P2P 消息补洞失败: ${formatCaughtError(exc)}`);
|
|
1710
1838
|
}
|
|
1711
1839
|
finally {
|
|
1712
1840
|
this._gapFillDone.delete(dedupKey);
|
|
@@ -1863,7 +1991,7 @@ export class AUNClient {
|
|
|
1863
1991
|
if (serverAck > 0) {
|
|
1864
1992
|
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1865
1993
|
if (contigBefore < serverAck) {
|
|
1866
|
-
_clientLog(
|
|
1994
|
+
this._clientLog.info(`group.pull_events retention-floor 推进: ns=${ns} contiguous=${contigBefore} -> cursor.current_seq=${serverAck}`);
|
|
1867
1995
|
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1868
1996
|
}
|
|
1869
1997
|
}
|
|
@@ -1876,7 +2004,7 @@ export class AUNClient {
|
|
|
1876
2004
|
event_seq: contig,
|
|
1877
2005
|
device_id: this._deviceId,
|
|
1878
2006
|
slot_id: this._slotId,
|
|
1879
|
-
}).catch((e) => { _clientLog(
|
|
2007
|
+
}).catch((e) => { this._clientLog.debug(`群事件 auto-ack 失败: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1880
2008
|
}
|
|
1881
2009
|
for (const evt of events) {
|
|
1882
2010
|
if (isJsonObject(evt)) {
|
|
@@ -1885,6 +2013,11 @@ export class AUNClient {
|
|
|
1885
2013
|
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
1886
2014
|
if (et === 'group.message_created')
|
|
1887
2015
|
continue;
|
|
2016
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2017
|
+
const cs = evt.client_signature;
|
|
2018
|
+
if (cs && typeof cs === 'object') {
|
|
2019
|
+
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
2020
|
+
}
|
|
1888
2021
|
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
1889
2022
|
await this._dispatcher.publish('group.changed', evt);
|
|
1890
2023
|
}
|
|
@@ -1893,43 +2026,12 @@ export class AUNClient {
|
|
|
1893
2026
|
}
|
|
1894
2027
|
}
|
|
1895
2028
|
catch (exc) {
|
|
1896
|
-
_clientLog(
|
|
2029
|
+
this._clientLog.warn(`群事件补洞失败: ${formatCaughtError(exc)}`);
|
|
1897
2030
|
}
|
|
1898
2031
|
finally {
|
|
1899
2032
|
this._gapFillDone.delete(dedupKey);
|
|
1900
2033
|
}
|
|
1901
2034
|
}
|
|
1902
|
-
/**
|
|
1903
|
-
* 上线/重连后一次性同步所有已加入群:
|
|
1904
|
-
* 1. 有 epoch key 的群 → 补消息 + 补事件
|
|
1905
|
-
* 2. 无 epoch key 的群 → 仅补事件(事件不加密,等推送触发密钥恢复)
|
|
1906
|
-
*/
|
|
1907
|
-
async _syncAllGroupsOnce() {
|
|
1908
|
-
try {
|
|
1909
|
-
const result = await this.call('group.list_my', {});
|
|
1910
|
-
if (!isJsonObject(result))
|
|
1911
|
-
return;
|
|
1912
|
-
const items = result.items;
|
|
1913
|
-
if (!Array.isArray(items))
|
|
1914
|
-
return;
|
|
1915
|
-
for (const g of items) {
|
|
1916
|
-
if (isJsonObject(g)) {
|
|
1917
|
-
const gid = String(g.group_id ?? '');
|
|
1918
|
-
if (gid) {
|
|
1919
|
-
// 有 epoch key → 补消息
|
|
1920
|
-
if (this._groupE2ee.hasSecret(gid)) {
|
|
1921
|
-
await this._fillGroupGap(gid);
|
|
1922
|
-
}
|
|
1923
|
-
// 所有群都补事件(事件不加密)
|
|
1924
|
-
await this._fillGroupEventGap(gid);
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
catch (exc) {
|
|
1930
|
-
_clientLog('debug', '批量同步群失败: %s', formatCaughtError(exc));
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
2035
|
/**
|
|
1934
2036
|
* 处理群组变更事件:透传给用户,并在成员离开/被踢时自动触发 epoch 轮换。
|
|
1935
2037
|
* 按协议,轮换由剩余在线 admin/owner 负责。
|
|
@@ -2081,12 +2183,12 @@ export class AUNClient {
|
|
|
2081
2183
|
event_seq: contig,
|
|
2082
2184
|
device_id: this._deviceId,
|
|
2083
2185
|
slot_id: this._slotId,
|
|
2084
|
-
}).catch((e) => { _clientLog(
|
|
2186
|
+
}).catch((e) => { this._clientLog.debug(`群事件推送 auto-ack 失败: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2085
2187
|
}
|
|
2086
2188
|
}
|
|
2087
2189
|
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
2088
2190
|
if (needPull && groupId && !d._from_gap_fill) {
|
|
2089
|
-
this._fillGroupEventGap(groupId).catch(exc => _clientLog(
|
|
2191
|
+
this._fillGroupEventGap(groupId).catch(exc => this._clientLog.warn(`后台补洞触发失败: ${formatCaughtError(exc)}`));
|
|
2090
2192
|
}
|
|
2091
2193
|
// 成员退出或被踢 → 剩余 admin/owner 自动补位轮换
|
|
2092
2194
|
// H21: 避免 epoch 轮换风暴——所有剩余 admin 同时收到事件不能都发起轮换,
|
|
@@ -2097,7 +2199,7 @@ export class AUNClient {
|
|
|
2097
2199
|
{
|
|
2098
2200
|
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2099
2201
|
if (expectedEpoch === null) {
|
|
2100
|
-
_clientLog(
|
|
2202
|
+
this._clientLog.debug(`membership event without old_epoch skipped for epoch rotation: aid=${this._aid ?? ''} group=${groupId} action=${String(d.action ?? '')} event_seq=${String(d.event_seq ?? '')}`);
|
|
2101
2203
|
}
|
|
2102
2204
|
else {
|
|
2103
2205
|
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
@@ -2105,15 +2207,38 @@ export class AUNClient {
|
|
|
2105
2207
|
}
|
|
2106
2208
|
}
|
|
2107
2209
|
}
|
|
2210
|
+
// 成员加入:按 action 区分策略
|
|
2211
|
+
// - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
|
|
2212
|
+
// - joined / invite_code_used(开放群/邀请码群):新成员先恢复 committed_epoch,延迟轮换
|
|
2108
2213
|
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
2109
2214
|
if (groupId) {
|
|
2110
2215
|
{
|
|
2216
|
+
const action = String(d.action ?? '');
|
|
2111
2217
|
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2112
|
-
|
|
2113
|
-
|
|
2218
|
+
const joinedAids = this._joinedMemberAidsFromPayload(d);
|
|
2219
|
+
const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
|
|
2220
|
+
this._clientLog.warn(`DEBUG: group.changed action=${action} groupId=${groupId} joinedAids=${JSON.stringify(joinedAids)} myAid=${this._aid} isSelfJoining=${String(isSelfJoining)} expectedEpoch=${String(expectedEpoch)}`);
|
|
2221
|
+
if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
|
|
2222
|
+
// open/invite_code 群:所有在线成员都参与延迟轮换
|
|
2223
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2224
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2225
|
+
if (!isSelfJoining) {
|
|
2226
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2227
|
+
}
|
|
2228
|
+
if (expectedEpoch !== null) {
|
|
2229
|
+
const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
|
|
2230
|
+
this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2231
|
+
}
|
|
2114
2232
|
}
|
|
2115
2233
|
else {
|
|
2116
|
-
|
|
2234
|
+
// member_added / join_approved:立即轮换
|
|
2235
|
+
if (expectedEpoch === null) {
|
|
2236
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2237
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2238
|
+
}
|
|
2239
|
+
else {
|
|
2240
|
+
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2241
|
+
}
|
|
2117
2242
|
}
|
|
2118
2243
|
}
|
|
2119
2244
|
}
|
|
@@ -2130,11 +2255,94 @@ export class AUNClient {
|
|
|
2130
2255
|
await this._dispatcher.publish('group.changed', data);
|
|
2131
2256
|
}
|
|
2132
2257
|
}
|
|
2258
|
+
/**
|
|
2259
|
+
* 处理 event/group.state_committed:验证 state_hash 链并更新本地存储。
|
|
2260
|
+
* 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
|
|
2261
|
+
*/
|
|
2262
|
+
async _onGroupStateCommitted(data) {
|
|
2263
|
+
if (!isJsonObject(data))
|
|
2264
|
+
return;
|
|
2265
|
+
const d = data;
|
|
2266
|
+
const groupId = String(d.group_id ?? '').trim();
|
|
2267
|
+
if (!groupId)
|
|
2268
|
+
return;
|
|
2269
|
+
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
2270
|
+
const cs = d.client_signature;
|
|
2271
|
+
if (cs && isJsonObject(cs)) {
|
|
2272
|
+
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2273
|
+
if (verified === false) {
|
|
2274
|
+
this._clientLog.warn(`state_committed 提交者签名验证失败 group=${groupId}`);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
d._verified = verified;
|
|
2278
|
+
}
|
|
2279
|
+
const stateVersion = Number(d.state_version ?? 0);
|
|
2280
|
+
const stateHash = String(d.state_hash ?? '').trim();
|
|
2281
|
+
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
2282
|
+
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
2283
|
+
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
2284
|
+
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
2285
|
+
// 1. 验证 prev_state_hash 连续性
|
|
2286
|
+
const loadFn = this._keystore.loadGroupState;
|
|
2287
|
+
const localState = loadFn ? loadFn.call(this._keystore, groupId) : null;
|
|
2288
|
+
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
2289
|
+
this._clientLog.warn(`state_hash 链不连续 group=${groupId} local_sv=${localState.state_version} event_sv=${stateVersion}`);
|
|
2290
|
+
// 回源同步
|
|
2291
|
+
try {
|
|
2292
|
+
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
2293
|
+
if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
|
|
2294
|
+
const sv = Number(serverState.state_version ?? 0);
|
|
2295
|
+
const sHash = String(serverState.state_hash ?? '');
|
|
2296
|
+
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
2297
|
+
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
2298
|
+
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
2299
|
+
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
2300
|
+
// 回源也做 hash 验证
|
|
2301
|
+
if (sMembersJson && sHash) {
|
|
2302
|
+
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
2303
|
+
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
2304
|
+
const computed = computeStateHash({
|
|
2305
|
+
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
2306
|
+
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
2307
|
+
});
|
|
2308
|
+
if (computed !== sHash) {
|
|
2309
|
+
this._clientLog.warn(`回源 state_hash 验证失败 group=${groupId} sv=${sv} expected=${sHash} got=${computed}`);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2314
|
+
if (saveFn) {
|
|
2315
|
+
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
catch (exc) {
|
|
2320
|
+
this._clientLog.warn(`state 回源失败 group=${groupId}: ${formatCaughtError(exc)}`);
|
|
2321
|
+
}
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
// 2. 本地重算验证
|
|
2325
|
+
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
2326
|
+
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
2327
|
+
const computed = computeStateHash({
|
|
2328
|
+
groupId, stateVersion, keyEpoch,
|
|
2329
|
+
members, policy, prevStateHash,
|
|
2330
|
+
});
|
|
2331
|
+
if (computed !== stateHash) {
|
|
2332
|
+
this._clientLog.warn(`state_hash 重算不匹配 group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
// 3. 更新本地存储
|
|
2336
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2337
|
+
if (saveFn) {
|
|
2338
|
+
saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2133
2341
|
/**
|
|
2134
2342
|
* 成员退出/被踢后,判断本地是否为 leader admin 并发起 epoch 轮换。
|
|
2135
2343
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
2136
2344
|
*/
|
|
2137
|
-
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
2345
|
+
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
2138
2346
|
const myAid = this._aid;
|
|
2139
2347
|
if (!myAid || this._closing || this._state !== 'connected')
|
|
2140
2348
|
return;
|
|
@@ -2145,7 +2353,7 @@ export class AUNClient {
|
|
|
2145
2353
|
if (this._closing || this._state !== 'connected')
|
|
2146
2354
|
return;
|
|
2147
2355
|
if (Date.now() - started > 20000) {
|
|
2148
|
-
_clientLog(
|
|
2356
|
+
this._clientLog.warn(`group epoch rotation still in-flight; skip pending trigger (group=${groupId} trigger=${triggerId || '-'})`);
|
|
2149
2357
|
return;
|
|
2150
2358
|
}
|
|
2151
2359
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
@@ -2163,24 +2371,46 @@ export class AUNClient {
|
|
|
2163
2371
|
if (!Array.isArray(rawList))
|
|
2164
2372
|
return;
|
|
2165
2373
|
const admins = [];
|
|
2374
|
+
const members = [];
|
|
2166
2375
|
for (const m of rawList) {
|
|
2167
2376
|
if (!isJsonObject(m))
|
|
2168
2377
|
continue;
|
|
2169
2378
|
const role = String(m.role ?? '');
|
|
2170
2379
|
const aid = String(m.aid ?? '');
|
|
2171
|
-
if (aid
|
|
2380
|
+
if (!aid)
|
|
2381
|
+
continue;
|
|
2382
|
+
if (role === 'admin' || role === 'owner') {
|
|
2172
2383
|
admins.push(aid);
|
|
2384
|
+
}
|
|
2385
|
+
else if (allowMember && role === 'member') {
|
|
2386
|
+
members.push(aid);
|
|
2387
|
+
}
|
|
2173
2388
|
}
|
|
2174
|
-
|
|
2389
|
+
// 候选列表:admin/owner 排序在前,member 排序在后
|
|
2390
|
+
let candidates = [...admins.sort(), ...members.sort()];
|
|
2391
|
+
if (candidates.length === 0)
|
|
2175
2392
|
return;
|
|
2176
|
-
|
|
2177
|
-
|
|
2393
|
+
// 没有当前 epoch key 的成员不参与 leader 选举。
|
|
2394
|
+
// open/invite_code 群排除后为空时保留自己兜底(从服务端取 prev chain)。
|
|
2395
|
+
if (expectedEpoch !== null && expectedEpoch > 0) {
|
|
2396
|
+
const localSecret = this._groupE2ee.loadSecret(groupId, expectedEpoch);
|
|
2397
|
+
if (!localSecret) {
|
|
2398
|
+
const filtered = candidates.filter(c => c !== myAid);
|
|
2399
|
+
if (filtered.length > 0) {
|
|
2400
|
+
candidates = filtered;
|
|
2401
|
+
}
|
|
2402
|
+
else if (!allowMember) {
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
const leader = candidates[0];
|
|
2178
2408
|
if (leader === myAid) {
|
|
2179
2409
|
// 我是 leader,直接发起
|
|
2180
2410
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2181
2411
|
return;
|
|
2182
2412
|
}
|
|
2183
|
-
if (!
|
|
2413
|
+
if (!candidates.includes(myAid))
|
|
2184
2414
|
return;
|
|
2185
2415
|
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
2186
2416
|
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
@@ -2218,11 +2448,11 @@ export class AUNClient {
|
|
|
2218
2448
|
});
|
|
2219
2449
|
return;
|
|
2220
2450
|
}
|
|
2221
|
-
_clientLog(
|
|
2451
|
+
this._clientLog.info(`[H21] leader 未完成 epoch 轮换,非 leader 兜底: group=${groupId} myAid=${myAid}`);
|
|
2222
2452
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2223
2453
|
}
|
|
2224
2454
|
catch (exc) {
|
|
2225
|
-
_clientLog(
|
|
2455
|
+
this._clientLog.warn(`_maybeLeadRotateGroupEpoch 失败: ${formatCaughtError(exc)}`);
|
|
2226
2456
|
}
|
|
2227
2457
|
finally {
|
|
2228
2458
|
this._groupEpochRotationInflight.delete(groupId);
|
|
@@ -2240,7 +2470,7 @@ export class AUNClient {
|
|
|
2240
2470
|
this._groupE2ee.removeGroup(groupId);
|
|
2241
2471
|
}
|
|
2242
2472
|
catch (exc) {
|
|
2243
|
-
_clientLog(
|
|
2473
|
+
this._clientLog.warn(`清理解散群组 ${groupId} epoch 密钥失败: ${formatCaughtError(exc)}`);
|
|
2244
2474
|
}
|
|
2245
2475
|
// 2. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
2246
2476
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -2257,7 +2487,7 @@ export class AUNClient {
|
|
|
2257
2487
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
2258
2488
|
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
2259
2489
|
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
2260
|
-
_clientLog(
|
|
2490
|
+
this._clientLog.info(`已清理解散群组 ${groupId} 的本地状态`);
|
|
2261
2491
|
}
|
|
2262
2492
|
/** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
|
|
2263
2493
|
/**
|
|
@@ -2292,7 +2522,7 @@ export class AUNClient {
|
|
|
2292
2522
|
if (expectedFP) {
|
|
2293
2523
|
const actualFP = 'sha256:' + certObj.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2294
2524
|
if (actualFP !== expectedFP) {
|
|
2295
|
-
_clientLog(
|
|
2525
|
+
this._clientLog.warn(`验签失败:证书指纹不匹配 aid=${sigAid}`);
|
|
2296
2526
|
return false;
|
|
2297
2527
|
}
|
|
2298
2528
|
}
|
|
@@ -2303,7 +2533,7 @@ export class AUNClient {
|
|
|
2303
2533
|
const pubKey = certObj.publicKey;
|
|
2304
2534
|
const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
|
|
2305
2535
|
if (!ok) {
|
|
2306
|
-
_clientLog(
|
|
2536
|
+
this._clientLog.warn(`群事件验签失败 aid=${sigAid} method=${method}`);
|
|
2307
2537
|
// P1-16: 签名失败统一发布事件
|
|
2308
2538
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2309
2539
|
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
@@ -2312,7 +2542,7 @@ export class AUNClient {
|
|
|
2312
2542
|
return ok;
|
|
2313
2543
|
}
|
|
2314
2544
|
catch (exc) {
|
|
2315
|
-
_clientLog(
|
|
2545
|
+
this._clientLog.warn(`群事件验签异常: ${formatCaughtError(exc)}`);
|
|
2316
2546
|
// P1-16: 签名失败统一发布事件
|
|
2317
2547
|
this._dispatcher.publish('signature.verification_failed', {
|
|
2318
2548
|
aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
|
|
@@ -2370,6 +2600,11 @@ export class AUNClient {
|
|
|
2370
2600
|
result = this._groupE2ee.handleIncoming(actualPayload);
|
|
2371
2601
|
if (result === 'distribution') {
|
|
2372
2602
|
await this._discardGroupDistributionIfStale(actualPayload);
|
|
2603
|
+
// 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
|
|
2604
|
+
const distGroupId = actualPayload.group_id;
|
|
2605
|
+
if (distGroupId && !this._groupSynced.has(distGroupId)) {
|
|
2606
|
+
this._lazySyncGroup(distGroupId).catch(() => { });
|
|
2607
|
+
}
|
|
2373
2608
|
}
|
|
2374
2609
|
// S14: 非控制面消息且 handleIncoming 不识别 → 不拦截
|
|
2375
2610
|
if (!isControlPlane && result === null)
|
|
@@ -2379,7 +2614,9 @@ export class AUNClient {
|
|
|
2379
2614
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2380
2615
|
const requester = String(actualPayload.requester_aid ?? '');
|
|
2381
2616
|
let members = this._groupE2ee.getMemberAids(groupId);
|
|
2382
|
-
//
|
|
2617
|
+
// 请求者不在本地成员列表时,回源查询服务端最新成员列表,
|
|
2618
|
+
// 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
|
|
2619
|
+
// (历史 epoch 的成员隔离由 handleKeyRequest 内部负责)。
|
|
2383
2620
|
if (requester && !members.includes(requester)) {
|
|
2384
2621
|
try {
|
|
2385
2622
|
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
@@ -2387,18 +2624,9 @@ export class AUNClient {
|
|
|
2387
2624
|
? membersResult.members
|
|
2388
2625
|
: [];
|
|
2389
2626
|
members = memberList.map((m) => String(m.aid));
|
|
2390
|
-
// 更新本地当前 epoch 的 member_aids/commitment
|
|
2391
|
-
if (members.includes(requester)) {
|
|
2392
|
-
const secretData = this._groupE2ee.loadSecret(groupId);
|
|
2393
|
-
if (secretData && this._aid) {
|
|
2394
|
-
const epoch = secretData.epoch;
|
|
2395
|
-
const commitment = computeMembershipCommitment(members, epoch, groupId, secretData.secret);
|
|
2396
|
-
storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, members);
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
2627
|
}
|
|
2400
2628
|
catch (exc) {
|
|
2401
|
-
_clientLog(
|
|
2629
|
+
this._clientLog.warn(`群组 ${groupId} 成员列表回源失败: ${formatCaughtError(exc)}`);
|
|
2402
2630
|
}
|
|
2403
2631
|
}
|
|
2404
2632
|
const response = this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
|
|
@@ -2412,7 +2640,7 @@ export class AUNClient {
|
|
|
2412
2640
|
});
|
|
2413
2641
|
}
|
|
2414
2642
|
catch (exc) {
|
|
2415
|
-
_clientLog(
|
|
2643
|
+
this._clientLog.warn(`向 ${requester} 回复群组密钥失败: ${formatCaughtError(exc)}`);
|
|
2416
2644
|
}
|
|
2417
2645
|
}
|
|
2418
2646
|
}
|
|
@@ -2423,7 +2651,7 @@ export class AUNClient {
|
|
|
2423
2651
|
const keyCommitment = String(actualPayload.commitment ?? '');
|
|
2424
2652
|
if (rotationId && keyCommitment) {
|
|
2425
2653
|
this._ackGroupRotationKey(rotationId, keyCommitment)
|
|
2426
|
-
.catch((exc) => _clientLog(
|
|
2654
|
+
.catch((exc) => this._clientLog.warn(`提交 epoch key ack 失败: ${formatCaughtError(exc)}`));
|
|
2427
2655
|
}
|
|
2428
2656
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2429
2657
|
}
|
|
@@ -2498,7 +2726,7 @@ export class AUNClient {
|
|
|
2498
2726
|
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2499
2727
|
}
|
|
2500
2728
|
catch (exc) {
|
|
2501
|
-
_clientLog(
|
|
2729
|
+
this._clientLog.error(`写入证书到 keystore 失败 (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
2502
2730
|
}
|
|
2503
2731
|
return certPem;
|
|
2504
2732
|
}
|
|
@@ -2531,7 +2759,7 @@ export class AUNClient {
|
|
|
2531
2759
|
if (normalized.length > 0) {
|
|
2532
2760
|
this._peerPrekeysCache.set(peerAid, {
|
|
2533
2761
|
items: normalized.map((item) => ({ ...item })),
|
|
2534
|
-
expireAt: Date.now() / 1000 +
|
|
2762
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2535
2763
|
});
|
|
2536
2764
|
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2537
2765
|
return normalized;
|
|
@@ -2546,7 +2774,7 @@ export class AUNClient {
|
|
|
2546
2774
|
if (normalized.length > 0) {
|
|
2547
2775
|
this._peerPrekeysCache.set(peerAid, {
|
|
2548
2776
|
items: normalized.map((item) => ({ ...item })),
|
|
2549
|
-
expireAt: Date.now() / 1000 +
|
|
2777
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2550
2778
|
});
|
|
2551
2779
|
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2552
2780
|
return normalized.map((item) => ({ ...item }));
|
|
@@ -2579,6 +2807,25 @@ export class AUNClient {
|
|
|
2579
2807
|
}
|
|
2580
2808
|
return { ...prekeys[0] };
|
|
2581
2809
|
}
|
|
2810
|
+
/** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
|
|
2811
|
+
_invalidatePeerPrekeyCache(peerAid) {
|
|
2812
|
+
this._peerPrekeysCache.delete(peerAid);
|
|
2813
|
+
this._e2ee.invalidatePrekeyCache(peerAid);
|
|
2814
|
+
}
|
|
2815
|
+
/** 清除对端证书缓存(精确匹配 aid 或 aid# 前缀的所有条目) */
|
|
2816
|
+
_clearPeerCertCache(peerAid) {
|
|
2817
|
+
for (const cacheKey of this._certCache.keys()) {
|
|
2818
|
+
if (cacheKey === peerAid || cacheKey.startsWith(`${peerAid}#`)) {
|
|
2819
|
+
this._certCache.delete(cacheKey);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
/** 清除对端所有缓存后重新拉取 prekey(用于指纹不匹配时的强制刷新) */
|
|
2824
|
+
async _refreshPeerPrekeys(peerAid) {
|
|
2825
|
+
this._invalidatePeerPrekeyCache(peerAid);
|
|
2826
|
+
this._clearPeerCertCache(peerAid);
|
|
2827
|
+
return await this._fetchPeerPrekeys(peerAid);
|
|
2828
|
+
}
|
|
2582
2829
|
/** 生成 prekey 并上传到服务端 */
|
|
2583
2830
|
async _uploadPrekey() {
|
|
2584
2831
|
const prekeyMaterial = this._e2ee.generatePrekey();
|
|
@@ -2630,10 +2877,10 @@ export class AUNClient {
|
|
|
2630
2877
|
catch (exc) {
|
|
2631
2878
|
// 刷新失败时:若内存缓存有 PKI 验证过的证书(未过期 x2 倍 TTL)则继续用
|
|
2632
2879
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2633
|
-
_clientLog(
|
|
2880
|
+
this._clientLog.debug(`刷新发送方 ${aid} 证书失败,继续使用已验证的内存缓存: ${formatCaughtError(exc)}`);
|
|
2634
2881
|
return true;
|
|
2635
2882
|
}
|
|
2636
|
-
_clientLog(
|
|
2883
|
+
this._clientLog.warn(`获取发送方 ${aid} 证书失败且无已验证缓存,拒绝信任: ${formatCaughtError(exc)}`);
|
|
2637
2884
|
return false;
|
|
2638
2885
|
}
|
|
2639
2886
|
}
|
|
@@ -2642,7 +2889,11 @@ export class AUNClient {
|
|
|
2642
2889
|
* 零信任:不直接信任 keystore 中可能由恶意服务端注入的证书。
|
|
2643
2890
|
*/
|
|
2644
2891
|
_getVerifiedPeerCert(aid, certFingerprint) {
|
|
2645
|
-
|
|
2892
|
+
let cached = this._certCache.get(AUNClient._certCacheKey(aid, certFingerprint));
|
|
2893
|
+
// 带 fingerprint 查不到时,降级用 aid 再查一次
|
|
2894
|
+
if (!cached && certFingerprint) {
|
|
2895
|
+
cached = this._certCache.get(AUNClient._certCacheKey(aid, undefined));
|
|
2896
|
+
}
|
|
2646
2897
|
const now = Date.now() / 1000;
|
|
2647
2898
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2648
2899
|
return cached.certPem;
|
|
@@ -2665,7 +2916,7 @@ export class AUNClient {
|
|
|
2665
2916
|
if (fromAid) {
|
|
2666
2917
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2667
2918
|
if (!certReady) {
|
|
2668
|
-
_clientLog(
|
|
2919
|
+
this._clientLog.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
|
|
2669
2920
|
throw new Error(`发送方证书不可用: from=${fromAid}, mid=${message.message_id}`);
|
|
2670
2921
|
}
|
|
2671
2922
|
}
|
|
@@ -2701,7 +2952,7 @@ export class AUNClient {
|
|
|
2701
2952
|
if (fromAid) {
|
|
2702
2953
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2703
2954
|
if (!certReady) {
|
|
2704
|
-
_clientLog(
|
|
2955
|
+
this._clientLog.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
|
|
2705
2956
|
continue;
|
|
2706
2957
|
}
|
|
2707
2958
|
}
|
|
@@ -2712,7 +2963,7 @@ export class AUNClient {
|
|
|
2712
2963
|
}
|
|
2713
2964
|
else {
|
|
2714
2965
|
// TS-015: 解密失败不回退到密文,跳过该消息并记录
|
|
2715
|
-
_clientLog(
|
|
2966
|
+
this._clientLog.warn(`pull 消息解密失败,跳过: from=${msg.from} mid=${msg.message_id}`);
|
|
2716
2967
|
}
|
|
2717
2968
|
}
|
|
2718
2969
|
else {
|
|
@@ -2765,7 +3016,7 @@ export class AUNClient {
|
|
|
2765
3016
|
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
2766
3017
|
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
2767
3018
|
return;
|
|
2768
|
-
this._retryPendingDecryptMsgs(groupId).catch((exc) => _clientLog(
|
|
3019
|
+
this._retryPendingDecryptMsgs(groupId).catch((exc) => this._clientLog.warn(`群 ${groupId} pending 消息重试失败: ${formatCaughtError(exc)}`));
|
|
2769
3020
|
}
|
|
2770
3021
|
async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
|
|
2771
3022
|
const existing = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
@@ -2783,7 +3034,254 @@ export class AUNClient {
|
|
|
2783
3034
|
this._groupEpochRecoveryInflight.set(key, promise);
|
|
2784
3035
|
return promise;
|
|
2785
3036
|
}
|
|
3037
|
+
static _extractGroupJoinMode(payload) {
|
|
3038
|
+
if (!isJsonObject(payload))
|
|
3039
|
+
return '';
|
|
3040
|
+
for (const key of ['join_mode', 'mode']) {
|
|
3041
|
+
const v = String(payload[key] ?? '').trim().toLowerCase();
|
|
3042
|
+
if (v)
|
|
3043
|
+
return v;
|
|
3044
|
+
}
|
|
3045
|
+
for (const key of ['join_requirements', 'join']) {
|
|
3046
|
+
const nested = payload[key];
|
|
3047
|
+
if (isJsonObject(nested)) {
|
|
3048
|
+
for (const nk of ['mode', 'join_mode']) {
|
|
3049
|
+
const v = String(nested[nk] ?? '').trim().toLowerCase();
|
|
3050
|
+
if (v)
|
|
3051
|
+
return v;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
if (isJsonObject(payload.group)) {
|
|
3056
|
+
const v = AUNClient._extractGroupJoinMode(payload.group);
|
|
3057
|
+
if (v)
|
|
3058
|
+
return v;
|
|
3059
|
+
}
|
|
3060
|
+
const settings = payload.settings;
|
|
3061
|
+
if (isJsonObject(settings)) {
|
|
3062
|
+
for (const key of ['join.mode', 'join_mode', 'mode']) {
|
|
3063
|
+
const v = String(settings[key] ?? '').trim().toLowerCase();
|
|
3064
|
+
if (v)
|
|
3065
|
+
return v;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
if (Array.isArray(settings)) {
|
|
3069
|
+
for (const item of settings) {
|
|
3070
|
+
if (!isJsonObject(item))
|
|
3071
|
+
continue;
|
|
3072
|
+
const k = String(item.key ?? item.name ?? '').trim().toLowerCase();
|
|
3073
|
+
if (k === 'join.mode' || k === 'join_mode' || k === 'mode') {
|
|
3074
|
+
const v = String(item.value ?? '').trim().toLowerCase();
|
|
3075
|
+
if (v)
|
|
3076
|
+
return v;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
return '';
|
|
3081
|
+
}
|
|
3082
|
+
static _joinModeAllowsMemberEpochRotation(mode) {
|
|
3083
|
+
const m = mode.trim().toLowerCase();
|
|
3084
|
+
return m === 'open' || m === 'invite_only' || m === 'invite_code';
|
|
3085
|
+
}
|
|
3086
|
+
async _groupAllowsMemberEpochRotation(groupId) {
|
|
3087
|
+
try {
|
|
3088
|
+
const resp = await this.call('group.get_join_requirements', { group_id: groupId });
|
|
3089
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
3090
|
+
if (mode)
|
|
3091
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
3092
|
+
}
|
|
3093
|
+
catch { /* best effort */ }
|
|
3094
|
+
try {
|
|
3095
|
+
const resp = await this.call('group.get_settings', { group_id: groupId, keys: ['join.mode'] });
|
|
3096
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
3097
|
+
if (mode)
|
|
3098
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
3099
|
+
}
|
|
3100
|
+
catch { /* best effort */ }
|
|
3101
|
+
try {
|
|
3102
|
+
const resp = await this.call('group.get', { group_id: groupId });
|
|
3103
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
3104
|
+
if (mode)
|
|
3105
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
3106
|
+
}
|
|
3107
|
+
catch { /* best effort */ }
|
|
3108
|
+
return false;
|
|
3109
|
+
}
|
|
3110
|
+
/** 尝试从服务端拉取 ECIES 加密的 epoch key 并解密存入 keystore */
|
|
3111
|
+
async _tryRecoverEpochKeyFromServer(groupId, epoch) {
|
|
3112
|
+
try {
|
|
3113
|
+
const params = { group_id: groupId };
|
|
3114
|
+
if (epoch > 0)
|
|
3115
|
+
params.epoch = epoch;
|
|
3116
|
+
const result = await this.call('group.e2ee.get_epoch_key', params);
|
|
3117
|
+
if (!isJsonObject(result))
|
|
3118
|
+
return false;
|
|
3119
|
+
const encryptedB64 = result.encrypted_key;
|
|
3120
|
+
if (!encryptedB64 || typeof encryptedB64 !== 'string')
|
|
3121
|
+
return false;
|
|
3122
|
+
const serverEpoch = Number(result.epoch ?? epoch);
|
|
3123
|
+
const encryptedBytes = Buffer.from(encryptedB64, 'base64');
|
|
3124
|
+
// 用自己的 AID 私钥 ECIES 解密
|
|
3125
|
+
const myAid = this._aid || '';
|
|
3126
|
+
const keyPair = this._keystore.loadKeyPair(myAid);
|
|
3127
|
+
if (!keyPair?.private_key_pem) {
|
|
3128
|
+
this._clientLog.warn(`无法加载 AID 私钥用于 ECIES 解密: aid=${myAid}`);
|
|
3129
|
+
return false;
|
|
3130
|
+
}
|
|
3131
|
+
const { eciesDecrypt } = await import('./e2ee-group.js');
|
|
3132
|
+
const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
|
|
3133
|
+
if (!groupSecret || groupSecret.length !== 32) {
|
|
3134
|
+
this._clientLog.warn(`服务端 epoch key ECIES 解密结果长度异常: group=${groupId} epoch=${serverEpoch} len=${groupSecret?.length ?? 0}`);
|
|
3135
|
+
return false;
|
|
3136
|
+
}
|
|
3137
|
+
// 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
|
|
3138
|
+
let memberAids = [];
|
|
3139
|
+
let committedRotation = null;
|
|
3140
|
+
let epochChain = '';
|
|
3141
|
+
try {
|
|
3142
|
+
const epochInfo = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
3143
|
+
if (isJsonObject(epochInfo)) {
|
|
3144
|
+
if (Array.isArray(epochInfo.members)) {
|
|
3145
|
+
memberAids = epochInfo.members
|
|
3146
|
+
.map((m) => {
|
|
3147
|
+
if (typeof m === 'string')
|
|
3148
|
+
return m;
|
|
3149
|
+
if (isJsonObject(m) && typeof m.aid === 'string')
|
|
3150
|
+
return m.aid;
|
|
3151
|
+
return '';
|
|
3152
|
+
})
|
|
3153
|
+
.filter((s) => s.length > 0);
|
|
3154
|
+
}
|
|
3155
|
+
if (isJsonObject(epochInfo.committed_rotation)) {
|
|
3156
|
+
committedRotation = epochInfo.committed_rotation;
|
|
3157
|
+
const rawChain = String(committedRotation.epoch_chain ?? '').trim();
|
|
3158
|
+
if (rawChain)
|
|
3159
|
+
epochChain = rawChain;
|
|
3160
|
+
// 如果有 expected_members,用它覆盖 memberAids
|
|
3161
|
+
if (Array.isArray(committedRotation.expected_members) && committedRotation.expected_members.length > 0) {
|
|
3162
|
+
memberAids = committedRotation.expected_members
|
|
3163
|
+
.map(item => String(item ?? '').trim())
|
|
3164
|
+
.filter(s => s.length > 0);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
catch { /* best effort */ }
|
|
3170
|
+
if (memberAids.length === 0) {
|
|
3171
|
+
this._clientLog.warn(`服务端 epoch key 恢复缺少成员快照: group=${groupId} epoch=${serverEpoch}`);
|
|
3172
|
+
return false;
|
|
3173
|
+
}
|
|
3174
|
+
const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
|
|
3175
|
+
// committed_rotation 存在时验证 commitment 和 epoch_chain
|
|
3176
|
+
let epochChainUnverified = null;
|
|
3177
|
+
let epochChainUnverifiedReason = null;
|
|
3178
|
+
if (committedRotation) {
|
|
3179
|
+
const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
|
|
3180
|
+
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3181
|
+
if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
|
|
3182
|
+
this._clientLog.warn(`服务端 epoch key 恢复 commitment 不匹配: group=${groupId} epoch=${serverEpoch}`);
|
|
3183
|
+
return false;
|
|
3184
|
+
}
|
|
3185
|
+
if (epochChain && committedEpoch === serverEpoch) {
|
|
3186
|
+
let rotatorAid = '';
|
|
3187
|
+
for (const key of ['rotated_by', 'lease_owner', 'committed_by']) {
|
|
3188
|
+
const v = String(committedRotation[key] ?? '').trim();
|
|
3189
|
+
if (v) {
|
|
3190
|
+
rotatorAid = v;
|
|
3191
|
+
break;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
const prevData = this._groupE2ee.loadSecret(groupId, serverEpoch - 1);
|
|
3195
|
+
const prevChain = String(prevData?.epoch_chain ?? '').trim();
|
|
3196
|
+
if (prevChain && rotatorAid) {
|
|
3197
|
+
if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
|
|
3198
|
+
this._clientLog.warn(`服务端 epoch key 恢复 epoch_chain 验证失败: group=${groupId} epoch=${serverEpoch} rotator=${rotatorAid}`);
|
|
3199
|
+
return false;
|
|
3200
|
+
}
|
|
3201
|
+
epochChainUnverified = false;
|
|
3202
|
+
}
|
|
3203
|
+
else {
|
|
3204
|
+
epochChainUnverified = true;
|
|
3205
|
+
epochChainUnverifiedReason = prevChain ? 'missing_rotator_aid' : 'missing_prev_chain';
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
|
|
3210
|
+
if (!stored) {
|
|
3211
|
+
this._clientLog.warn(`服务端 epoch key 恢复存储失败: group=${groupId} epoch=${serverEpoch}`);
|
|
3212
|
+
return false;
|
|
3213
|
+
}
|
|
3214
|
+
this._clientLog.info(`从服务端恢复 epoch key 成功: group=${groupId} epoch=${serverEpoch}`);
|
|
3215
|
+
return true;
|
|
3216
|
+
}
|
|
3217
|
+
catch (exc) {
|
|
3218
|
+
this._clientLog.debug(`从服务端恢复 epoch key 失败: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3219
|
+
return false;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
/** 为每个成员用其 AID 证书公钥 ECIES 加密 group_secret,返回 {aid: base64_ciphertext} */
|
|
3223
|
+
async _buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId) {
|
|
3224
|
+
try {
|
|
3225
|
+
const { eciesEncrypt } = await import('./e2ee-group.js');
|
|
3226
|
+
// 从 distribution payload 中提取 group_secret
|
|
3227
|
+
let groupSecretBytes = null;
|
|
3228
|
+
const distributions = Array.isArray(info.distributions) ? info.distributions : [];
|
|
3229
|
+
for (const dist of distributions) {
|
|
3230
|
+
if (isJsonObject(dist) && isJsonObject(dist.payload)) {
|
|
3231
|
+
const gsB64 = dist.payload.group_secret;
|
|
3232
|
+
if (typeof gsB64 === 'string' && gsB64.length > 0) {
|
|
3233
|
+
groupSecretBytes = Buffer.from(gsB64, 'base64');
|
|
3234
|
+
break;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
if (!groupSecretBytes) {
|
|
3239
|
+
// fallback: 从本地 keystore 加载
|
|
3240
|
+
const loaded = this._groupE2ee.loadSecret(groupId, targetEpoch);
|
|
3241
|
+
if (loaded?.secret) {
|
|
3242
|
+
groupSecretBytes = loaded.secret;
|
|
3243
|
+
}
|
|
3244
|
+
else {
|
|
3245
|
+
this._clientLog.debug(`无法获取 group_secret 用于 ECIES 加密: group=${groupId} epoch=${targetEpoch}`);
|
|
3246
|
+
return {};
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
const encryptedKeys = {};
|
|
3250
|
+
for (const aid of memberAids) {
|
|
3251
|
+
try {
|
|
3252
|
+
const certPem = await this._fetchPeerCert(aid);
|
|
3253
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
3254
|
+
const pubKey = x509Cert.publicKey;
|
|
3255
|
+
// 导出未压缩 EC 公钥点
|
|
3256
|
+
const jwk = pubKey.export({ format: 'jwk' });
|
|
3257
|
+
if (jwk.crv !== 'P-256' || !jwk.x || !jwk.y)
|
|
3258
|
+
continue;
|
|
3259
|
+
const xBuf = Buffer.from(jwk.x, 'base64url');
|
|
3260
|
+
const yBuf = Buffer.from(jwk.y, 'base64url');
|
|
3261
|
+
const pubkeyBytes = Buffer.concat([Buffer.from([0x04]), xBuf, yBuf]);
|
|
3262
|
+
const ciphertext = eciesEncrypt(pubkeyBytes, groupSecretBytes);
|
|
3263
|
+
encryptedKeys[aid] = ciphertext.toString('base64');
|
|
3264
|
+
}
|
|
3265
|
+
catch (exc) {
|
|
3266
|
+
this._clientLog.debug(`为成员 ${aid} 构建 ECIES epoch key 失败: ${formatCaughtError(exc)}`);
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return encryptedKeys;
|
|
3271
|
+
}
|
|
3272
|
+
catch (exc) {
|
|
3273
|
+
this._clientLog.debug(`构建 encrypted_keys 失败: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
3274
|
+
return {};
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
2786
3277
|
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
3278
|
+
// 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
|
|
3279
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3280
|
+
if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
|
|
3281
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3282
|
+
return true;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
2787
3285
|
let epochResult = { epoch };
|
|
2788
3286
|
try {
|
|
2789
3287
|
const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -2797,7 +3295,31 @@ export class AUNClient {
|
|
|
2797
3295
|
const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
|
|
2798
3296
|
epochResult.recovery_candidates = [senderAid, ...current];
|
|
2799
3297
|
}
|
|
2800
|
-
|
|
3298
|
+
// 在线优先恢复:先查在线成员列表,只向在线成员发送密钥请求
|
|
3299
|
+
let onlineAids = null;
|
|
3300
|
+
try {
|
|
3301
|
+
const onlineResp = await this.call('group.get_online_members', { group_id: groupId });
|
|
3302
|
+
if (isJsonObject(onlineResp)) {
|
|
3303
|
+
const rawMembers = Array.isArray(onlineResp.members) ? onlineResp.members
|
|
3304
|
+
: Array.isArray(onlineResp.items) ? onlineResp.items : [];
|
|
3305
|
+
onlineAids = rawMembers
|
|
3306
|
+
.filter((m) => isJsonObject(m) && m.online === true && String(m.aid ?? '') !== this._aid)
|
|
3307
|
+
.map(m => String(m.aid ?? ''));
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
catch {
|
|
3311
|
+
this._clientLog.debug(`群 ${groupId} 查询在线成员失败,回退全量候选`);
|
|
3312
|
+
}
|
|
3313
|
+
if (onlineAids !== null) {
|
|
3314
|
+
if (onlineAids.length === 0) {
|
|
3315
|
+
this._clientLog.info(`群 ${groupId} epoch ${String(epoch)} 恢复失败:无在线成员可请求密钥`);
|
|
3316
|
+
return false;
|
|
3317
|
+
}
|
|
3318
|
+
await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
|
|
3319
|
+
}
|
|
3320
|
+
else {
|
|
3321
|
+
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
3322
|
+
}
|
|
2801
3323
|
const deadline = Date.now() + timeoutMs;
|
|
2802
3324
|
while (Date.now() < deadline) {
|
|
2803
3325
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
@@ -2813,6 +3335,22 @@ export class AUNClient {
|
|
|
2813
3335
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2814
3336
|
return ready;
|
|
2815
3337
|
}
|
|
3338
|
+
/** 只向在线成员发送密钥恢复请求 */
|
|
3339
|
+
async _requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult) {
|
|
3340
|
+
const candidates = this._groupKeyRecoveryCandidates(groupId, epochResult);
|
|
3341
|
+
const ordered = [];
|
|
3342
|
+
for (const aid of candidates) {
|
|
3343
|
+
if (onlineAids.includes(aid) && !ordered.includes(aid))
|
|
3344
|
+
ordered.push(aid);
|
|
3345
|
+
}
|
|
3346
|
+
for (const aid of onlineAids) {
|
|
3347
|
+
if (!ordered.includes(aid))
|
|
3348
|
+
ordered.push(aid);
|
|
3349
|
+
}
|
|
3350
|
+
for (const aid of ordered) {
|
|
3351
|
+
await this._requestGroupKeyFrom(groupId, aid, epoch);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
2816
3354
|
async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
|
|
2817
3355
|
if (!isJsonObject(secret))
|
|
2818
3356
|
return false;
|
|
@@ -2855,7 +3393,7 @@ export class AUNClient {
|
|
|
2855
3393
|
if (senderAid) {
|
|
2856
3394
|
const certOk = await this._ensureSenderCertCached(senderAid);
|
|
2857
3395
|
if (!certOk) {
|
|
2858
|
-
_clientLog(
|
|
3396
|
+
this._clientLog.warn(`群消息解密跳过:发送方 ${senderAid} 证书不可用`);
|
|
2859
3397
|
return message;
|
|
2860
3398
|
}
|
|
2861
3399
|
}
|
|
@@ -2882,7 +3420,7 @@ export class AUNClient {
|
|
|
2882
3420
|
}
|
|
2883
3421
|
}
|
|
2884
3422
|
catch (exc) {
|
|
2885
|
-
_clientLog(
|
|
3423
|
+
this._clientLog.debug(`群 ${groupId} epoch ${epoch} 同步恢复失败: ${formatCaughtError(exc)}`);
|
|
2886
3424
|
}
|
|
2887
3425
|
}
|
|
2888
3426
|
return message;
|
|
@@ -2943,18 +3481,27 @@ export class AUNClient {
|
|
|
2943
3481
|
payload: payload ?? {},
|
|
2944
3482
|
created_at: Number(item.created_at ?? 0),
|
|
2945
3483
|
};
|
|
3484
|
+
if (isJsonObject(item.context))
|
|
3485
|
+
message.context = item.context;
|
|
2946
3486
|
const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
|
|
3487
|
+
let decryptFailed = false;
|
|
2947
3488
|
if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
|
|
2948
|
-
|
|
2949
|
-
|
|
3489
|
+
decryptFailed = true;
|
|
3490
|
+
// 安全网:触发 epoch key 恢复(内部有去重,重复调用安全)
|
|
3491
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
3492
|
+
if (epoch > 0) {
|
|
3493
|
+
this._recoverGroupEpochKey(groupId, epoch, senderAid, 5000).catch(() => { });
|
|
3494
|
+
}
|
|
2950
3495
|
}
|
|
2951
3496
|
const thought = {
|
|
2952
3497
|
thought_id: thoughtId,
|
|
2953
3498
|
message_id: thoughtId,
|
|
2954
|
-
payload: decrypted.payload,
|
|
3499
|
+
payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
|
|
2955
3500
|
created_at: item.created_at,
|
|
2956
3501
|
e2ee: decrypted.e2ee,
|
|
2957
3502
|
};
|
|
3503
|
+
if (decryptFailed)
|
|
3504
|
+
thought.decrypt_failed = true;
|
|
2958
3505
|
if ('context' in item)
|
|
2959
3506
|
thought.context = item.context;
|
|
2960
3507
|
thoughts.push(thought);
|
|
@@ -2985,19 +3532,25 @@ export class AUNClient {
|
|
|
2985
3532
|
encrypted: item.encrypted !== false,
|
|
2986
3533
|
timestamp: Number(item.created_at ?? 0),
|
|
2987
3534
|
};
|
|
3535
|
+
if (isJsonObject(item.context))
|
|
3536
|
+
message.context = item.context;
|
|
2988
3537
|
let decrypted = message;
|
|
3538
|
+
let decryptFailed = false;
|
|
2989
3539
|
if (payload?.type === 'e2ee.encrypted') {
|
|
2990
3540
|
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2991
3541
|
if (fromAid) {
|
|
2992
3542
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2993
3543
|
if (!certReady) {
|
|
2994
|
-
_clientLog(
|
|
2995
|
-
|
|
3544
|
+
this._clientLog.warn(`无法获取发送方 ${fromAid} 的证书,跳过 message.thought.get 解密`);
|
|
3545
|
+
decryptFailed = true;
|
|
2996
3546
|
}
|
|
2997
3547
|
}
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3548
|
+
if (!decryptFailed) {
|
|
3549
|
+
decrypted = this._e2ee._decryptMessage(message);
|
|
3550
|
+
if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
|
|
3551
|
+
decryptFailed = true;
|
|
3552
|
+
decrypted = message;
|
|
3553
|
+
}
|
|
3001
3554
|
}
|
|
3002
3555
|
}
|
|
3003
3556
|
const thought = {
|
|
@@ -3005,10 +3558,12 @@ export class AUNClient {
|
|
|
3005
3558
|
message_id: thoughtId,
|
|
3006
3559
|
from: fromAid,
|
|
3007
3560
|
to: toAid,
|
|
3008
|
-
payload: decrypted.payload,
|
|
3561
|
+
payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
|
|
3009
3562
|
created_at: item.created_at,
|
|
3010
|
-
e2ee: decrypted.e2ee,
|
|
3563
|
+
e2ee: decryptFailed ? undefined : decrypted.e2ee,
|
|
3011
3564
|
};
|
|
3565
|
+
if (decryptFailed)
|
|
3566
|
+
thought.decrypt_failed = true;
|
|
3012
3567
|
if ('context' in item)
|
|
3013
3568
|
thought.context = item.context;
|
|
3014
3569
|
thoughts.push(thought);
|
|
@@ -3071,7 +3626,7 @@ export class AUNClient {
|
|
|
3071
3626
|
}
|
|
3072
3627
|
else {
|
|
3073
3628
|
failed.push(String(dist.to));
|
|
3074
|
-
_clientLog(
|
|
3629
|
+
this._clientLog.warn(`epoch 密钥分发失败 (to=${dist.to}): ${formatCaughtError(exc)}`);
|
|
3075
3630
|
}
|
|
3076
3631
|
}
|
|
3077
3632
|
}
|
|
@@ -3089,7 +3644,7 @@ export class AUNClient {
|
|
|
3089
3644
|
return isJsonObject(result) && result.success === true;
|
|
3090
3645
|
}
|
|
3091
3646
|
catch (exc) {
|
|
3092
|
-
_clientLog(
|
|
3647
|
+
this._clientLog.warn(`刷新 epoch rotation lease 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3093
3648
|
return false;
|
|
3094
3649
|
}
|
|
3095
3650
|
}
|
|
@@ -3105,7 +3660,7 @@ export class AUNClient {
|
|
|
3105
3660
|
return isJsonObject(result) && result.success === true;
|
|
3106
3661
|
}
|
|
3107
3662
|
catch (exc) {
|
|
3108
|
-
_clientLog(
|
|
3663
|
+
this._clientLog.warn(`提交 epoch key ack 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3109
3664
|
return false;
|
|
3110
3665
|
}
|
|
3111
3666
|
}
|
|
@@ -3128,12 +3683,21 @@ export class AUNClient {
|
|
|
3128
3683
|
if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
|
|
3129
3684
|
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
3130
3685
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3131
|
-
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
3132
|
-
|
|
3686
|
+
if (committedCommitment && commitment && committedCommitment !== commitment) {
|
|
3687
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
3688
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3689
|
+
: [];
|
|
3690
|
+
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3691
|
+
this._clientLog.debug(`放行 group key 分发:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
|
|
3692
|
+
}
|
|
3693
|
+
else {
|
|
3694
|
+
return false;
|
|
3695
|
+
}
|
|
3696
|
+
}
|
|
3133
3697
|
}
|
|
3134
3698
|
return true;
|
|
3135
3699
|
}
|
|
3136
|
-
_clientLog(
|
|
3700
|
+
this._clientLog.info(`拒绝缺少 rotation_id 的未来 epoch key 分发: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3137
3701
|
return false;
|
|
3138
3702
|
}
|
|
3139
3703
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -3154,10 +3718,10 @@ export class AUNClient {
|
|
|
3154
3718
|
}
|
|
3155
3719
|
}
|
|
3156
3720
|
catch (exc) {
|
|
3157
|
-
_clientLog(
|
|
3721
|
+
this._clientLog.warn(`拒绝无法校验 active rotation 的 epoch key 分发: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3158
3722
|
return false;
|
|
3159
3723
|
}
|
|
3160
|
-
_clientLog(
|
|
3724
|
+
this._clientLog.info(`拒绝非 pending/committed 状态的 epoch key 分发: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
|
|
3161
3725
|
return false;
|
|
3162
3726
|
}
|
|
3163
3727
|
async _discardGroupDistributionIfStale(payload) {
|
|
@@ -3172,10 +3736,10 @@ export class AUNClient {
|
|
|
3172
3736
|
return;
|
|
3173
3737
|
try {
|
|
3174
3738
|
this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
|
|
3175
|
-
_clientLog(
|
|
3739
|
+
this._clientLog.info(`丢弃 verify 后变为 stale 的 group epoch key: group=${groupId} epoch=${epoch} rotation=${rotationId}`);
|
|
3176
3740
|
}
|
|
3177
3741
|
catch (exc) {
|
|
3178
|
-
_clientLog(
|
|
3742
|
+
this._clientLog.debug(`清理 stale group epoch key 失败: group=${groupId} epoch=${epoch} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3179
3743
|
}
|
|
3180
3744
|
}
|
|
3181
3745
|
async _verifyGroupKeyResponseEpoch(payload) {
|
|
@@ -3192,19 +3756,28 @@ export class AUNClient {
|
|
|
3192
3756
|
return false;
|
|
3193
3757
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
3194
3758
|
if (epoch > committedEpoch) {
|
|
3195
|
-
_clientLog(
|
|
3759
|
+
this._clientLog.info(`拒绝未提交 epoch 的 group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
3196
3760
|
return false;
|
|
3197
3761
|
}
|
|
3198
3762
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
3199
3763
|
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
3200
3764
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3201
|
-
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
3202
|
-
|
|
3765
|
+
if (committedCommitment && commitment && committedCommitment !== commitment) {
|
|
3766
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
3767
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3768
|
+
: [];
|
|
3769
|
+
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3770
|
+
this._clientLog.debug(`放行 group key response:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
|
|
3771
|
+
}
|
|
3772
|
+
else {
|
|
3773
|
+
return false;
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3203
3776
|
}
|
|
3204
3777
|
return true;
|
|
3205
3778
|
}
|
|
3206
3779
|
catch (exc) {
|
|
3207
|
-
_clientLog(
|
|
3780
|
+
this._clientLog.warn(`拒绝无法校验 committed epoch 的 group key response: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
3208
3781
|
return false;
|
|
3209
3782
|
}
|
|
3210
3783
|
}
|
|
@@ -3219,7 +3792,7 @@ export class AUNClient {
|
|
|
3219
3792
|
return isJsonObject(result) && result.success === true;
|
|
3220
3793
|
}
|
|
3221
3794
|
catch (exc) {
|
|
3222
|
-
_clientLog(
|
|
3795
|
+
this._clientLog.warn(`中止 epoch rotation 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
3223
3796
|
return false;
|
|
3224
3797
|
}
|
|
3225
3798
|
}
|
|
@@ -3253,7 +3826,7 @@ export class AUNClient {
|
|
|
3253
3826
|
if (this._closing || this._state !== 'connected')
|
|
3254
3827
|
return;
|
|
3255
3828
|
this._maybeLeadRotateGroupEpoch(groupId, opts.triggerId, opts.expectedEpoch)
|
|
3256
|
-
.catch((exc) => _clientLog(
|
|
3829
|
+
.catch((exc) => this._clientLog.warn(`group epoch rotation retry failed: ${formatCaughtError(exc)}`));
|
|
3257
3830
|
}, this._rotationRetryDelayMs(opts.pending));
|
|
3258
3831
|
this._groupEpochRotationRetryTimers.set(retryKey, timer);
|
|
3259
3832
|
this._unrefTimer(timer);
|
|
@@ -3265,7 +3838,7 @@ export class AUNClient {
|
|
|
3265
3838
|
if (this._closing || this._state !== 'connected')
|
|
3266
3839
|
return;
|
|
3267
3840
|
if (Date.now() - started > 20000) {
|
|
3268
|
-
_clientLog(
|
|
3841
|
+
this._clientLog.warn(`group epoch create sync still in-flight; skip duplicate sync (group=${groupId})`);
|
|
3269
3842
|
return;
|
|
3270
3843
|
}
|
|
3271
3844
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
@@ -3298,31 +3871,39 @@ export class AUNClient {
|
|
|
3298
3871
|
const beginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
|
|
3299
3872
|
const rotation = isJsonObject(beginResult) && isJsonObject(beginResult.rotation) ? beginResult.rotation : null;
|
|
3300
3873
|
if (!isJsonObject(beginResult) || beginResult.success !== true || !rotation) {
|
|
3301
|
-
_clientLog(
|
|
3874
|
+
this._clientLog.warn(`group epoch begin failed; stop key distribution (group=${groupId}, returned=${JSON.stringify(beginResult)})`);
|
|
3302
3875
|
return;
|
|
3303
3876
|
}
|
|
3304
3877
|
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
3305
3878
|
if (!await this._ackGroupRotationKey(activeRotationId, secretData.commitment)) {
|
|
3306
|
-
_clientLog(
|
|
3879
|
+
this._clientLog.warn(`group epoch self ack failed (group=${groupId}, rotation=${activeRotationId})`);
|
|
3307
3880
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3308
3881
|
return;
|
|
3309
3882
|
}
|
|
3310
|
-
const
|
|
3883
|
+
const commitParams2 = { rotation_id: activeRotationId };
|
|
3884
|
+
const createMembers = secretData.member_aids.length > 0 ? secretData.member_aids : (this._aid ? [this._aid] : []);
|
|
3885
|
+
const encKeys2 = await this._buildEpochEncryptedKeys({ distributions: [{ payload: { group_secret: secretData.secret.toString('base64') } }] }, createMembers, 1, groupId);
|
|
3886
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3887
|
+
if (encKeys2 && Object.keys(encKeys2).length > 0) {
|
|
3888
|
+
commitParams2.encrypted_keys = encKeys2;
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams2);
|
|
3311
3892
|
if (isJsonObject(commitResult) && commitResult.success === true) {
|
|
3312
3893
|
storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
|
|
3313
3894
|
return;
|
|
3314
3895
|
}
|
|
3315
|
-
_clientLog(
|
|
3896
|
+
this._clientLog.warn(`group epoch commit failed (group=${groupId}, returned=${JSON.stringify(commitResult)})`);
|
|
3316
3897
|
return;
|
|
3317
3898
|
}
|
|
3318
3899
|
catch (exc) {
|
|
3319
3900
|
if (attempt < maxRetries) {
|
|
3320
3901
|
const delay = 500 * Math.pow(2, attempt - 1);
|
|
3321
|
-
_clientLog(
|
|
3902
|
+
this._clientLog.warn(`同步 epoch 到服务端失败 (group=${groupId}, 第${attempt}/${maxRetries}次): ${formatCaughtError(exc)}, ${delay}ms后重试`);
|
|
3322
3903
|
await new Promise(r => setTimeout(r, delay));
|
|
3323
3904
|
}
|
|
3324
3905
|
else {
|
|
3325
|
-
_clientLog(
|
|
3906
|
+
this._clientLog.error(`同步 epoch 到服务端最终失败 (group=${groupId}, 已重试${maxRetries}次): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3326
3907
|
}
|
|
3327
3908
|
}
|
|
3328
3909
|
}
|
|
@@ -3353,7 +3934,7 @@ export class AUNClient {
|
|
|
3353
3934
|
&& serverEpoch === expectedEpoch
|
|
3354
3935
|
&& this._rotationExpectedMembersStale(pendingRotation, memberAids));
|
|
3355
3936
|
if (stalePending && await this._abortGroupRotation(pendingRotationId, 'membership_changed_during_rotation')) {
|
|
3356
|
-
_clientLog(
|
|
3937
|
+
this._clientLog.info(`aborted stale pending group epoch rotation: group=${groupId} rotation=${pendingRotationId || '-'}`);
|
|
3357
3938
|
}
|
|
3358
3939
|
else {
|
|
3359
3940
|
this._scheduleGroupRotationRetry(groupId, {
|
|
@@ -3368,20 +3949,35 @@ export class AUNClient {
|
|
|
3368
3949
|
if (expectedEpoch !== null && serverEpoch !== expectedEpoch) {
|
|
3369
3950
|
if (triggerId)
|
|
3370
3951
|
this._groupMembershipRotationDone.add(triggerId);
|
|
3371
|
-
_clientLog(
|
|
3952
|
+
this._clientLog.info(`skip membership epoch rotation: group=${groupId} expected_epoch=${expectedEpoch} server_epoch=${serverEpoch} trigger=${triggerId || '-'}`);
|
|
3372
3953
|
return;
|
|
3373
3954
|
}
|
|
3374
3955
|
const currentEpoch = expectedEpoch ?? serverEpoch;
|
|
3375
3956
|
const targetEpoch = currentEpoch + 1;
|
|
3957
|
+
// 新成员可能没有 prev epoch key,或有 key 但缺少 epoch_chain(通过 backfill 接收)。
|
|
3958
|
+
// 从 committed_rotation.epoch_chain 获取 prev chain hint。
|
|
3959
|
+
let prevChainHint = null;
|
|
3960
|
+
const localPrev = this._groupE2ee.loadSecret(groupId, currentEpoch);
|
|
3961
|
+
const localPrevChain = String(localPrev?.epoch_chain ?? '');
|
|
3962
|
+
if (!localPrevChain && isJsonObject(epochResult)) {
|
|
3963
|
+
const cr = epochResult.committed_rotation;
|
|
3964
|
+
if (isJsonObject(cr)) {
|
|
3965
|
+
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
3966
|
+
if (rawChain) {
|
|
3967
|
+
prevChainHint = rawChain;
|
|
3968
|
+
this._clientLog.info(`新成员轮换补充 prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3376
3972
|
const rotationId = `rot-${crypto.randomUUID().replace(/-/g, '')}`;
|
|
3377
|
-
const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
|
|
3973
|
+
const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
|
|
3378
3974
|
this._attachRotationId(info, rotationId);
|
|
3379
3975
|
const discardGeneratedPending = () => {
|
|
3380
3976
|
try {
|
|
3381
3977
|
this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
|
|
3382
3978
|
}
|
|
3383
3979
|
catch (cleanupExc) {
|
|
3384
|
-
_clientLog(
|
|
3980
|
+
this._clientLog.debug(`清理本地 pending group key 失败: group=${groupId} epoch=${targetEpoch} rotation=${rotationId} err=${formatCaughtError(cleanupExc)}`);
|
|
3385
3981
|
}
|
|
3386
3982
|
};
|
|
3387
3983
|
const rotateParams = {
|
|
@@ -3435,14 +4031,14 @@ export class AUNClient {
|
|
|
3435
4031
|
pending: null,
|
|
3436
4032
|
});
|
|
3437
4033
|
}
|
|
3438
|
-
_clientLog(
|
|
4034
|
+
this._clientLog.warn(`group epoch begin failed; stop key distribution (group=${groupId}, current_epoch=${currentEpoch}, returned=${JSON.stringify(beginResult)})`);
|
|
3439
4035
|
discardGeneratedPending();
|
|
3440
4036
|
return;
|
|
3441
4037
|
}
|
|
3442
4038
|
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
3443
4039
|
const distributeResult = await this._distributeGroupEpochKey(info, activeRotationId);
|
|
3444
4040
|
if (distributeResult.failed.length > 0) {
|
|
3445
|
-
_clientLog(
|
|
4041
|
+
this._clientLog.warn(`group epoch key distribution incomplete; abort rotation before retry (group=${groupId} rotation=${activeRotationId} failed=${distributeResult.failed.join(',')})`);
|
|
3446
4042
|
await this._abortGroupRotation(activeRotationId, 'distribution_failed');
|
|
3447
4043
|
this._scheduleGroupRotationRetry(groupId, {
|
|
3448
4044
|
reason: 'membership_changed',
|
|
@@ -3455,7 +4051,7 @@ export class AUNClient {
|
|
|
3455
4051
|
}
|
|
3456
4052
|
await this._heartbeatGroupRotation(activeRotationId);
|
|
3457
4053
|
if (!await this._ackGroupRotationKey(activeRotationId, String(info.commitment ?? ''))) {
|
|
3458
|
-
_clientLog(
|
|
4054
|
+
this._clientLog.warn(`group epoch self ack failed; abort rotation before retry (group=${groupId} rotation=${activeRotationId})`);
|
|
3459
4055
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3460
4056
|
this._scheduleGroupRotationRetry(groupId, {
|
|
3461
4057
|
reason: 'membership_changed',
|
|
@@ -3466,9 +4062,17 @@ export class AUNClient {
|
|
|
3466
4062
|
discardGeneratedPending();
|
|
3467
4063
|
return;
|
|
3468
4064
|
}
|
|
3469
|
-
const
|
|
4065
|
+
const commitParams = { rotation_id: activeRotationId };
|
|
4066
|
+
// 构建 per-member ECIES 加密的 epoch key 上传到服务端
|
|
4067
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
4068
|
+
const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
|
|
4069
|
+
if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
|
|
4070
|
+
commitParams.encrypted_keys = encryptedKeys;
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
|
|
3470
4074
|
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
3471
|
-
_clientLog(
|
|
4075
|
+
this._clientLog.warn(`group epoch commit failed (group=${groupId}, rotation=${activeRotationId}, returned=${JSON.stringify(commitResult)})`);
|
|
3472
4076
|
this._scheduleGroupRotationRetry(groupId, {
|
|
3473
4077
|
reason: 'membership_changed',
|
|
3474
4078
|
triggerId,
|
|
@@ -3492,7 +4096,7 @@ export class AUNClient {
|
|
|
3492
4096
|
storeGroupSecret(this._keystore, this._aid, groupId, targetEpoch, committedSecret.secret, committedSecret.commitment, committedSecret.member_aids.length > 0 ? committedSecret.member_aids : memberAids, committedSecret.epoch_chain);
|
|
3493
4097
|
}
|
|
3494
4098
|
else {
|
|
3495
|
-
_clientLog(
|
|
4099
|
+
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})`);
|
|
3496
4100
|
}
|
|
3497
4101
|
}
|
|
3498
4102
|
if (triggerId) {
|
|
@@ -3534,7 +4138,7 @@ export class AUNClient {
|
|
|
3534
4138
|
if (identity && identity.private_key_pem) {
|
|
3535
4139
|
manifest = signMembershipManifest(manifest, String(identity.private_key_pem));
|
|
3536
4140
|
}
|
|
3537
|
-
const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest);
|
|
4141
|
+
const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest, String(secretData.epoch_chain ?? ''));
|
|
3538
4142
|
// 重试 3 次,间隔递增(1s, 2s)
|
|
3539
4143
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3540
4144
|
try {
|
|
@@ -3560,7 +4164,79 @@ export class AUNClient {
|
|
|
3560
4164
|
this._logE2eeError('distribute_key', groupId, newMemberAid, exc);
|
|
3561
4165
|
}
|
|
3562
4166
|
}
|
|
3563
|
-
/**
|
|
4167
|
+
/** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
|
|
4168
|
+
_joinedMemberAidsFromPayload(payload) {
|
|
4169
|
+
const aids = new Set();
|
|
4170
|
+
const addAid = (value) => {
|
|
4171
|
+
const aid = String(value ?? '').trim();
|
|
4172
|
+
if (aid)
|
|
4173
|
+
aids.add(aid);
|
|
4174
|
+
};
|
|
4175
|
+
addAid(payload.aid ?? payload.applicant_aid ?? payload.applicantAid);
|
|
4176
|
+
addAid(payload.actor_aid);
|
|
4177
|
+
for (const key of ['member_aid', 'target_aid', 'new_member_aid', 'used_by']) {
|
|
4178
|
+
addAid(payload[key]);
|
|
4179
|
+
}
|
|
4180
|
+
for (const key of ['member', 'request', 'invite_code']) {
|
|
4181
|
+
const nested = isJsonObject(payload[key]) ? payload[key] : null;
|
|
4182
|
+
if (!nested)
|
|
4183
|
+
continue;
|
|
4184
|
+
addAid(nested.aid ?? nested.applicant_aid ?? nested.applicantAid);
|
|
4185
|
+
for (const nk of ['member_aid', 'target_aid', 'used_by'])
|
|
4186
|
+
addAid(nested[nk]);
|
|
4187
|
+
}
|
|
4188
|
+
if (Array.isArray(payload.results)) {
|
|
4189
|
+
for (const item of payload.results) {
|
|
4190
|
+
if (!isJsonObject(item))
|
|
4191
|
+
continue;
|
|
4192
|
+
const obj = item;
|
|
4193
|
+
const status = String(obj.status ?? '').trim().toLowerCase();
|
|
4194
|
+
if (status !== 'approved' && obj.approved !== true)
|
|
4195
|
+
continue;
|
|
4196
|
+
addAid(obj.aid ?? obj.applicant_aid ?? obj.applicantAid);
|
|
4197
|
+
for (const key of ['member_aid', 'target_aid'])
|
|
4198
|
+
addAid(obj[key]);
|
|
4199
|
+
for (const key of ['member', 'request']) {
|
|
4200
|
+
const nested = isJsonObject(obj[key]) ? obj[key] : null;
|
|
4201
|
+
if (!nested)
|
|
4202
|
+
continue;
|
|
4203
|
+
addAid(nested.aid ?? nested.applicant_aid);
|
|
4204
|
+
for (const nk of ['member_aid', 'target_aid'])
|
|
4205
|
+
addAid(nested[nk]);
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
return Array.from(aids);
|
|
4210
|
+
}
|
|
4211
|
+
// ── 入群密钥恢复策略 ──────────────────────────────────────
|
|
4212
|
+
/** 延迟轮换等待时间(毫秒):给新成员恢复 committed_epoch 的窗口 */
|
|
4213
|
+
static _JOIN_ROTATION_DELAY_MS = 3000;
|
|
4214
|
+
// 新成员自身延迟轮换时间:优先让其他在线成员先轮换
|
|
4215
|
+
static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
|
|
4216
|
+
/** open/invite_code 入群后延迟轮换。 */
|
|
4217
|
+
async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
|
|
4218
|
+
await new Promise(resolve => setTimeout(resolve, delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS));
|
|
4219
|
+
await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
|
|
4220
|
+
}
|
|
4221
|
+
/** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
|
|
4222
|
+
async _maybeBackfillKeyToJoinedMember(groupId, payload, triggerId = '') {
|
|
4223
|
+
const memberAids = this._joinedMemberAidsFromPayload(payload)
|
|
4224
|
+
.filter(aid => aid && aid !== this._aid);
|
|
4225
|
+
if (!groupId || !this._aid || memberAids.length === 0)
|
|
4226
|
+
return;
|
|
4227
|
+
if (!this._groupE2ee.hasSecret(groupId))
|
|
4228
|
+
return;
|
|
4229
|
+
for (const memberAid of memberAids) {
|
|
4230
|
+
const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
|
|
4231
|
+
if (this._groupMemberKeyBackfillDone.has(dedupeKey))
|
|
4232
|
+
continue;
|
|
4233
|
+
this._groupMemberKeyBackfillDone.add(dedupeKey);
|
|
4234
|
+
if (this._groupMemberKeyBackfillDone.size > 2000) {
|
|
4235
|
+
this._groupMemberKeyBackfillDone = new Set(Array.from(this._groupMemberKeyBackfillDone).slice(-1000));
|
|
4236
|
+
}
|
|
4237
|
+
await this._distributeKeyToNewMember(groupId, memberAid);
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
3564
4240
|
_buildRotationSignature(groupId, currentEpoch, newEpoch = 0, source) {
|
|
3565
4241
|
const identity = this._identity;
|
|
3566
4242
|
if (!identity || !identity.private_key_pem) {
|
|
@@ -3613,8 +4289,9 @@ export class AUNClient {
|
|
|
3613
4289
|
// 优先从 seq_tracker 表按行读取
|
|
3614
4290
|
const loadAll = this._keystore.loadAllSeqs;
|
|
3615
4291
|
if (typeof loadAll === 'function') {
|
|
3616
|
-
|
|
4292
|
+
let state = loadAll.call(this._keystore, this._aid, this._deviceId, this._slotId);
|
|
3617
4293
|
if (state && Object.keys(state).length > 0) {
|
|
4294
|
+
state = this._migrateSeqStateGroupIds(state);
|
|
3618
4295
|
this._seqTracker.restoreState(state);
|
|
3619
4296
|
return;
|
|
3620
4297
|
}
|
|
@@ -3624,12 +4301,14 @@ export class AUNClient {
|
|
|
3624
4301
|
if (typeof loader === 'function') {
|
|
3625
4302
|
const instanceState = loader.call(this._keystore, this._aid, this._deviceId, this._slotId);
|
|
3626
4303
|
if (instanceState && typeof instanceState.seq_tracker_state === 'object') {
|
|
3627
|
-
|
|
4304
|
+
let state = instanceState.seq_tracker_state;
|
|
4305
|
+
state = this._migrateSeqStateGroupIds(state);
|
|
4306
|
+
this._seqTracker.restoreState(state);
|
|
3628
4307
|
}
|
|
3629
4308
|
}
|
|
3630
4309
|
}
|
|
3631
4310
|
catch (exc) {
|
|
3632
|
-
_clientLog(
|
|
4311
|
+
this._clientLog.warn(`恢复 SeqTracker 状态失败: ${formatCaughtError(exc)}`);
|
|
3633
4312
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
3634
4313
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3635
4314
|
phase: 'restore',
|
|
@@ -3640,6 +4319,59 @@ export class AUNClient {
|
|
|
3640
4319
|
}).catch(() => { });
|
|
3641
4320
|
}
|
|
3642
4321
|
}
|
|
4322
|
+
/**
|
|
4323
|
+
* 把 seq_tracker state 里 group_event:/group_msg: 前缀的老/污染 group_id 归一化为 canonical。
|
|
4324
|
+
* 冲突取 max。同时落盘删除老 ns、写入新 ns,避免下次启动重复迁移。
|
|
4325
|
+
*/
|
|
4326
|
+
_migrateSeqStateGroupIds(state) {
|
|
4327
|
+
if (!state || Object.keys(state).length === 0)
|
|
4328
|
+
return state;
|
|
4329
|
+
const renameMap = {};
|
|
4330
|
+
for (const ns of Object.keys(state)) {
|
|
4331
|
+
for (const prefix of ['group_event:', 'group_msg:']) {
|
|
4332
|
+
if (ns.startsWith(prefix)) {
|
|
4333
|
+
const oldGid = ns.slice(prefix.length);
|
|
4334
|
+
const newGid = normalizeGroupId(oldGid);
|
|
4335
|
+
if (newGid && newGid !== oldGid) {
|
|
4336
|
+
renameMap[ns] = `${prefix}${newGid}`;
|
|
4337
|
+
}
|
|
4338
|
+
break;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
if (Object.keys(renameMap).length === 0)
|
|
4343
|
+
return state;
|
|
4344
|
+
const newState = { ...state };
|
|
4345
|
+
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
4346
|
+
const oldVal = Number(newState[oldNs] ?? 0);
|
|
4347
|
+
const curVal = Number(newState[newNs] ?? 0);
|
|
4348
|
+
delete newState[oldNs];
|
|
4349
|
+
newState[newNs] = Math.max(oldVal, curVal);
|
|
4350
|
+
}
|
|
4351
|
+
this._clientLog.info(`SeqTracker group_id 迁移:${Object.keys(renameMap).length} 个命名空间重写`);
|
|
4352
|
+
// 落盘
|
|
4353
|
+
const saver = this._keystore.saveSeq;
|
|
4354
|
+
const deleter = this._keystore.deleteSeq;
|
|
4355
|
+
if (typeof saver === 'function' && this._aid) {
|
|
4356
|
+
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
4357
|
+
if (typeof deleter === 'function') {
|
|
4358
|
+
try {
|
|
4359
|
+
deleter.call(this._keystore, this._aid, this._deviceId, this._slotId, oldNs);
|
|
4360
|
+
}
|
|
4361
|
+
catch (e) {
|
|
4362
|
+
this._clientLog.debug(`删除旧 seq ns 失败: ns=${oldNs} err=${formatCaughtError(e)}`);
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
try {
|
|
4366
|
+
saver.call(this._keystore, this._aid, this._deviceId, this._slotId, newNs, newState[newNs]);
|
|
4367
|
+
}
|
|
4368
|
+
catch (e) {
|
|
4369
|
+
this._clientLog.debug(`写入新 seq ns 失败: ns=${newNs} err=${formatCaughtError(e)}`);
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
return newState;
|
|
4374
|
+
}
|
|
3643
4375
|
_currentSeqTrackerContext() {
|
|
3644
4376
|
if (!this._aid)
|
|
3645
4377
|
return null;
|
|
@@ -3694,7 +4426,7 @@ export class AUNClient {
|
|
|
3694
4426
|
}
|
|
3695
4427
|
}
|
|
3696
4428
|
catch (exc) {
|
|
3697
|
-
_clientLog(
|
|
4429
|
+
this._clientLog.warn(`保存 SeqTracker 状态失败: ${formatCaughtError(exc)}`);
|
|
3698
4430
|
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
3699
4431
|
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3700
4432
|
phase: 'save',
|
|
@@ -3776,8 +4508,8 @@ export class AUNClient {
|
|
|
3776
4508
|
if (identity && isJsonObject(identity)) {
|
|
3777
4509
|
this._identity = identity;
|
|
3778
4510
|
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
3779
|
-
if (
|
|
3780
|
-
|
|
4511
|
+
if (this._aid)
|
|
4512
|
+
this._logger.bindAid(this._aid);
|
|
3781
4513
|
if (this._sessionParams !== null) {
|
|
3782
4514
|
this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
|
|
3783
4515
|
}
|
|
@@ -3806,7 +4538,7 @@ export class AUNClient {
|
|
|
3806
4538
|
await this._uploadPrekey();
|
|
3807
4539
|
}
|
|
3808
4540
|
catch (exc) {
|
|
3809
|
-
_clientLog(
|
|
4541
|
+
this._clientLog.warn(`prekey 上传失败: ${formatCaughtError(exc)}`);
|
|
3810
4542
|
}
|
|
3811
4543
|
}
|
|
3812
4544
|
catch (err) {
|
|
@@ -3843,8 +4575,8 @@ export class AUNClient {
|
|
|
3843
4575
|
identity.access_token = accessToken;
|
|
3844
4576
|
this._identity = identity;
|
|
3845
4577
|
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
3846
|
-
if (
|
|
3847
|
-
|
|
4578
|
+
if (this._aid)
|
|
4579
|
+
this._logger.bindAid(this._aid);
|
|
3848
4580
|
const persistIdentity = this._auth._persistIdentity;
|
|
3849
4581
|
if (typeof persistIdentity === 'function') {
|
|
3850
4582
|
persistIdentity.call(this._auth, identity);
|
|
@@ -3923,8 +4655,6 @@ export class AUNClient {
|
|
|
3923
4655
|
this._startHeartbeatTask();
|
|
3924
4656
|
this._startTokenRefreshTask();
|
|
3925
4657
|
this._startGroupEpochTasks();
|
|
3926
|
-
// 上线/重连后一次性补齐群消息和群事件
|
|
3927
|
-
this._syncAllGroupsOnce().catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
|
|
3928
4658
|
}
|
|
3929
4659
|
/** 停止所有后台任务 */
|
|
3930
4660
|
_stopBackgroundTasks() {
|
|
@@ -3961,9 +4691,10 @@ export class AUNClient {
|
|
|
3961
4691
|
_startHeartbeatTask() {
|
|
3962
4692
|
if (this._heartbeatTimer !== null)
|
|
3963
4693
|
return;
|
|
3964
|
-
const
|
|
3965
|
-
if (
|
|
4694
|
+
const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
|
|
4695
|
+
if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
|
|
3966
4696
|
return;
|
|
4697
|
+
const interval = Math.max(rawIntervalSeconds, 30) * 1000;
|
|
3967
4698
|
// M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
|
|
3968
4699
|
// 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
|
|
3969
4700
|
// 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
|
|
@@ -3977,10 +4708,10 @@ export class AUNClient {
|
|
|
3977
4708
|
consecutiveFailures = 0;
|
|
3978
4709
|
}).catch((exc) => {
|
|
3979
4710
|
consecutiveFailures++;
|
|
3980
|
-
_clientLog(
|
|
4711
|
+
this._clientLog.warn(`心跳失败 (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
|
|
3981
4712
|
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) }).catch(() => { });
|
|
3982
4713
|
if (consecutiveFailures >= maxFailures) {
|
|
3983
|
-
_clientLog(
|
|
4714
|
+
this._clientLog.warn(`连续 ${maxFailures} 次心跳失败,触发断线重连`);
|
|
3984
4715
|
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
3985
4716
|
}
|
|
3986
4717
|
});
|
|
@@ -3994,31 +4725,36 @@ export class AUNClient {
|
|
|
3994
4725
|
_startTokenRefreshTask() {
|
|
3995
4726
|
if (this._tokenRefreshTimer !== null)
|
|
3996
4727
|
return;
|
|
3997
|
-
const
|
|
3998
|
-
const
|
|
3999
|
-
|
|
4728
|
+
const rawLead = Number(this._sessionOptions.token_refresh_before ?? DEFAULT_SESSION_OPTIONS.token_refresh_before);
|
|
4729
|
+
const lead = Number.isFinite(rawLead) && rawLead > 0
|
|
4730
|
+
? rawLead
|
|
4731
|
+
: DEFAULT_SESSION_OPTIONS.token_refresh_before;
|
|
4732
|
+
const scheduleNext = (delayMs = TOKEN_REFRESH_CHECK_INTERVAL_MS) => {
|
|
4000
4733
|
if (this._closing)
|
|
4001
4734
|
return;
|
|
4002
|
-
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
4003
|
-
this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
|
|
4004
|
-
this._unrefTimer(this._tokenRefreshTimer);
|
|
4005
|
-
return;
|
|
4006
|
-
}
|
|
4007
|
-
let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
|
|
4008
|
-
if (identity === null) {
|
|
4009
|
-
this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
|
|
4010
|
-
this._unrefTimer(this._tokenRefreshTimer);
|
|
4011
|
-
return;
|
|
4012
|
-
}
|
|
4013
|
-
this._identity = identity;
|
|
4014
|
-
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
4015
|
-
if (expiresAt === null) {
|
|
4016
|
-
this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
|
|
4017
|
-
this._unrefTimer(this._tokenRefreshTimer);
|
|
4018
|
-
return;
|
|
4019
|
-
}
|
|
4020
|
-
const delay = Math.max((expiresAt - lead - Date.now() / 1000) * 1000, minimumSleep);
|
|
4021
4735
|
this._tokenRefreshTimer = setTimeout(async () => {
|
|
4736
|
+
if (this._closing)
|
|
4737
|
+
return;
|
|
4738
|
+
this._tokenRefreshTimer = null;
|
|
4739
|
+
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
4740
|
+
scheduleNext();
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
|
|
4744
|
+
if (identity === null) {
|
|
4745
|
+
scheduleNext();
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4748
|
+
this._identity = identity;
|
|
4749
|
+
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
4750
|
+
if (expiresAt === null) {
|
|
4751
|
+
scheduleNext();
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
if ((expiresAt - Date.now() / 1000) > lead) {
|
|
4755
|
+
scheduleNext();
|
|
4756
|
+
return;
|
|
4757
|
+
}
|
|
4022
4758
|
if (this._closing || this._state !== 'connected' || !this._gatewayUrl) {
|
|
4023
4759
|
scheduleNext();
|
|
4024
4760
|
return;
|
|
@@ -4039,7 +4775,7 @@ export class AUNClient {
|
|
|
4039
4775
|
if (exc instanceof AuthError) {
|
|
4040
4776
|
this._tokenRefreshFailures++;
|
|
4041
4777
|
if (this._tokenRefreshFailures >= 3) {
|
|
4042
|
-
_clientLog(
|
|
4778
|
+
this._clientLog.warn(`token 刷新连续失败 ${this._tokenRefreshFailures} 次,停止刷新循环并触发重连`);
|
|
4043
4779
|
await this._dispatcher.publish('token.refresh_exhausted', {
|
|
4044
4780
|
aid: this._identity?.aid ?? null,
|
|
4045
4781
|
consecutive_failures: this._tokenRefreshFailures,
|
|
@@ -4049,17 +4785,17 @@ export class AUNClient {
|
|
|
4049
4785
|
this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
|
|
4050
4786
|
return;
|
|
4051
4787
|
}
|
|
4052
|
-
_clientLog(
|
|
4788
|
+
this._clientLog.debug(`token 刷新失败 (${this._tokenRefreshFailures}/3),下次重试: ${exc}`);
|
|
4053
4789
|
}
|
|
4054
4790
|
else {
|
|
4055
4791
|
await this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
4056
4792
|
}
|
|
4057
4793
|
}
|
|
4058
4794
|
scheduleNext();
|
|
4059
|
-
},
|
|
4795
|
+
}, delayMs);
|
|
4060
4796
|
this._unrefTimer(this._tokenRefreshTimer);
|
|
4061
4797
|
};
|
|
4062
|
-
scheduleNext();
|
|
4798
|
+
scheduleNext(0);
|
|
4063
4799
|
}
|
|
4064
4800
|
/** 启动 prekey 刷新任务 */
|
|
4065
4801
|
_startPrekeyRefreshTask() {
|
|
@@ -4151,7 +4887,7 @@ export class AUNClient {
|
|
|
4151
4887
|
this._prekeyReplenished.add(prekeyId);
|
|
4152
4888
|
}
|
|
4153
4889
|
catch (exc) {
|
|
4154
|
-
_clientLog(
|
|
4890
|
+
this._clientLog.warn(`消费 prekey ${prekeyId} 后补充 current prekey 失败: ${formatCaughtError(exc)}`);
|
|
4155
4891
|
}
|
|
4156
4892
|
finally {
|
|
4157
4893
|
this._prekeyReplenishInflight.delete(prekeyId);
|
|
@@ -4176,7 +4912,7 @@ export class AUNClient {
|
|
|
4176
4912
|
}
|
|
4177
4913
|
}
|
|
4178
4914
|
catch (exc) {
|
|
4179
|
-
_clientLog(
|
|
4915
|
+
this._clientLog.warn(`epoch 清理失败: ${formatCaughtError(exc)}`);
|
|
4180
4916
|
}
|
|
4181
4917
|
}, 3600_000);
|
|
4182
4918
|
this._unrefTimer(this._groupEpochCleanupTimer);
|
|
@@ -4192,11 +4928,11 @@ export class AUNClient {
|
|
|
4192
4928
|
? this._keystore.listGroupSecretIds(this._aid)
|
|
4193
4929
|
: [];
|
|
4194
4930
|
for (const gid of groupIds) {
|
|
4195
|
-
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => _clientLog(
|
|
4931
|
+
this._maybeLeadRotateGroupEpoch(gid).catch((exc) => this._clientLog.warn(`epoch 轮换失败: ${formatCaughtError(exc)}`));
|
|
4196
4932
|
}
|
|
4197
4933
|
}
|
|
4198
4934
|
catch (exc) {
|
|
4199
|
-
_clientLog(
|
|
4935
|
+
this._clientLog.warn(`epoch 轮换失败: ${formatCaughtError(exc)}`);
|
|
4200
4936
|
}
|
|
4201
4937
|
}, rotateInterval * 1000);
|
|
4202
4938
|
this._unrefTimer(this._groupEpochRotateTimer);
|
|
@@ -4244,7 +4980,7 @@ export class AUNClient {
|
|
|
4244
4980
|
_onGatewayDisconnect(data) {
|
|
4245
4981
|
const code = data?.code;
|
|
4246
4982
|
const reason = data?.reason ?? '';
|
|
4247
|
-
_clientLog(
|
|
4983
|
+
this._clientLog.warn(`服务端主动断开: code=${code}, reason=${reason}`);
|
|
4248
4984
|
this._serverKicked = true;
|
|
4249
4985
|
}
|
|
4250
4986
|
/** 传输层断线回调 */
|
|
@@ -4265,7 +5001,7 @@ export class AUNClient {
|
|
|
4265
5001
|
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
4266
5002
|
this._state = 'terminal_failed';
|
|
4267
5003
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
4268
|
-
_clientLog(
|
|
5004
|
+
this._clientLog.warn(`抑制自动重连: ${reason}`);
|
|
4269
5005
|
await this._dispatcher.publish('connection.state', {
|
|
4270
5006
|
state: this._state, error, reason,
|
|
4271
5007
|
});
|
|
@@ -4282,7 +5018,7 @@ export class AUNClient {
|
|
|
4282
5018
|
this._reconnectActive = true;
|
|
4283
5019
|
this._reconnectAbort = new AbortController();
|
|
4284
5020
|
this._reconnectLoop(serverInitiated).catch((exc) => {
|
|
4285
|
-
_clientLog(
|
|
5021
|
+
this._clientLog.warn(`重连循环异常: ${formatCaughtError(exc)}`);
|
|
4286
5022
|
});
|
|
4287
5023
|
}
|
|
4288
5024
|
/** 重连循环(for 循环 + AbortController,与 JS/Python 对齐) */
|
|
@@ -4368,6 +5104,69 @@ export class AUNClient {
|
|
|
4368
5104
|
}
|
|
4369
5105
|
this._reconnectActive = false;
|
|
4370
5106
|
}
|
|
5107
|
+
// ── Named Group(命名群)高层 API ────────────────────────────
|
|
5108
|
+
/**
|
|
5109
|
+
* 创建命名群:本地生成 P-256 keypair,调用 group.create 传入 public_key,
|
|
5110
|
+
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5111
|
+
*/
|
|
5112
|
+
async createNamedGroup(groupName, opts = {}) {
|
|
5113
|
+
const cp = new CryptoProvider();
|
|
5114
|
+
const identity = cp.generateIdentity();
|
|
5115
|
+
const params = {};
|
|
5116
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
5117
|
+
params[k] = v;
|
|
5118
|
+
}
|
|
5119
|
+
params.group_name = groupName;
|
|
5120
|
+
params.public_key = identity.public_key_der_b64;
|
|
5121
|
+
params.curve = 'P-256';
|
|
5122
|
+
const result = await this.call('group.create', params);
|
|
5123
|
+
const groupInfo = result?.group;
|
|
5124
|
+
const aidCert = result?.aid_cert;
|
|
5125
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5126
|
+
if (groupAid && aidCert) {
|
|
5127
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5128
|
+
private_key_pem: identity.private_key_pem,
|
|
5129
|
+
public_key: identity.public_key_der_b64,
|
|
5130
|
+
curve: 'P-256',
|
|
5131
|
+
type: 'group_identity',
|
|
5132
|
+
});
|
|
5133
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5134
|
+
if (certPem) {
|
|
5135
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
return result;
|
|
5139
|
+
}
|
|
5140
|
+
/**
|
|
5141
|
+
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5142
|
+
*/
|
|
5143
|
+
async bindGroupAid(groupId, groupName) {
|
|
5144
|
+
const cp = new CryptoProvider();
|
|
5145
|
+
const identity = cp.generateIdentity();
|
|
5146
|
+
const params = {
|
|
5147
|
+
group_id: groupId,
|
|
5148
|
+
group_name: groupName,
|
|
5149
|
+
public_key: identity.public_key_der_b64,
|
|
5150
|
+
curve: 'P-256',
|
|
5151
|
+
};
|
|
5152
|
+
const result = await this.call('group.bind_aid', params);
|
|
5153
|
+
const groupInfo = result?.group;
|
|
5154
|
+
const aidCert = result?.aid_cert;
|
|
5155
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5156
|
+
if (groupAid && aidCert) {
|
|
5157
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5158
|
+
private_key_pem: identity.private_key_pem,
|
|
5159
|
+
public_key: identity.public_key_der_b64,
|
|
5160
|
+
curve: 'P-256',
|
|
5161
|
+
type: 'group_identity',
|
|
5162
|
+
});
|
|
5163
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5164
|
+
if (certPem) {
|
|
5165
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
return result;
|
|
5169
|
+
}
|
|
4371
5170
|
/** 判断是否应重试重连 */
|
|
4372
5171
|
static _shouldRetryReconnect(error) {
|
|
4373
5172
|
if (error instanceof AuthError) {
|